---
id: "packages/active-grid/changelog"
title: "Changelog"
description: "What changed for users of active-grid, per version."
url: "/packages/active-grid/changelog"
locale: "en"
revision: "dbc0cc89a4f476a0"
---

# Changelog

> [!NOTE]
> **Release state**
>
> The version line at the top of this page is read from the package and the
> release register of Foundation. A recorded release counts only for exactly the
> content it recorded: when the package changed after it, this site does not
> call that version released. The section Unreleased lists what changed since,
> including the changes made since Foundation maintains the package.

Notable changes to `@workspace/active-grid`. Format based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning per
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

For architectural rationale of larger changes see
`docs/DECISIONS.md`.

---

## \[Unreleased\]

### Maintained in Foundation (since 2026-10-21)

- **Dependencies: `@workspace/active-helpers` replaces `@workspace/active-utils`.**
  The serialization, export-font and menu helpers the grid uses
  (`./serialization`, `./export-fonts`, `./menu-items`) now come from
  `@workspace/active-helpers`, the one package that owns them, instead of the
  `portable-helpers` selection of `@workspace/active-utils`. Same functions,
  same behaviour; the grid's public API is unchanged. A host that receives
  the grid receives `active-helpers` with it (the transfer closure names it).
- **Fixed: a wide grid no longer widens the page.** `.grid-root` is now
  `position: relative`, the containing block of the grid's absolutely
  positioned parts (visually hidden texts, the skip-link target, the
  announcer). Before, those took a containing block outside the grid: a grid
  wider than its host's scroll container (a phone, a narrow column) made the
  whole page scroll sideways. Found on the documentation site at 390 px.
- **Tests run without the source repository.** The source contracts
  (`candidate-boundary`, `consumer-precheck`, `runtime-reach`) read through a
  package-local helper (`src/__test-utils__/files-if-present.ts`) instead of
  the host's `repo-tools`; one test sorts with a local machine-order
  comparator instead of `@workspace/active-utils/sorting`. The scrollbar test
  checks the grid's own thumb tokens and no longer reads one host's brand
  mapping of them (that mapping is the host's test).
- **Documentation:** references to documents of the source repository are
  text, marked `(source repository: …)`, instead of links that resolve nowhere here.

### Changed — BREAKING for the managed package set (ADR-059)

- **Formatting comes from `@workspace/active-format`**, not from the
  `formatters` entry of a translation package. The manifest declares
  `@workspace/active-format` instead of `@workspace/active-i18n`; the package
  imports nothing from a translation package any more. No prop, export or
  behaviour of active-grid changed: its presentation type is still the
  `ValuePresentation` union, now owned by `@workspace/active-format`.
- A consumer that formats next to active-grid with the same functions imports
  them from `@workspace/active-format`.

---

## \[11.0.0\] - 2026-10-18 - The canonical transfer form, and the first recorded release

### Changed — canonical transfer form (2026-10-18, platform ADR-112)

- **The manifest is the transferable manifest** (RPS-D7): no workspace
  package in `devDependencies` (what the package's own development takes from
  its host repository, such as the lint preset, is host tooling in the
  reusable package register); the declaration packages of what the source
  imports sit in the field of their module (`@types/react` and
  `@types/react-dom` as optional peers beside the `react` peers). Nothing
  changes for an existing consumer.
- **`tsconfig.json` is self-contained**: it no longer extends the host
  repository's shared TypeScript preset, with the same compiler options,
  because a bundler in a consumer reads it for every source file it compiles.

### Changed (2026-10-04, one menu pipeline and behaviour with active-charts, ADR-058)

- **Added (G-1):** custom context-menu items nest to any depth; a `children`
  inside `children` is a submenu of the submenu.
- **Added (G-2):** a disabled item's `disabledReason` is exposed as
  `aria-description` (it was a `title` only), on submenu triggers too.
- **Changed (G-3):** the resolve pipeline is `resolveMenuItems` from
  `@workspace/active-utils/menu-items`, shared with Active Charts; same
  semantics, the permission check runs as its `keep` step.
- **Changed (keyboard):** a menu opened by Shift+F10 or the ContextMenu key
  focuses its first item; Tab closes the menu (not during IME composition).
- **Changed (touch):** under a coarse pointer every menu item is at least
  44 px high.

### Changed (2026-10-04, a declared menu permission fails closed)

- **Behaviour change:** a context-menu item that declares `permission` is no
  longer shown when no `hasPermission` checker answered it. Before, a menu
  built without a checker (or passed directly as a `ContextMenuBuilder`)
  ignored `permission` and showed the item to every viewer. Now
  `createContextMenu` without a checker leaves such an item out, the render
  boundary keeps an item with `permission` only when the factory approved it,
  and it reports a dropped item through the `<GridDefaults diagnostics>` sink
  (`warn`, source `ActiveGrid.ContextMenu`, `data: {label, permission}`).
  The factory returns approved items as fresh copies; the consumer's objects
  are never mutated. **Migration:** a menu that declares rights passes the
  viewer's checker: `createContextMenu({hasPermission, customItems})`.
  The report reaches the sink outside production only, like the other
  configuration warnings; leaving the item out does not depend on it.

### Changed (diagnostics family parity, R-58, ADR-057, 2026-10-02)

- **Every diagnostic reaches the `GridDefaults` sink.** The thirty
  configuration warnings that wrote to `console.warn` directly now go through
  `<GridDefaults diagnostics>` (source `ActiveGrid`), still outside
  production only. Without a sink they reach the console as before, as
  `[ActiveGrid] …`.
- The currency, percent and calendar-day preset warnings are reported once
  per column when the columns are defined, no longer once per rendered cell.
  The CSV separator and the capability-drift warning of
  `createResourceServerFetchFn` are reported once per ground; the drift
  carries both capability maps as structured `data`.
- Messages no longer repeat an `[active-grid]` or `[ActiveGrid]` prefix.
- No public API change.

### Fixed (re-convergence, 2026-09-30)

- **Server mode: a re-render no longer restarts the adjacent-block prefetch.** The prefetch effect depended on a page object the block cache creates on every render. Every host re-render therefore aborted and reissued the request for the next block.
- **Server grouping: a load-more that settles after a sort, filter or grouping change is dropped.** Before, its rows were appended to the group in the new dataset, and a failure set the grid's error. Only the first page of a group was checked against the dataset.
- **Undo: a failed undo keeps what was already undone redoable.** The failure path re-recorded the entry, which cleared the whole redo stack.
- **A select filter restored from the URL matches again.** The URL brings a numeric-looking option code back as a number, and the select predicate compared it with the cell's written form. Numbers are now compared by their written form; other non-strings still never match (the string-key contract of `filterOptions`).
- **Group labels are isolated bidi runs.** The client group label, the server group value and the grouping chip render `dir="auto"` with `.grid-isolate` (ADR-052 point 4).
- **The lookup dialog's hint has its style again.** It is small, muted text in the footer.

### Changed (portable runtime P9, ADR-056)

- **`@date-fns/tz` is a declared dependency.** The inlined formatter closure imports it; an install outside the workspace reads only this manifest, so the package now declares it. No runtime change.
- **Documentation.** README, functional overview and the `locale` prop describe the standalone product (no Next, no active-ui, the stylesheet import, the time-zone requirement of date filters and calendars, optional export backends as bundler externals).

### Documentation (portable runtime P8D, ADR-055)

- **Export guarantees.** `docs/guides/export-guarantees.md` states the tiers L0 to L3b-print per format and script, the route to take, the error kinds, a host font example and the host L3b recipes. No runtime change.

### Changed (portable runtime P8C, ADR-054)

- **BREAKING (behaviour): the PDF refuses what it cannot draw.** Without an
  export font set, text outside WinAnsi fails with `GridExportError`
  `EXPORT_FONT_REQUIRED` instead of drawing broken glyphs; complex and
  right-to-left scripts, residual combining marks, joiners and variation
  selectors, emoji and supplementary-plane characters fail with
  `EXPORT_TEXT_UNSUPPORTED` whatever the fonts. WinAnsi-only PDFs without a
  set are unchanged.
- **`xlsx`, `jspdf` and `jspdf-autotable` are optional dependencies.** The
  package root no longer reaches them; a missing one fails its export with
  `EXPORT_DEPENDENCY_MISSING` (`missingModule`).

### Added (portable runtime P8C, ADR-054)

- **Export fonts for the PDF.** `GridExportFonts` (with
  `GridExportFontFamily`, `GridExportFontFace`, `GridExportFontSource`): host
  families in fallback order, faces as bytes or lazy loaders. Accepted by
  `GridPdfExportOptions.fonts`, the grid's `export.fonts` and `GridDefaults`'
  `export.fonts`, in that precedence; `'inherit'` uses no set. Each cell
  draws in the first family that covers all of it (`EXPORT_GLYPH_COVERAGE`
  otherwise); the flat, grouped and server-grouped PDF share one pipeline.
  Structurally the charts' `ChartExportFonts`.
- **`GridExportError`** (`GridExportErrorCode`, `GridExportErrorOptions`):
  stable codes, metadata without exported text.
- **Export direction.** `GridExportDirection`; `GridPdfExportOptions.direction`
  puts the first column on the right; `GridExcelExportOptions.direction`
  opens the sheet right to left (`Workbook.Views[0].RTL`). The row menu passes
  the grid's resolved direction. Values stay logical and typed.

### Fixed (portable runtime P8C, ADR-054)

- **The PDF export works in the browser.** It called `doc.autoTable(...)`,
  which jspdf-autotable 5 installs only on a global `window.jsPDF` that the
  ES build of jsPDF never sets (measured: `TypeError: doc.autoTable is not a
  function`). It now calls `autoTable(doc, options)`.

### Changed (portable runtime P6, BW-3, ADR-053)

- **BREAKING: pinning is logical.** `ColumnPinningPosition` is
  `false | 'start' | 'end'`, `ColumnPinningState` is `{start, end}`,
  `column.pin('start' | 'end')`; the readers are `getStart*`/`getEnd*`
  (`getStartVisibleLeafColumns`, `getEndHeaderGroups`, …) and header group
  names `start_*`/`end_*`. No alias for `left`/`right`.
- **BREAKING: `columnResizeDirection` is removed.** The grid's one resolved
  direction reaches the options as `direction`.
- **BREAKING: persisted state version 3.** A version 2 payload is cleared on
  load; a physical `left`/`right` pinning slice is dropped.
- **RTL.** `dir="rtl"` (or an inherited rtl) lays the grid out mirrored:
  pinning, scrolling, column virtualization (active-virtual reads the rtl
  offset), header/body sync (TD-14 fixed), resize, reorder, keyboard
  (ArrowLeft/Right visual, Home/End logical), selection, menus (submenus
  toward the inline end), filters and the calendar; directional icons mirror.
  `cellAlign: 'right'` is the inline end.
- **Formatted values read in their locale's direction.** A value renders
  with its presentation locale's script direction where that differs from
  the grid's (`GridValue`), so an ltr-locale date keeps its order in an rtl
  grid.

### Added (portable runtime P5, ADR-052)

- **`dir` prop.** A grid resolves one UI direction: the explicit `dir`,
  else the page's direction (the nearest `dir` attribute, followed when it
  changes), else `ltr`. Never the locale. Every popup the grid opens carries
  it, and Base UI's keyboard semantics follow it. Until P6 a grid that
  resolves `rtl` throws `GridProjectionFault('unsupported-direction')`; an
  invalid `dir` throws `GridConfigurationError` (`GRID_DIRECTION_INVALID`).
- **`meta.textDirection`** (`GridTextDirection`: `auto`, `ltr`, `rtl`).
  Cell text, headers, filter options and the cell tooltip render as isolated
  runs: free text `auto`, formatted values inheriting, codes as declared.
  A right-to-left column name in an `aria-label` or announcement is isolated
  with FSI/PDI; Latin labels in an `ltr` grid are unchanged. Exports and the
  clipboard carry no bidi controls.

### Changed (portable runtime P4, BW-2, ADR-051)

- **BREAKING: an unsupported locale throws.** A grid whose presentation
  names a tag the formatters cannot honour (malformed, without Intl data, a
  non-Gregorian `-u-ca-` or an unknown `-u-nu-`) fails with
  `UnsupportedLocaleError` from `@workspace/active-i18n/formatters` instead of
  rendering English. Any other BCP 47 tag Intl supports formats in its own
  convention and digits.
- **The calendar accepts every supported locale.** Month and weekday names in
  the Gregorian calendar, accessible labels from Intl ("Next month",
  "today"), day numbers in the locale's digits, and the week start from
  `Intl.Locale` week info (Monday without it). A `calendarLocale` object that
  carries its own `labels` keeps them. The previous five-language refusal is
  gone.
- **Number input reads any supported locale.** The number editor and the
  numeric filters use the one portable parser: the locale's decimal
  separator, Indian and other grouping on paste, every Unicode digit set and
  the U+2212 minus. Parentheses and a trailing minus stay unsupported.
- **IME composition is left to the input method.** While an input method
  composes, Enter, Escape, Tab and the arrows neither commit, cancel nor move
  the active cell.

### Changed (portable runtime P3, BW-1, ADR-049, ADR-050)

- **BREAKING: the grid ships its own stylesheet.** Import
  `@workspace/active-grid/styles.css` once, after declaring the cascade layer
  order (`… base, active-grid, components, utilities`). All rules are plain
  CSS in `@layer active-grid`; no Tailwind scan and no design-system
  stylesheet is needed. A neutral light and dark baseline is built in; a host
  brands the grid by mapping the `--grid-*` role tokens (`--grid-surface`,
  `--grid-primary`, `--grid-border`, `--grid-ring`, `--grid-status-<tone>`,
  the radius and type scale, …). Previously unlayered, the grid's rules now
  lose to a host's utility layer, so a host class on a grid element takes
  effect.
- **BREAKING: `StatusTone` → `GridStatusTone`** (same five values) for
  `meta.statusTones` and `<StatusCell tone>`. The grid no longer exposes any
  type from `@workspace/active-ui`; `FilterPopoverContent` takes the grid's own
  popover props.
- **BREAKING: `LinkCell` renders a native `<a href>`** for internal links.
  A host with a client router passes its link once via
  `<GridDefaults ui={{linkComponent}}>` (`GridLinkComponent`,
  `GridLinkProps`: `{href, className, children}`). External links are
  unchanged.
- **BREAKING: default menu classes.** `ui.contextMenu` and `ui.headerMenu`
  default to the package classes `grid-menu-popup--context` /
  `grid-menu-item--context` and `grid-menu-popup--header` /
  `grid-menu-item--header` (same sizes as before).
- The grid renders on an internal primitive layer (Base UI and
  react-day-picker); its markup names only `grid-*` classes.
- **Dependencies:** `@workspace/active-ui` and `tailwind-merge` removed,
  `react-day-picker` added; the `next` peer removed. Peers are `react` and
  `react-dom` only.

### Changed (portable runtime P1, ADR-047)

- **Diagnostics go to a host sink.** `<GridDefaults diagnostics>` takes a
  `GridDiagnosticSink` (`{level: 'debug' | 'warn' | 'error', source, message,
  data?}`, a caught error under `data.error`); nearest boundary wins. Without
  one, `warn` and `error` go to the console and `debug` is dropped. New public
  types `GridDiagnostic`, `GridDiagnosticLevel`, `GridDiagnosticSink`.
  `copyToClipboard` options take `diagnostics` too.
- **BREAKING (internal consumers only):** a misconfiguration throws the new,
  public `GridConfigurationError` (`code` `GRID_ROW_ID_MISSING` or
  `GRID_SERVER_OPTIONS_INVALID`, `field`, `constraint`, `value`) instead of
  `ValidationError` from `@workspace/active-errors`. Messages are unchanged.
- `CellErrorBoundary` is a function component over an internal class
  boundary; its props and rendering are unchanged.
- **Dependencies:** `@workspace/active-logging` and `@workspace/active-errors`
  removed. The CSV/decimal helpers come from
  `@workspace/active-utils/serialization` instead of `/helpers`.

### Added (FAM-V2-T6, ADR-046)

- **The outcome of a menu export reaches the host.**
  `GridExportOptions.onExportComplete(format, scope)` fires after the file was
  handed to the browser; `onExportError(format, error, scope)` when no file was
  produced. Before, a failed menu export was only logged and the host could
  not tell. Without `onExportError` the failure is still logged, never
  swallowed. New type `GridExportScope` (`'page' | 'loaded' | 'selection'`).
  Additive; the programmatic export functions keep reporting by throw,
  rejection and return.

### Breaking (FAM-V2-T4, ADR-045)

- **`GridDefaults` replaces `GridConfigProvider`.** One public defaults
  boundary carries the presentation (`locale`), the translator (`t`) and the
  `PartialGridConfig` sections as top-level props. Migration:
  `<GridConfigProvider config={{storage: {...}}}>` becomes
  `<GridDefaults storage={{...}}>`. Nested boundaries refine the enclosing one;
  presentation and translator are replaced whole, sections merge one level
  deep, `filtering.operatorPresets` per name.
- **No implicit English.** A grid without a `t` prop and without an inherited
  `<GridDefaults t>` throws a configuration error. The English table is the
  exported `englishGridTranslator`; pass it on purpose for standalone use,
  fixtures and examples. The operator factories (`getTextOperators`, …,
  `getOperatorsForType`) take a required translator, and the English-baked
  `TEXT_OPERATORS`, `NUMBER_OPERATORS`, `DATE_OPERATORS` and
  `BOOLEAN_OPERATORS` are removed.
- **Built-in column types are the package default.** Every grid applies them
  without a prop; `columnTypes` extends or overrides per type name.
  `getDefaultColumnTypes` leaves the public API: remove
  `columnTypes={getDefaultColumnTypes()}` and spread nothing into a custom map.
- **Provider surface narrowed.** `GridTranslationProvider`,
  `useGridCalendarLocale`, `useGridConfig`, `DEFAULT_GRID_CONFIG`,
  `GridProvider`, `FlexLayoutContext` and `FlexLayoutContextType` are internal.
  `useGridTranslation` and `useGridLocale` stay public and return the
  effective values of the enclosing grid.
- **JavaScript theme layer removed** (FAM-W6): the `theme` prop,
  `createGridTheme`, `GridTheme`, `gridThemes`, `GridThemePreset`,
  `compactTheme`, `comfortableTheme` and `financialTheme`. The grid CSS stays in
  active-ui; a scoped override sets a `--grid-*` value on `.grid-root` through
  `className`. Row height has one authority (`settings.rowHeight`, then
  `layout.rowHeight`). The per-grid scrollbar thickness setting went with it;
  the lane is 10px.

### Changed

- `locale` is an optional instance override on `ActiveGrid` and `LookupGrid`:
  the prop, else the nearest `<GridDefaults locale>`, else a configuration
  error. `t` on `LookupGrid` follows the same rule.
- `GridPresentation` is an alias of the active-i18n `ValuePresentation`
  (active-i18n D23); the type is unchanged.
- The filter builder takes a condition's number and date in the grid's own
  controls instead of native `number` and `date` inputs. A number is typed in
  the notation of the grid's presentation, as in the number filter, and stored
  as a number; a date is typed in the presentation's date notation or picked
  from a calendar behind its own button, and stored as an ISO calendar day.
  Half-typed or unreadable input leaves the condition's value as it was, and
  emptying the field makes the value absent (it was `''`, which a number
  operator read as `0`). The operator stays a select, as in the column filters.
- The pagination footer counts selected rows without building a row view per
  selected row. The displayed count is unchanged.
- Row and column virtualization run on `@workspace/active-virtual` 2.0.0
  (`useVirtualAxis`). Observable behavior is kept: the same windows, overscan,
  scroll-to-cell behavior and column resize; the measured row heights are now
  reset before the browser paints after a change of the row model, and the
  whole scroll range stays reachable for keyboard navigation to the last row
  when pinned rows or a footer follow the rows. No public API of the grid
  changes.
- Diagnostic severity follows the repository meaning of ERROR and WARN.
  Recovered or expected conditions now log at WARN instead of ERROR: a row
  that is gone when an edit starts, commits or is buffered in batch mode, an
  edit cancelled because its row left the data, a refused persistence write
  (quota, private mode) and a refused clipboard write (permission; the
  function still returns `false`). Commit and batch-commit failures, export,
  render and fetch failures, and the refusal of a group row stay at ERROR.
  Messages are unchanged.

### Fixed

- An incomplete filter-builder condition no longer filters rows.
  `evaluateFilterExpression` ignores a condition until its operator has every
  value it reads, as its documentation already said: adding a number condition
  before typing its value used to hide every row. `0` and `false` still filter,
  a range needs both ends, and `isEmpty` / `isNotEmpty` stay active without a
  value. An AND or OR group judges only its complete conditions (an incomplete
  range inside an OR group used to match every row), and a group with none
  matches every row.
- The filter-builder toolbar button counts only conditions that filter rows,
  on the same rule as the evaluator. A condition still waiting for its value no
  longer shows a count or marks the button active; `isEmpty` / `isNotEmpty`
  count as soon as they are chosen. The panel's own count still covers every
  condition in it.
- When row virtualization switches on while the reader is scrolled (rows
  loaded past `virtualizationThreshold`, or client data replaced by a larger
  array), the record at the top of the viewport stays there, at the same
  distance into it. The two bodies can lay rows out at different heights, so an
  unchanged `scrollTop` showed another record, up to many rows away. A grid at
  the top stays at the top; horizontal scroll is untouched.
- Undo, redo and the edit history act only while the grid is mounted. An undo
  or redo whose `onDataCommit` finishes after the grid was unmounted no longer
  patches the old grid's rows, calls the consumer's `getRowId` or rewrites its
  history, also when the call is rejected; an ordinary commit finishing after
  unmount records no history. The consumer's write itself still completes.
- A client group row's label cell no longer carries `data-wrap="true"` from a
  `wrapText` column: the label stays on one line, as on a server group row. A
  label too wide for its column now ends in an ellipsis with its full text as
  `title` (client and server), and the item count no longer wraps below it.
- `wrapText` with the default text renderer now actually wraps. The renderer
  truncated by default whatever the column said, so a `wrapText` column,
  including one with `autoHeight`, still showed one line with an ellipsis and
  its row never grew. With `truncate` unset, a `wrapText` column now renders
  untruncated text on every default text path (plain, `valueFormatter` text,
  number, currency, percent and date semantics, and the empty-value
  placeholder), so the text breaks over lines and `autoHeight` rows grow to
  hold it. An explicit `truncate: true` still truncates, an explicit
  `truncate: false` still does not, columns without `wrapText` are unchanged,
  and custom `cell` renderers are untouched.
- Under row virtualization, a toolbar search now resets the measured row
  heights, as sorting, column filters, grouping and expansion already did. A
  search puts other records at the row positions; an auto-height row kept the
  height measured for the record that was there before.
- A measured row under row virtualization now keeps its own height when rows
  are pinned to the top. The row reported its display position (pinned rows
  first) to the row virtualizer, which counts only the unpinned rows, so every
  measured height landed on the row that many positions further down: an
  auto-height row was clipped to the estimate while its neighbour grew, and the
  last row's measurement was dropped. Server group and load-more rows had the
  same path. Keyboard navigation, cell ranges and `aria-rowindex` keep the
  display position.
- Ctrl/Cmd+Shift+Z now redoes, and Ctrl/Cmd+Z and Ctrl/Cmd+Y also work with
  Caps Lock on. The handler compared the letter in lower case only, while a
  browser reports it in upper case under Shift or Caps Lock.
- Documentation matches the source again: `enableUndoRedo` defaults to `true`
  when `onDataCommit` is set, records only single-column commits and keeps its
  history per mounted grid; the cell editors are the grid's own number input
  and a calendar-backed date input, not native `type="number"`/`type="date"`
  fields; `gridRef.removeRow` is a post-commit patch, not an optimistic update;
  the exclusive-selection recipe states that the consumer translates the
  `SelectionQuery` itself; the header checkbox is three-state for assistive
  technology and two-state on screen. The `calendarLocale` docblocks no
  longer show a locale the calendar refuses. Consumer guides and API pages
  that were partly Dutch are English.
- A "Save all" that completes while the user kept editing no longer erases
  the newer edits (CR2-02). Its completion cleared the whole dirty buffer:
  a cell changed again after the save began was marked clean although it was
  never sent, and when `onBatchCommit` returned rows the returned (older)
  value replaced the newer one on screen. The completion now settles only the
  cells that save sent. A cell edited again, a cell first edited meanwhile and
  a cell edited back to its old value stay dirty with the value on screen,
  keep "Save all" available, and go out with the next save; a returned row no
  longer overwrites a cell that is still dirty. A rejected save keeps every
  change, and a second "Save all" while one is pending sends nothing. With no
  edit during the save, nothing changes.
- Unmounting the grid ends a pending edit (CR-01). A validator that settles
  after the grid unmounted no longer leads to an `onDataCommit` call, in cell
  and row mode, and a pending batch flush no longer fills the buffer. A write
  the consumer already received finishes; the grid then patches no row and
  opens no editor. The same holds for "Save all".
- A write that finishes after the user opened another cell no longer replaces
  that edit (CR-02). Enter, Tab and a `commitOnBlur` switch opened the next
  cell as soon as the earlier write completed, closing the newer editor and
  discarding what was typed in it. They now move on only when the committed
  edit is still the one in progress; the newer editor keeps its exact draft.
  Normal Enter and Tab progression, and a refused commit that stays put, are
  unchanged.
- `aria-rowcount` and the body's `aria-rowindex` no longer count a filter row
  that is not rendered (R-54). A grid with one header row and no filter row
  announced its first body row as row 3 and one row too many in total; it now
  announces row 2. Grouped header levels, pinned rows, the virtual body and
  the group and "load more" rows of server grouping follow the same count.
  With a rendered filter row nothing changes.
- An edit that ends while its validation is pending no longer commits
  (IR-01). Removing the row, cancelling the edit, starting another edit or
  "Discard all" in batch mode now ends the pending commit: `onDataCommit` is
  not called and nothing enters the batch buffer when the validator settles
  later. A validator's late message is no longer written under a cell opened
  afterwards. A live edit still commits exactly once. With `commitOnBlur`,
  moving to another cell commits the open edit first and opens the next one
  once that commit is accepted; a refused value keeps the open edit.
- A grid with multi-row selection declares `aria-multiselectable="true"`
  also without cell selection (IR-02). Assistive technology was told the grid
  could not select several rows while it could.
- The header checkbox announces a partly selected page as
  `aria-checked="mixed"` instead of checked (IR-03). Its look and what a click
  on it does are unchanged.
- Turning `enableCellSelection` off ends every range, reported once as `[]`
  (IR-04). The range stayed stored out of sight: it came back when the
  feature was turned on again, and while off it still took the Ctrl+C hint
  from the selected-rows item in the context menu.
- Server group rows carry `aria-rowindex` like every other body row (IR-05),
  so group, member and load-more rows form one unbroken sequence, also in a
  virtualized body.
- `createResourceServerFetchFn`'s `onError` also fires for a read that fails
  by a rejecting or throwing action or a throwing `transform` (IR-06), once,
  after the retries are spent. It fired only for `{success: false}`. Redeemed
  and superseded attempts still notify nobody.
- The server-grouping "load more" row works without a pointer (R-44). Its
  cells are cells of the keyboard navigation: the arrow keys reach the row,
  and Enter or Space on any of its cells loads the group's next block once,
  the same action a click takes. The row is named by its label, its cells
  announce the keys (`aria-keyshortcuts`), and it is `aria-busy` while the
  block loads; keys pressed in that time ask for nothing more. When the block
  lands, the active cell keeps its position, which then holds the first new
  row, and the focus is there. Before, Arrow Down moved the grid's position
  onto the row while the focus and the mark stayed on the row above, and Enter
  loaded from a row the reader could not see was active.
- Focus that a replaced cell held returns to the active cell in every
  browser. The recovery relied on the `focusout` a removed element dispatches,
  which not every engine does; a cell that loses its registration while it
  holds the focus now takes the same path.
- `pagination={{enabled: false}}` no longer pages a client grid (R-51). The
  pager was hidden while the rows were still cut to the page size, so a grid
  of 500 rows showed ten and the rest were out of reach. Every filtered and
  sorted row is now presented, a page size is ignored, and row virtualization
  still limits what is mounted; `aria-rowindex` counts from one. Server grids
  are unchanged. Consumer grids with pagination off now show all their rows.
- A column width set by dragging survives a reload and every later viewport
  resize (TD-50 b). The flex layout ran before the stored column locks were
  read and wrote its own width over the stored one; no layout pass now runs
  before the locks are known.
- `createResourceServerFetchFn`'s `onError` fires once per failed read, after
  the grid's retries are spent (TD-50 f). It fired for every attempt: one
  failure gave up to four toasts, and a read a retry redeemed still gave one.
  Retries and the grid's own diagnostics are unchanged.
- A second click on the active cell changes nothing. It used to drop the
  cell's ring while its corner cue stayed, a half-drawn state.
- Escape never leaves half a selection on screen: with a range it ends the
  range and leaves exactly the look of a click on the active cell; without
  one it changes nothing. It used to leave the ring and remove only the cue.
- The first click after a reload no longer draws its mark bright and then
  dim. The row under the pointer faded its hover background in over 100 ms,
  and a browser applies no hover after a reload until the first pointer event,
  so that fade started under a mark that had just been drawn. The hover
  background now changes at once.
- After a drag, the focus and the grid's active cell are the same cell (the
  drag's start). The press focused the start cell while the active cell stayed
  where it was, so the next arrow key moved from a cell the reader had left.
- With rows selected, a click on a cell (including on a row's checkbox) no
  longer takes Ctrl/Cmd+C from the rows. The click left a one-cell range, which
  went before the selected rows and, on the checkbox column, copied nothing.
- A right-click on a body cell outside a range makes that cell the active
  cell. It already took focus natively while the grid kept its previous active
  cell, so after the menu closed the next arrow key continued from a cell the
  reader had left.
- `rowMenu.enabled: false` is honoured: no row offers the grid's context menu.
  The setting was declared and documented but not read.
- The row menu's Copy copies a pinned row whose data position is on another
  page. It looked the row up in the current page and wrote nothing.
- Keyboard operations on a pinned row act on the row shown there (R-47). They
  used to act on the row at that index of the data order:

  - Enter calls `onCellActivate`, or starts the edit, for the pinned row, and
    for the centre rows next to it.
  - ArrowLeft/ArrowRight on a server group row expands or collapses the group
    that has focus. It no longer toggles the group at a pinned member row's
    data index.
  - Arrow navigation past a column span uses the span of the row shown.
- Cell-range selection and copy (R-45), with `enableCellSelection`:

  - Shift+click extends the selection from the active cell, which stays the
    anchor, to the clicked cell. It used to select only the clicked cell.
  - A range copy and a single-cell copy (Ctrl/Cmd+C) now go through the same
    clipboard TSV boundary as the row copy: a value a spreadsheet would run as
    a formula is neutralized with a leading apostrophe, and a value with a
    tab, line break or quote is quoted. The copied text is still the
    presentation form.
  - A range ends when a sort, filter, row pin or column move, hide or reorder
    puts other rows or columns at the positions it covers; one that leaves
    those positions in place keeps it. A range used to survive such a change
    and copy the data that had moved in. It no longer ends merely because the
    row count changed.
  - Ctrl/Cmd+C reports its outcome through `meta.onCopySuccess` and
    `meta.onCopyError`, once per copy, like the row menu's copy. The count is
    the number of rows copied: the selected rows, the rows a cell range spans,
    or 1 for a single cell.
  - A range or active cell on a pinned row copies the row shown there; it used
    to copy the row at that index of the data order.
  - Ctrl/Cmd+C copies a range made with the mouse alone; it used to do
    nothing.
- Clearing a number filter right after an operator change clears it; the
  still-debounced value no longer re-applies the filter under the default
  operator (TD-50). An operator change re-applies the typed value as a number,
  not as the input's string.
- A column drop lands on the side of the target header where the pointer is
  released. It used to read the side the last `dragover` had written to React
  state, which a drop in the same frame could see before it rendered (TD-50).
- `unpinRow` refuses a missing row identity, as the kernel's `unpinRow` does,
  instead of filtering the pinned buffer by an absent identity (TD-46).
- An empty grid is valid for assistive technology: the "No results" message is
  one row with one cell, and a body still loading its first rows carries
  `aria-busy="true"`. A row group without a row was a critical
  `aria-required-children` violation (R-27).
- The focus marks follow the configured accent theme (UI-F1, UI-F2). The outer
  grid element carries the class `grid-root`, on which active-ui re-declares
  every theme-derived grid token, so a focused filter field no longer shows the
  default blue under another accent. The active body cell is marked at 50% of
  `--ring`, the active-ui field focus, instead of the full ring. Focus behaviour
  is unchanged.
- The grid container's keyboard focus is subdued (UI-F3). Its tab stop marked
  itself with a full `border-primary` and a `ring-2` halo; it now carries its
  own border at 50% of `--ring`, no ring and no outline, the same expression as
  the active cell and the filter fields. Focus behaviour is unchanged.
- Keyboard focus stays in the grid when the active cell leaves the DOM (R-27).
  Scrolling a virtualized row or column out of the window, or a render that
  replaces a control inside a cell, used to drop focus to `<body>`, and the
  grid then ignored the arrow keys on its own tab stop. Focus now parks on the
  grid, returns to the active cell when it mounts again, and the next
  navigation key resumes from the active cell (clamped to the current model).
- Toggling a row selection or an expander with the keyboard keeps the focus on
  that control. The system columns' renderers keep their identity when the
  consumer passes new column definitions, so their cells no longer remount.
- Action mode no longer stops on a control's hidden native proxy (the
  `aria-hidden` input of a checkbox).

### Added

- The body context menu names every copy action by its scope. In a range
  context: "Copy selection" and "Copy selection with headers". In a cell
  context: "Copy cell", "Copy row", "Copy row with headers" and, while rows
  are selected, "Copy selected rows (N)". In a row context (no cell
  selection): the selected rows, or the row, each with and without headers.
  "Copy selection" writes exactly the block Ctrl+C writes; "with headers" adds
  one header row with the columns' labels, also over several ranges.
- The Ctrl+C hint sits on the one menu item that does what Ctrl/Cmd+C does
  at that moment, exposed as `aria-keyshortcuts`; where no item matches, none
  carries it.
- `ContextMenuBuilder` receives an optional second argument,
  `ContextMenuContext` (new export): `kind` (`range`, `cell` or `row`),
  `columnId`, `selection` (`rangeCount`, `cellCount`) for a range, and
  `selectedRowCount`. A one-argument builder keeps working unchanged.
- With cell selection, a client group row offers the grid's copy-only menu:
  "Copy cell" on a data column, or the selection items inside a range. The
  row builder, edit and pin never appear there.
- Cells in a range carry `aria-selected="true"`, and a grid with cell
  selection declares `aria-multiselectable`.
- New translation keys, supplied by the host: `grid.copy.selection`,
  `grid.copy.selectionWithHeaders`, `grid.copy.cell`, `grid.copy.row`,
  `grid.copy.rowWithHeaders`, `grid.copy.selectedRows`,
  `grid.copy.selectedRowsWithHeaders`.
- Shift+F10 and the ContextMenu key open the row context menu for the active
  cell, with the same target as a right-click on it. The grid opens the menu
  itself; the browser's own `contextmenu` for that keystroke is suppressed.
  Escape closes the menu and focus returns to the active cell.
- With `enableCellSelection`, the body context menu resolves its target when
  it opens: a right-click inside a range of more than one cell keeps every
  range and the active cell where they are (range context); a right-click
  outside all ranges clears them and makes the clicked cell active (cell
  context). Row selection is not changed, and the row's own menu items stay.
- `useKeyboardNavigation`'s cell props carry `onMouseDown`: a primary-button
  press makes the cell active on the press itself. `onClick` stays and changes
  nothing after that press.
- Styling hooks for the selection look, all set by the grid: on the grid
  element `data-cell-selection` (cell selection is on), `data-cell-range` (an
  explicit range exists) and `data-body-focus` (the focus is in the grid's
  body or in its own body menu); on a cell `data-cell-cue` (the cell that
  carries the selection's cue). Two tokens on `.grid-root`:
  `--grid-range-outline` and `--grid-range-fill`.
- While the focus is parked on the grid (the active cell scrolled out of the
  mounted window), Shift+arrow keeps extending the range, Ctrl/Cmd+C copies,
  Escape ends the range and brings the active cell back, and Shift+F10 or the
  ContextMenu key brings it back and opens its menu.
- A keyboard path for column order and width (R-27, WCAG 2.1.1). With
  `enableColumnReordering` the column menu offers "Move left" and "Move right";
  on a resizable column it offers "Increase width" and "Decrease width" (10 px
  per step). The menu stays open so a step can be repeated. A move follows the
  rules of a drop (never across a pinned boundary or out of its header group,
  never past a grid-owned column); a width is held between the column's bounds
  and locks the column as the end of a drag does. Each step is announced
  through the grid's live region; a pointer drag announces nothing. New
  translation keys, supplied by the host: `grid.move.label`, `grid.move.left`,
  `grid.move.right`, `grid.layout.increaseWidth`, `grid.layout.decreaseWidth`,
  `grid.announce.moved`, `grid.announce.resized`, `grid.announce.atMinWidth`,
  `grid.announce.atMaxWidth`.
- A server group row is a row of the keyboard navigation: the arrow keys reach
  it and Enter on its first cell reaches its toggle.
- `Grid.getState()` and `Grid.subscribe(listener)` are part of the published
  grid: the accepted interaction state, replaced when a change is accepted and
  handed back unchanged otherwise, and an observer told after every accepted
  change (ADR-038).
- The row and cell shapes are exported from the root: `SourceRow` (a row that
  stands for a record, with `original: TData`), `GeneratedRow` (a row grouping
  generated, with `original: undefined`, `groupingColumnId` and
  `groupingValue`), `SourceCell`, `GeneratedCell` and `GeneratedCellContext`
  (ADR-037).
- The grid options type declares `collationLocale`, the language the
  `localeText` comparator orders text in. There is no default.
- `Column.getNextSortingOrder(multi?)` declares its optional argument: it asks
  about a toggle that extends a multi-column sort.

### Changed

- Persistence writes only the slices it restores (TD-50 d): sorting, filters,
  visibility, order, sizing, column pinning, pagination, global filter, row
  pinning and expansion. Row selection, grouping and the resize bookkeeping
  are no longer written, and remain non-persistent. A stored entry that still
  carries them loads as before and drops them at the next save.
- Cell selection has one model (ADR-043). The active cell is the grid's
  keyboard position and the anchor of every extension; a range is an explicit
  selection (a drag that reached a second cell, a Shift extension, a Ctrl/Cmd
  addition):

  - A click makes the cell active and stores no range. With cell selection on,
    the active cell shows the one-cell look.
  - `onCellSelectionChange` reports explicit ranges only: a click reports
    nothing, and a single selected cell is `[]`. `aria-selected="true"` is on
    the cells of an explicit range only.
  - A drag, Shift+click and Shift+arrow keep the active cell on the anchor and
    move the far end; Shift+arrow brings the far end into view. They used to
    move the active cell to the far corner.
  - Ctrl/Cmd+click adds the cell and keeps the previously active cell
    selected. Ctrl/Cmd+Shift+click replaces the last range instead of adding an
    overlapping one (R-52).
  - Ctrl/Cmd+C copies an explicit range, else the selected rows, else the
    active cell.
  - Starting an edit ends the range.
- The selection look is drawn from the grid's own state, never from where the
  browser put the focus, and nothing it draws moves content. The active cell
  outside a range: one square one-pixel mark at 50% of `--ring`, plus, with
  cell selection on, the corner cue. A range: an 8% `--ring` fill, a one-pixel
  outline on its outer edges and one cue on the bottom-right corner of the last
  range. The active cell inside a range is the window: unfilled, with no mark
  of its own. When the focus leaves the grid the active cell's look disappears
  entirely and a range stays; a menu the grid opened changes nothing. The row
  of the active cell takes no focus tint of its own while a range is on
  screen. In forced colours the range is Highlight and the active cell a
  Highlight mark. The cue is a mark, not a handle: there is still no fill and
  no paste. See `guides/styling.md`.
- The cell-range copy (Ctrl+C and the menu) leaves out the grid's own control
  columns (selection, expander, actions), as the row copy and the exports
  already did; a range over the selection column no longer copies an empty
  field for it.
- The row menu's consumer builder runs when that row's menu opens, not on
  every render of every row.
- The selection checkbox, the expander and the group toggles are no longer
  stops in the page's Tab sequence while keyboard navigation is on (R-27): a
  page of 100 rows used to add 200 stops. Enter or Space on the cell reaches
  them (action mode), Escape returns to the cell. With
  `enableKeyboardNavigation={false}` they keep their stop. Action mode now also
  stops on a cell control with `tabIndex={-1}`; a control a consumer renders in
  a cell joins this model by declaring that tab index.
- The grouping panel is rendered directly above the grid, as the top of the
  grid's card, instead of between the column headers and the body. The
  `role="grid"` element owns rows only; the panel's buttons inside it were a
  critical `aria-required-children` violation.
- Controls that exist once per column are named after that column's label
  (`meta.label`, else a string `header`) instead of its id or a generic word:
  the filter-row value control (`Filter value for {column}`, with the chosen
  value when there is one), the funnel and its popover (`Filter {column}`),
  the clear button, and the column menu button
  (`Column options for {column}`). The popover's condition select and value
  fields, the filter builder's selects and fields, and cell editors are named.
  A multi-column sort names its priority as a phrase instead of a bare digit.
  Expander and group toggles expose `aria-expanded`, and the group toggle is
  named by `aria-label` instead of only a `title`. A server group row shows
  the grouped column's label instead of its field id.
- New translation keys, supplied by the host: `grid.filter.valueFor`,
  `grid.filter.valueForWithValue`, `grid.filter.condition`,
  `grid.filter.value`, `grid.filter.rangeFrom`, `grid.filter.rangeTo`,
  `grid.header.optionsFor`, `grid.sort.priority`, `grid.filterBuilder.column`.
- R14B complete: the temporary approved-delta test register is retired. Every
  behaviour it listed is asserted by a directly named test, and the
  consumer-visible ones are now documented in the API reference: the built-in
  comparator and predicate names (unknown names, mixed values,
  `inNumberRange`, `between`, `arrHas`), width bounds, `getRowId`,
  `postSortRows`, row models and selection scopes, pinned positions, group-row
  ids and faceting. No runtime behaviour changed.
- R14 complete: license and attribution disposition. The package license is
  unchanged (`UNLICENSED`, proprietary `LICENSE`, `private: true`). The current
  source contains no vendored or copied TanStack Table material and the package
  has no TanStack Table dependency, so no TanStack Table notice applies; none
  was ever present in `LICENSE`. The README License section now states this,
  keeps the removed fork as history, and notes that the runtime dependency
  `@workspace/active-virtual` carries its own third-party notice. ADR-007 keeps
  its historical text with a current disposition. A legal question about
  earlier distributed versions is escalated (ROADMAP TD-42). Completed
  temporary rewrite reports were removed; the approved-delta test register is
  retired in the follow-up R14B.
- The development warning for toolbar search on a server grid without a
  `server.global` column no longer names a removed grid-side resolver. It now
  says the term is sent to the fetch adapter as `globalFilter` and the server
  decides which fields it searches. Text only; when it fires is unchanged.
- R13 complete: independent technical provenance supported; all 38 canonical
  documents reviewed, kernel ownership and API guidance corrected, neutral
  examples enforced by a scoped documentation guard. Historical ADR evidence
  and approved deltas remain through R14. Five internal hook aliases now say
  KernelGrid; source comment corrections do not change runtime behavior.
  Attribution and license decisions remain with R14. Performance follow-up
  remains due 2026-09-29; unrelated repository validation debt is recorded in
  the R13 closure report.
- The grid runs on its own kernel; the replaced engine is no longer used at
  runtime (ADR-038). The published objects, options and state slices keep their
  names and shapes.
- A callback that is handed a row's record — `meta.valueFormatter`,
  `meta.editable`, `meta.cellClassName` as a function, `meta.cellClassRules`,
  `meta.cellStyle`, `meta.tooltipValueGetter`, `meta.tooltipField`,
  `meta.colSpan`, `onRowClick`, `onRowDoubleClick`, `onCellClick` and the row
  `contextMenu` — is no longer called for a row grouping generated, which has
  no record; it was handed `undefined`. On such a row no edit starts and no row
  menu opens. Group totals, group footers, server-grouping aggregates and the
  presentation export of an aggregate are shown through the column's declared
  semantics (`semanticType`) or its `aggregatedCell` template and no longer pass
  through `valueFormatter`, which received an empty object in place of a
  record. Ratified as delta DC-1 (2026-09-22): a column whose aggregate relied
  on `valueFormatter` for its notation declares `semanticType` instead.
- The `median` aggregate skips an absent value (`null`, `undefined`, `''`,
  `NaN`, `±Infinity`) and is the median of the values that are present; without
  a present value it has no result. It used to return nothing as soon as one
  value was absent. Ratified as delta DC-2 (2026-09-22).
- **Group-row aggregation is declarative.** A column shows an aggregate in a
  group row only when it declares `aggregationFn`. A numeric column without a
  declaration used to be summed through an implicit `auto`; it now shows an
  empty cell, because a numeric value (an identifier, a year, a rate) does not
  imply an additive meaning. A column that should total declares it, for
  example `aggregationFn: 'sum'`; `auto` stays available as an explicit choice.
  Ratified as delta DC-3 (2026-09-22).
- A mounted client grid no longer builds a row object for every filtered row
  on each data change. The pagination total and the status announcement read
  the filtered count without building rows, and the row numbering is built
  only when a row-number column is shown. At 100 000 rows this removes
  200 000 of the 200 010 row objects a data change built.
- `onSelectionChange` hands over the records of the selected source rows only.
- **Breaking (types).** `Row<TData>` is `SourceRow<TData> | GeneratedRow<TData>`,
  and `row.getIsGrouped()` narrows it. Code that reads `row.original` where a
  generated row can arrive — a comparator, an eligibility predicate such as
  `enableRowSelection`, `renderDetailPanel`, `getRowCanExpand`, a row model —
  narrows first:
  `(row) => !row.getIsGrouped() && row.original.active`. Callbacks that only
  meet rows standing for records keep `original: TData`: a filter function
  receives a `SourceRow`, an aggregation's `leafRows` are `SourceRow`s, the
  parent `getRowId` receives is a `SourceRow`, and a body-cell renderer's
  `CellContext` carries a `SourceRow`. An `aggregatedCell` renderer receives a
  `GeneratedCellContext`. `Cell` is `SourceCell | GeneratedCell`.
- A persisted pagination state whose page size is not a whole number greater
  than zero is discarded on restore, as a zero or negative size already was; a
  fractional size is no longer restored. One rule decides every page size.
- `createLocaleSortFn(locale)` refuses an empty or missing locale with a
  `TypeError` instead of falling back to the host locale. The sorting
  semantics for a given locale are unchanged. Its declared return type is now a
  structural comparator over `row.getValue(columnId)`; the parameters are
  unchanged.
- `createColumnHelper` and `createLocaleSortFn` are implemented independently
  of the engine; the objects the helper returns are unchanged.
- The objects a consumer receives (`Grid`, `Row`, `Column`, `Cell`, `Header`,
  `HeaderGroup`, `CellContext`, `HeaderContext`), the state slices, the
  callback types and `DeepKeys` are declared by the package itself instead of
  aliasing the engine's types (ADR-036). Every supported member keeps its name;
  the row, cell and callback signatures that change are listed above (ADR-037).
  Collections the grid hands out (`getRowModel().rows`,
  `getAllColumns()`, `row.getVisibleCells()`, …) are typed `readonly`; the
  value type of a column now reaches `Column`, `Cell`, `Header` and both
  renderer contexts, and every slice reset declares its optional
  `defaultState` flag.
- `createLocaleSortFn` and `createColumnHelper` are documented as part of the
  root export; they were described as contributor-only.

### Fixed

- **A replaced column definition is what the grid shows.** Re-rendering a
  mounted grid with the same records and a replaced `accessorFn`,
  `accessorKey`, `cell`, `header`, `aggregatedCell`, `meta` (for example
  `valueFormatter`, `semanticType`, `cellClassName`) or default column now
  updates the visible cells and headers when the render commits. The grid
  already answered with the new definition; the cells went on drawing the old
  one until an unrelated interaction. No row is rebuilt for it: the displayed
  rows keep their identity, and only their cells are rebuilt, also in a
  virtualized body.
- **The active cell is marked as soon as a click makes it active**, not only
  after an arrow key moves it (styling in `@workspace/active-ui`).
- **A sort label clicked with a held Shift (multi-sort) no longer shows the
  keyboard focus ring.** Keyboard focus on a sort label still shows it.
- A malformed page size in the address (`pageSize=2.5`, `25junk`, `1e2`) is
  ignored instead of being truncated into a different, valid size; the one
  page-size rule reads the text (PW-02).
- A malformed page number in the address (`page=2junk`, `1.5`, `-1`, `0`) is
  ignored instead of being truncated or clamped into a page; it follows the
  same textual rule as the page size.
- With two or more sorted columns every sorted header shows its priority, the
  first one included; the `1` was never shown, so a multi-sort read as `2`
  without a first. With one sorted column no number shows. The header reads
  the priority from `Column.getSortIndex()`.
- Clearing a text filter whose operator is not the column default (for
  example "does not equal") clears it; the debounced input used to re-apply
  the old text under the default operator.
- Server grouping no longer hands its group headers and load-more markers to
  `getRowId`; the grid names them itself.
- Server grouping shows the group rows the server returns. Since the kernel
  cutover the grid grouped those rows a second time by the mirrored grouping
  and folded every group into one empty group; in server-grouping mode the
  grid's own grouping is now manual.
- A page restored from the address or from the stored state survives the
  mount on a grid with a filter row. Every filter control clears its own
  column once on mount, and that no-op write returned the grid to page 1; a
  sort, filter or search now returns to the first page only when it changes
  the slice.
- The grid type declares `setColumnResizing`, the member the grid has at
  runtime, instead of the misspelled `setcolumnResizing`.

### Removed

- The CSS token `--grid-z-cell-focus`. The active cell's mark is an overlay
  inside the cell and needs no stacking of its own; the old rule's z-index
  also lifted a scrolled active cell over the sticky pinned columns.
- The active cell's inset box-shadow ring on `.grid-cell[role='gridcell']:focus`,
  replaced by the overlay mark above. A consumer rule that restyled that
  shadow no longer has an effect.
- The translation keys `grid.copy.label` and `grid.copy.withHeaders`: the one
  ambiguous "Copy" is replaced by the items named by scope (see Added).
- The translation key `grid.filter.openFilter`: every funnel is named with its
  column (`grid.filter.openFilterFor`).
- From the public types: members that belong to the engine's implementation
  (`_features`, the prototype and row-model fields, value caches,
  `autoResetExpanded()`), the state-container fields `baseStore` and `store`
  (grid and options), the `debug*` options and `mergeOptions`, and five
  declared grid members that do not exist at runtime (`getFacetedRowModel`,
  `getFacetedUniqueValues`, `getFacetedMinMaxValues`, `getPinnedLeafColumns`,
  `getPinnedVisibleLeafColumns`). `Header.rowSpan`, which is always `0`, is no
  longer declared.
- The dependency on `@workspace/active-state`. It served only the replaced
  engine, which is removed from the source together with its binding (TD-42,
  R12); neither was reachable from the published entry, so no import changes.

### Documentation

- The version-to-version migration guides (`migration/from-pre-1.0.md`,
  `v1-to-v2.md`, `v2-to-v3.md`, `v3-to-v4.md`) are removed: they described
  upgrades to versions and helpers that no longer exist. A current integration
  starts from the current API; this changelog keeps the history. The AG Grid
  migration guide (`migration/from-aggrid.md`) is removed too: the package is
  private and has no external adopters.
- `guides/i18n-date-filters.md` is merged into `guides/filters.md` and
  `DATA-ACCESS.md` into `ARCHITECTURE.md` § Datatoegang en eigendom.
- The README quick starts now pass the required `locale`, and
  `api/filters.md` names the real operator label keys (`grid.operators.*`).

---

## \[10.2.2\] - 2026-09-21 - The baseline is frozen on a quiet machine, and the column window is bounded

> **R-26 is closed.** The entries below were held at 10.2.1 until a baseline
> could be frozen on a quiet machine: freezing the numbers of a loaded machine,
> or of a defect, is worse than having no baseline at all. On 2026-09-21 both
> rounds reported `quiescent: yes` -- the browser round with only admin:3002
> and auth:3000 listening, the jsdom round with nothing listening at all -- and
> `benchmarks/baseline.json` now exists at this version. The immediate
> compare-back was green: no counter drift, no scenario-integrity drift.

### The benchmark baseline exists (R-26)

`benchmarks/baseline.json` is the package's first legitimate frozen baseline:
both runners, 18 scenarios, 3 warm-ups plus N = 15, raw observations retained,
every counter observed fifteen times and equal among itself, and the machine
condition of each round recorded inside the document alongside an explicit
human confirmation.

#### Added

- **`benchmarks/baseline.json`** -- the frozen reference point. It is the one
  shared artefact under `benchmarks/`; everything under `benchmarks/local/`
  stays ignored.

#### Measured

- **Zero counter drift against the 2026-08-28 trial rounds**, in both runners.
  Those rounds were measured on a machine carrying seven to nine dev servers
  with coefficients of variation up to 89%, and their counters are still
  exactly these. That is the tightest evidence so far that the counter class is
  genuinely machine-independent while the timing class is not.
- **Memory is a usable trend for the first time.** The jsdom round carries
  `memoryPolicy: "trend"` and all twelve retained-heap series sit below a 12%
  coefficient of variation, where 10.2.1 published negative deltas of hundreds
  of megabytes. It remains a trend and does not become a gate.
- **The three pre-rewrite measurements R1 asked for: one delivered, two
  reported as unobtainable.** Accessor-call counts and engine-wide publication
  counts do not exist in the shipping engine, and adding a scenario counter is
  by the scenario manifest a baseline change -- which would have invalidated
  the very reference point this work produced. The column-normalization
  question is answered as a static census instead: of 240 `columns={...}`
  bindings across 235 consumer files, 40 rebuild the array identity on every
  render and therefore miss the engine's column memo every render. Full
  reporting in `PERFORMANCE.md`.

### The public column contract is a contract again (TD-31B, ADR-035)

`GridColumnDef<TData>` resolved literally to the top type, so
`const columns: GridColumnDef<Row>[] = [42, 'not a column']` compiled. There were
two independent causes with that effect, and repairing one of them did nothing
for the other. No runtime behaviour of this package changed.

#### Changed

- **The feature set is supplied, not hidden.** `ReactLayerFeatures` was `any`;
  it is now `StockFeatures`, in one named package-private type. Measured:
  `CoreFeatures & StockFeatures` produces 16 errors that `StockFeatures` does
  not and removes none, all of them an alias claiming a wider set than the
  engine builds.
- **`GridColumnDef` is a union of three kinds** — accessor-key, accessor-function,
  and group-or-display. A display column is not a fourth kind on purpose: as a
  separate member it would absorb a group whose children are invalid.
- **`accessorKey` must name a field of the row**, dotted paths included,
  computed from the unwidened row type.
- **A group's `columns` is recursively this same definition**, so a nested child
  carries `type`, `server` and `meta` exactly as its parent does. It was the
  engine's own shape and carried none of them.
- **`createColumnHelper` declares the public shapes** and infers each column's
  value type from the key or the function. It is the only construction path that
  infers a value type at all.
- **`enableRowSelection`'s predicate receives the ROW, and the prop now says so.**
  It was declared with the record while the engine passed the row, so a
  predicate written against record fields read `undefined` and refused every
  row. This is a corrected declaration, not a behaviour change in the package;
  the one consumer using the function form was corrected with it.
- **`meta.statusTones` is declared partial.** A total `Record<string, StatusTone>`
  claims every possible string has a tone, which contradicts the documented
  fallback, and it refused a shape TypeScript synthesizes when two columns with
  different key sets share one array literal.
- **The header selection checkbox states its value honestly.** It passed
  `'indeterminate'` through a boolean field, so a partial page has always
  rendered as fully checked; the rendering is unchanged and the missing third
  state is `ROADMAP.md` TD-45.

#### Added

- **`hasAccessorKey` / `hasAccessorFn`** — the two narrowings for reading a
  definition back. `accessorKey` is not reachable on a union, and a local
  `column as {accessorKey?: string}` keeps compiling after the field moves.

#### Removed

- **`BivariantGridColumnDef`** — the bivariance it provided is now the default of
  the public type itself, so a second name for the same type would be a second
  truth. Its five consumers name `GridColumnDef<TData>`.
- Three `(r: any)` annotations in component code that existed only because the
  React layer hid `TFeatures`.

---

### Source language convergence completed, and the engine's provenance measured (AGRID-STD-04)

No runtime behaviour changed and no public API changed. Every change is
authored prose: comments, docblocks, test descriptions, `it.each` labels,
assertion messages and developer diagnostics.

#### Changed

- **All remaining Dutch authored prose is English (TD-44, closed).** Three
  independently constructed sweeps found 908 lines across production source,
  tests, `e2e/`, `benchmarks/` and `scripts/` -- more than double the 242 the
  previous re-measurement reported, because a comment-only scan cannot by
  construction see `describe`/`it`/`it.each` descriptions, assertion messages or
  Dutch `throw new Error(...)` prose. Seven English comments that quote a Dutch
  UI label remain, as `CODING_STANDARDS` § 2 permits, along with the Dutch UI
  expectations and locale fixtures themselves.
- **Benchmark scenario descriptions are English.** `scenario-manifest.ts`
  carried Dutch `measures` prose that lands in every published benchmark
  document. The harness assertion that matched on that Dutch text moved with it,
  so the guard keeps its subject.

#### Documentation

- **TD-42 is reframed from attribution to provenance.** Measured: `src/engine/**`
  (100 files, 14,004 lines) and `src/react/**` (4 files, 392 lines) are a fork
  with a rename of TanStack Table v9 `table-core`, not an independent
  implementation -- established on directory layout, per-feature file names, the
  complete identifier set, and two character-for-character identical helpers in
  `utils.ts`. 27% of the package is derived; the remaining 73% is independently
  authored active-grid code. `LICENSE` was not touched, no attribution text was
  added, and no existing provenance reference was removed. The evidence and the
  seven-tranche replacement plan are in
  `docs/research/ACTIVE-GRID-TANSTACK-PROVENANCE-ASSESSMENT-2026-09-20.md` and
  in `ROADMAP.md` TD-42. This is a technical finding, not legal advice.

### Documentation drift corrected (AGRID-DOC-01)

No runtime behaviour changed and no public API changed. Listed here because
three of these corrections were misleading to a consumer, which is the one
case the changelog convention records a documentation-only change for.

#### Documentation

- **The README no longer says the package is MIT.** `package.json` declares
  `UNLICENSED`, `LICENSE` carries the proprietary Active Development text and
  `private: true` is set. The README also claimed the TanStack attribution was
  "preserved in `LICENSE`"; it is not there, and no file under `src/engine/`
  carries an upstream copyright header. ADR-007 requires that clause, so the
  gap is now stated in the README and registered as `ROADMAP.md` TD-42.
- **`next` is no longer described as optional.** `ARCHITECTURE.md` said
  "optioneel Next.js 16" and `FUNCTIONEEL-OVERZICHT.md` said "optioneel Next.js
  15 of 16", both left over from before AGRID-F04 made it a required
  `^16.0.0` peer.
- **`docs/api/columns.md` typed a column group's `columns` as
  `GridColumnDef[]`.** It is the engine shape, `Array<ColumnDef<…, unknown>>`,
  and has never been otherwise: a group child carries no `type`, `server` or
  `meta`, and its `TValue` is pinned to `unknown`. Repairing the type was
  TD-31B, which landed in this same release and made the field the public
  definition at every depth; see the TD-31B entry above for what it is now.
- **`docs/guides/filters.md` described `withEmpty` against a symbol that does
  not exist.** `toPrismaWhereFromContract` has no occurrence anywhere in the
  repository, and the claimed "silently dropped with a dev-warning" does not
  happen: in `@workspace/active-resources` a non-nullable text column matches
  the empty string, and number, date and boolean have `isEmpty` rejected by the
  operator allowlist before any condition is built. The same wrong sentence is
  corrected in the `operator-presets.ts` docblock.
- **`docs/FUNCTIONEEL-OVERZICHT.md` rewritten against the measured package.**
  It still described a different monorepo, counted 7 e2e specs where there are
  17, cited the withdrawn ADR-011 guard, named a `--max-warnings 545` cap that
  is 100, and listed the Prisma query builder and the schema-aware server
  contract — both removed in 7.0.0 — as current differentiators. Batch editing,
  undo/redo, PDF export, the filter builder and cell/range selection were
  missing and are now listed.
- **Version labels.** ADR-030, ADR-031 and ADR-032 read "geaccepteerd (…,
  10.2.2)" as though 10.2.2 existed. Nine places now say what `INDEX.md`
  already said: the work sits in `[Unreleased]` and ships as 10.2.2 with the
  R-26 freeze.

#### Removed

- **Eighteen temporary documents**, after each was read in full and its durable
  content handed to its owner: `docs/plans/`, `docs/traycer/`,
  `docs/comparison/`, `docs/integrations/`, six research files, the resize
  handle analysis and the 2026-07-21 audit verification — about 12 000 lines.
  active-grid no longer has a work-archive or history concept; git is the
  history. Two research files remain, each with a deletion trigger recorded in
  `docs/INDEX.md`.

### The package conforms to `/docs/standards` (AGRID-STD-03)

No runtime behaviour changed, no public API changed, and `lint:api-drift`
reports the same 271 exports and 401 documented symbols as before. The full
suite runs green on the same 193 files and 2 808 tests.

#### Changed

- **Braces on every conditional and loop body** (CODING\_STANDARDS § 5), across
  all authored source: 825 braceless bodies in 182 files became zero. Measured
  over production, engine, test, benchmark, e2e and script source -- § 1 puts
  all of them in scope -- and applied as an AST rewrite with `eslint --fix`
  running `curly` alone, followed by Prettier. `src/engine/` is included: ADR-007
  vendors it and says to treat it as internal code, no file under it carries an
  upstream copyright header, and there is no generator or synchronisation
  contract that § 2 would exempt. The TanStack portion clause in `LICENSE` is
  untouched.
- **All sixteen remaining hooks declare an explicit return type**
  (CODING\_STANDARDS § 6), and **not one name was added to `src/index.ts`**. Four
  reuse a type that already existed; the other twelve get a result interface in
  their own module, fourteen of which are module-private. TD-39 argued that
  naming a hook result inevitably publishes it -- the package already contradicts
  that six times (`UseAutoSizeReturn`, `UseEditHistoryReturn`,
  `UseServerGroupingReturn`, `UseServerSelectAllReturn`,
  `UseBatchEditingStateReturn`, `UseGridImperativeResult`), none of which appears
  in the barrel. Exported non-component functions in the non-engine source now
  carry an explicit return type 205 out of 205 times.
- **`curly` is enabled in the package's own `eslint.config.mjs`** so a regression
  shows up in `pnpm lint`. It reports as a warning, because the shared base
  downgrades every severity; blocking enforcement is a repository decision and is
  registered as TD-40.

#### Documentation

- **Authored source prose is English** (CODING\_STANDARDS § 2): 1 417 comment and
  docblock units across 185 files, 154 `describe`/`it`/`test` descriptions, and
  two developer-facing `throw` messages. Meaning-preserving translation only --
  no claim added, no commentary rewritten, and every date, identifier, path and
  translation key left exactly as it stood. The Dutch UI labels quoted as
  expectations in `column-chooser.spec.ts`, `column-resize.spec.ts` and
  `context-menu.spec.ts` stay: those specs run against `apps/admin`, which hands
  the grid its own `t`, and § 2 covers test expectations whose language is the
  point.

#### Removed

- **`bash.exe.stackdump`** (1 196 bytes, an msys bash crash trace). Already
  git-ignored, referenced by no gate, script or document; its call stack was read
  before removal and contains no Datyx frame.

### A fallback message speaks the current language, and the manifest tells the truth (AGRID-STD-02)

#### Fixed

- **A translated fallback no longer arrives in the language the user just
  left (AGRID-F03).** Three `useCallback`s reached for the grid's resolved
  translator without listing it: the paged fetch and the infinite-scroll
  fetch in `useDataOrchestrator`, and the cell flush in
  `useBatchEditingState`. A Datyx host memoizes `t` per language, so the
  identity rotates exactly on a language switch and the callbacks kept the
  previous one. Reach: `grid.error.fetchFailed` (a fetch that rejects with a
  non-`Error`) and `grid.edit.validationFailed` (a column validator that
  throws). `translate` is now in all three dependency arrays; the fetch
  trigger already calls through `fetchDataRef`, so nothing refires.

#### Changed

- **`next` is a REQUIRED peer dependency (AGRID-F04).** `peerDependenciesMeta`
  marked it optional while `LinkCell` imports `next/link` at module scope and
  the single entry point exports `LinkCell` -- so importing the package has
  always had to resolve `next`, and `@workspace/active-ui` requires it too.
  The manifest and the README now say what the package actually needs. No
  export, subpath or component moved; no consumer changes.
- **Type-only imports use `import type`** in 30 modules (CODING\_STANDARDS
  § 4). Mechanical; no runtime or type-contract change.
- **Five production `as any` casts and their suppressions are gone from
  `export-utils.ts`.** `jspdf-autotable` v5 publishes `UserOptions`,
  `RowInput` and `CellHookData`; the prototype-installed `autoTable` is now
  reached through one named view type instead of `any`, and the two
  `didParseCell` hooks are typed. The PDF option objects and both table
  bodies are checked against the package's own types for the first time.
- **Thirteen public functions declare an explicit return type** (CODING\_STANDARDS
  § 6): the twelve `exportTo*` variants and `copyToClipboard`. The sixteen that
  remain are all hooks whose result shape would have to be NAMED, which is a
  public-API decision; registered as TD-39.
- **Ten `react-hooks/exhaustive-deps` suppressions carry an English reason**
  (CODING\_STANDARDS § 10). Each was probed by removal first: all ten are still
  necessary. Two redundant `useMemo` dependencies that the rule flagged as
  unnecessary were removed from `grid.tsx` and `use-grid-setup.ts`; neither is
  read in its memo body. active-grid now lints at zero warnings.

#### Documentation

- **TD-37: two stale source comments corrected.** `types/filters.ts` called the
  module-scope operator lists "NL defaults" -- they carry the package's own
  standalone English table. `i18n/grid-translation-context.tsx` and
  `types/props.ts` both claimed the built-in calendar mapping "covers
  en/nl/de/fr/es"; since ADR-034 `utils/date-locale.ts` defers to active-i18n
  and active-grid carries no language list at all.

### Keyboard navigation and auto-height read the live grid model (AGRID-CORR-01)

#### Fixed

- **Keyboard navigation no longer freezes its bounds on the first render.**
  `useKeyboardNavigation` derived `rowCount`, `colCount` and `pinnedTopCount`
  in a `useMemo` keyed on the grid instance, which never changes identity
  (`react/useGrid.ts` builds it in a `useState` initializer). The counts
  therefore stayed on whatever the first render saw. Consequences, all gone:

  - **`mode="server"`**: the first render has zero rows by contract, so
    `rowCount` was 0, `Math.min(rowIndex + 1, rowCount - 1)` targeted row -1,
    and the first ArrowDown after the fetch threw DOM focus out of the grid.
    Every later key then landed nowhere. WCAG 2.1.1 is level A.
  - **`mode="client"`**: a row model that changed length after mount —
    filtering, searching, a shorter last page, data arriving late — kept the
    old bound, so arrows, `End`, `Ctrl+End` and `PageDown` could not reach the
    rows that were there.
  - **The column axis**: the column chooser, responsive hiding and a restored
    visibility state changed the visible column count without changing the
    bound, so `ArrowRight`/`End` stopped short or aimed at a column that had
    been hidden.
  - **`pinnedTopCount`**: pinning a row to the top while the grid was mounted
    left `scrollToIndex` converting to the wrong center index.
- **`meta.autoHeight` now takes effect when its column becomes visible.** Both
  bodies (`sections/body.tsx`, `sections/body-virtual.tsx`) derived
  `hasAutoHeight` from the same frozen memo. A column with `autoHeight` that
  was hidden at mount — a persisted visibility state, responsive hiding — and
  made visible afterwards left every row on a fixed height, clipping content
  that should have grown.

No public API change, no remount, no new state, no change to
`@workspace/active-virtual`. The counts are read during the render and the
auto-height flag hangs on the visible-column model; both are engine-memoised
on the state that can change them, so an unchanged model costs a dependency
comparison. ADR-030
carried the rule already and now names `grid` as the second stable instance.

### The date calendars mark the presentation's day, not the runtime's (AUI-FINAL-REM-01)

#### Changed (breaking)

- The calendars of `DateEditor` and the date filter pass `today` to active-ui's
  `Calendar`, which now requires it (active-ui ADR-025, amendment of
  2026-09-19). The day comes from the zone of the grid presentation, through
  the same `todayInPresentation` the relative date presets use; without a
  selection the calendar opens on that day's month. `new Date()` is gone from
  both.
- A grid presentation **without a time zone** (a bare language code) now throws
  a configuration error when a date editor or a date filter calendar renders,
  the rule the relative presets already followed. Every product grid is fed by
  `usePresentation()` under a resolved zone and is unaffected.

### The host owns words and presentation; no invented English (I18N-HC-02, ADR-034)

#### Changed (breaking)

- `locale` on `ActiveGrid` and `LookupGrid` is **required**. `useGridLocale()`
  has no `'en'` default any more and throws a configuration error without a
  presentation.
- A host translator (`t`) is **authoritative**. A key it does not carry
  renders as the raw key; the former silent replacement by the English table
  (`result === key`) is gone.
- The fetch, save, validation and cell-error fallbacks ask the same translator
  (they called the English table directly before).

#### Fixed

- Ctrl+C of selected rows or of the focused cell copied values in English
  notation whatever the grid's `locale` (the cell-range copy was correct): `useKeyboardNavigation` read the presentation context from outside
  the provider the grid renders. The grid now passes its `locale` to the hook.

### The scrollbar is the sidebar's, pixel for pixel (UI-SCROLLBAR-02, active-ui ADR-026 amendment)

#### Changed

- The scrollbar lane is built exactly like the house `ScrollArea` bar:
  `padding: 1px`, a transparent 1px border on the side facing the content
  (`border-left` vertical, `border-top` horizontal) and a `flex: 1` thumb,
  replacing the symmetric 1.5px inset (`--grid-scrollbar-inset` is gone). The
  thumb therefore has the sidebar's width and position (7.36px at DPR 1.5625,
  1px from the outer edge) instead of a 7px thumb centred in the lane.
  Measured side by side in UI Lab `scroll-area#case-grid-parity`: lane, thumb,
  inset, corner, rest, hover and scrolling state identical, light and dark.
- No change to the adapter, the lane width, the colours, virtualization or the
  scroll math. `scrollbar-presentation.test.ts` pins the construction.

### The scrollbar reads the platform contract (UI-SCROLLBAR-01, active-ui ADR-026)

#### Changed

- `--grid-scrollbar-thumb`, `--grid-scrollbar-thumb-hover` and
  `--grid-scrollbar-track` default to the platform tokens `--scrollbar-thumb`,
  `--scrollbar-thumb-hover` and `--scrollbar-track` instead of their own values.
  The resting colour is the same (`--border`).
- **The thumb now reacts under the pointer and while scrolling**, with the
  platform hover token. AG-SCROLL-01 kept it at rest only because the house
  `ScrollArea` did not react either; both now do. `theme.scrollbarThumbHover`
  still overrides it per grid.
- Under `forced-colors` the thumb is `CanvasText`; before, the browser replaced
  its background with the lane colour and it disappeared.
- No change to the adapter, the lane, the geometry or virtualization.
  `scrollbar-presentation.test.ts` pins the aliases and the hover rule.

### Grid fields carry the platform field focus (UI-FOCUS-01, active-ui ADR-022)

#### Changed

- **Every grid field focuses like a form field**: a border at 50% of `--ring`,
  no ring, in light and dark. The popover filter inputs and selects (text,
  number, interval, date, operator) dropped
  `focus:border-primary focus:ring-2 focus:ring-primary/20`; the date popover's
  text inputs dropped the same with `ring-1`; the filter-builder fields dropped
  `focus-visible:ring-1 focus-visible:ring-ring`. All of them take the internal
  `FIELD_FOCUS_CLASS` (`components/primitives/field-focus.ts`), which is bound to
  active-ui's `Input`.
- **The inline filter bar is dimmed** through active-ui's
  `--grid-filter-trigger-focus-border`, now
  `color-mix(in oklab, var(--ring) 50%, transparent)` (was `var(--ring)`). The
  dead `focus:border-primary/50 focus:bg-background` on the inline text and
  number inputs is gone; the grid CSS already won over them.
- The toolbar search no longer adds `focus-visible:ring-1` to `Input`, and the
  page-size `SelectTrigger` no longer carries a no-op `focus:ring-0`.

No public API change. Buttons, cells and the grid container keep their focus.
Guarded by `field-focus-contract.test.ts`.

### `±Infinity` is absent, like `NaN` (active-i18n F3b-9)

#### Changed

- **A non-finite number is absent on every layer.** `isAbsentCellValue` now
  treats `Infinity` and `-Infinity` like `NaN`: the cell draws the placeholder
  instead of `∞`, `isEmpty` matches, the presentation export writes `''`, and
  `sum`/`mean`/`min`/`max` skip the value (`median` returns no result). The
  DATA export was already empty. Walt's decision A2: the human formatters of
  active-i18n return `''` for a non-finite value, and no product means an
  infinite number. Supersedes the REVIEW-AG-004 rule that Infinity is data.
  The internal `toNonFiniteNumber` pass-through in the presentation export is
  removed.

### Date filter: a calendar day is not an instant (active-i18n F3b-3)

#### Fixed

- **A calendar-day column no longer filters one day early west of UTC.** The
  client-mode date filter read every cell as an instant in the presentation's
  zone, so a Prisma `@db.Date` or `…T00:00:00.000Z` value for 9 March matched
  8 March on an `America/Curacao` surface. A `semanticType: 'date'` column (and
  a `filterType: 'date'` column without semantics, as `DateCell` shows it) now
  compares calendar days without a zone. ADR-033.
- **The filter builder receives the presentation and each field's date
  semantics**, which it did not before.

#### Changed

- **A `dateTime` column filter and the relative date presets require a time
  zone in the grid presentation** and throw a `TypeError` without one, instead
  of reading the browser's day. `DateFilterPreset.getValue` documents this.
- `evaluateDateFilter`, `evaluateFilter`, `advancedFilterFn` and
  `evaluateFilterExpression` take an optional date-semantics argument.

### `SelectFilter`: a declared selected state and reader-language search (`DATYX-FINAL-013`)

#### Added

- **Every option row in the select filter declares its membership.** An option
  is a `<button>` with a drawn check mark; the check is an `svg`, so the state
  existed only for someone looking at it. Each option now carries
  `aria-pressed`, and `Select all` carries the three-valued form
  (`true` / `"mixed"` / `false`) that matches the check, the dash and the empty
  box it already drew. `aria-pressed` and not `role="checkbox"`: the DOM is a
  button, its keyboard behaviour is already correct, and a role override would
  contradict it.

#### Changed

- **Option search follows the reader's language.** It was
  `label.toLowerCase().includes(query)`, which does not find `Zürich` for
  `zuruck`-style input and applies a case rule that differs per language. It now
  runs over an `Intl.Collator` with `sensitivity: 'base'` and `usage: 'search'`
  on the language the host already supplies through `GridPresentation`
  (`languageLocaleOf`). No coupling to `SearchCombobox` was introduced; the
  collator belongs to this module.

#### Unchanged, and now guarded

- **Empty is NO FILTER**, `Select all` writes the explicit set rather than a
  wildcard, `Clear` returns the state to `undefined`, and the stored identity
  remains `option.value` and never the label.
- **The declared label map keeps winning over the derived one.** A column that
  declares `meta.filterOptions` shows those words; a column that declares none
  shows the value itself. The grid invents no human label -- no `startCase`, no
  underscore replacement, no capitalisation rule. Re-measured for
  `DATYX-FINAL-013`: the `String(key)` label Codex saw occurs only where no
  label authority is declared, which is the contract rather than a defect.
  `select-filter-option-contract.test.tsx` now fails if the derivation ever
  starts beating the declaration.

### Row menus: destructive grouping, a live `visible`, and an honest export scope

#### Changed

- **BREAKING (menu shape). A destructive action now sits in its own block.**
  The resolve pipeline adds a separator above the destructive tail when the menu
  ends in a `variant: 'destructive'` item and something precedes it. Measured on
  2026-08-31 across the monorepo: of 43 destructive items, 15 carried that rule
  and 24 did not, while all 43 used the same shared translation key. A mixed
  lifecycle group stays whole -- the tail starts at the FIRST destructive item of
  the last segment, so delete / restore / delete-permanently is one block with
  one rule above it.
- **BREAKING. `Export all` was removed from the row menu**, in every mode, and
  `Export view` now means the CURRENT paginated page. It previously read
  `getFilteredRowModel()`: in client mode every filtered row across all pages
  (`Export view (152)` beside a paginator reading `1 - 10 of 152`), in server
  mode only the current page, because `manualFiltering` falls that model back
  onto the page data. One label, two meanings. `Export all` promised "all rows,
  ignoring filters" and, measured on a server grid of fifteen users with a
  filter active, wrote a file containing the seven FILTERED rows under the label
  `Export all (7)`. A full-dataset export is a separate product feature.
- **Infinite scroll carries its own label**, `Export loaded rows (N)`, over the
  blocks already fetched. It never fetches missing blocks for an export.
- **The structure-preserving grouped export is no longer wired to the row
  menu.** It writes the whole group tree, which is not one page. `exportTo*Grouped`
  and `exportTo*ServerGrouped` remain public API for programmatic use.

#### Fixed

- **`ContextMenuItem.visible` was declared but never evaluated.** Measured: 31
  occurrences in 13 consumer files decided nothing, and three permission checks
  hung on it as their only client-side gate. It is now evaluated by the resolve
  pipeline, before separator normalization, so a group that filters away leaves
  no orphaned rule.
- **The pipeline now also runs at the render boundary**, so a menu passed
  directly as a `ContextMenuBuilder` gets the same treatment as one built by
  `createContextMenu`. 32 of the 139 consumer declarations never met the
  factory.
- **`meta.exportConfig` set by a consumer was silently overwritten** by the
  grid's own value; `test-server-grouping` had carried a dead filename that way.
  The `export` prop is the contract.

#### Added

- `ContextMenuItem.separatorBefore` -- the explicit group boundary, same word
  and meaning as `GridSelectionAction.separatorBefore` on the bulk bar.
- `GridExportRowScope` (`'page' | 'loaded'`), declared by the grid on
  `meta.exportConfig.rowScope`.

#### Removed

- `ServerGroupingContextValue.exportConfig` -- its only reader was the row
  menu's grouped-export branch, which is gone. Producers without readers are
  silent state.

### TD-36 — `enableColumnVirtualization` bounds the mounted column window

#### Fixed

- **`enableColumnVirtualization` did not bound the mounted column window at all
  (TD-36).** Measured in Chromium at 1600x900 on `/test-column-virtualization`:
  with the flag on, all 120 declared columns were mounted (1 200 cells, 4 835
  DOM nodes) — exactly as many as with the flag off — and scrolling 6 000px
  horizontally moved no column in or out of the window. After the fix the same
  page mounts **16 columns, 160 cells and 686 DOM nodes**, and the window
  follows the scroll.

  The cause was a single `useMemo`. `useColumnVirtualizer` derived its result
  under `[enabled, centerCount, virtualizer, leftPinnedCount, rightPinnedCount,
  scrollToColumnIndex]`, and all six are stable once the grid is mounted — the
  virtualizer instance most of all, because `@workspace/active-virtual` keeps it
  referentially stable by design and publishes changes through a version
  counter. On the first render the scroll container does not exist yet (it
  arrives through a callback ref), so the virtualizer measured a zero viewport,
  returned an empty window, and the memo cached the safe "render every column"
  branch. Nothing in the dependency list ever changed again, so that first
  answer stood forever — including after the measurement landed, and on every
  scroll after that.

  The window is now read during render, exactly as the row side has always done
  in `body-virtual.tsx` and exactly as `active-virtual` documents. Referential
  stability — which three `React.memo` boundaries depend on — comes from a value
  signature over the window (indices, both spacers, total width), never from the
  instance. There are no extra renders: the subscription already re-rendered the
  grid on every window change; the result was simply discarded. See
  ADR-030.
- **`scrollToColumnIndex` was always `undefined`, so keyboard navigation had no
  horizontal scroll-to-focus path.** It only appeared in the branch the memo
  never reached. It works now; until this release it was masked by the fact that
  every column happened to be mounted.

#### Changed

- **Autosize keeps measuring real cell content under column virtualization.**
  `measureCellsWidth` clones actual cells, and a column outside the window has
  none — it would have been sized on its header alone. The grid now suspends the
  column window for the duration of a measurement pass (`autoSizeOnMount`,
  `autoSizeAllColumns`, the single-column menu item) instead of narrowing the
  measurement. One pass per public entry point, not one per column, and the
  suspension is lifted in a `finally` so no state can be left behind. Grids
  without column virtualization are untouched: no runner, no extra render.
- **`enableColumnVirtualization` with `height="auto"` now dev-warns.** The prop
  has always documented a fixed height as a requirement; nothing enforced or
  said it. This is a warning in the shape of the other configuration warnings —
  no default changes, nothing is overridden and nothing is clamped.
- **The browser benchmark treats ineffective column virtualization as a scenario
  integrity failure.** The round fails when the flag mounts as many columns as
  the un-virtualized branch, when a 6 000px scroll moves no column in or out,
  or when header and body do not carry the same columns — checked on the column
  ids, because two equal counts prove nothing about which columns they are. The
  cross-check between the two branches was `on === off` while TD-36 stood; it is
  now `on < off` on columns, cells and DOM nodes.
- **`censusGrid` reports `mountedHeaderColumnIds`** next to the body window, so
  header/body drift is observable at all. Placeholder headers are excluded.
- **The Playwright-side census helpers are shared.** `waitForStableCensus`,
  `nextFrame` and `setScroll` moved from the benchmark file to
  `e2e/helpers/grid-census.ts`; the benchmark and the new
  `e2e/column-virtualization.spec.ts` now use one readiness contract instead of
  two copies of a settle window.

#### Added

- **`e2e/column-virtualization.spec.ts`** — 14 browser tests covering the
  product contract that jsdom cannot show: a bounded window with the flag on,
  every column with the flag off, strictly fewer columns/cells/DOM nodes than
  the un-virtualized grid, an unchanged horizontal scroll range, `aria-colcount`
  staying the declared total while `aria-colindex` stays absolute, columns
  entering and leaving on scroll, header and body on the same window, pinned
  columns surviving every scroll position, a valid window after grouping a
  column away, keyboard navigation reaching a column outside the first window,
  a cell outside the first window being reachable, and every column mounting for
  the duration of an autosize pass.
- **`src/hooks/use-column-virtualizer.test.tsx`** — 17 tests on the reactive
  chain itself, including the transition from the initial empty window to a live
  one, three different windows in a row, referential stability across renders
  that change nothing, and the shape of `ColumnVirtualizationResult` (the one
  maintenance trap of the signature approach).

---

### R-26 — the benchmark harness

#### Changed

- **The benchmark harness was rebuilt (R-26, phases B1-B4). No product code
  changed; the grid behaves exactly as in 10.2.1.** Phase B5, the freeze
  itself, ran on 2026-09-21 and is recorded above; the measured baseline is in
  `PERFORMANCE.md` § De bevroren ronde van 2026-09-21.

  - **`pnpm benchmark` ran no scenario at all.** `vitest.benchmark.ts` lacked the
    `server-only` alias that `vitest.config.ts` had, so the import chain of
    `src/components/grid.tsx` failed with "This module cannot be imported from a
    Client Component module". Both configurations now take their alias from one
    source (`vitest.shared.ts`), and a seconds-long smoke test
    (`pnpm benchmark:smoke`) loads that chain under the benchmark configuration
    itself.
  - **A normal run overwrote the frozen baseline.** The default output path was
    the file the documentation called the baseline. A run now writes to the
    ignored `benchmarks/local/`, and `benchmarks/baseline.json` has exactly one
    writer: `pnpm benchmark:freeze`, which requires an explicit confirmation,
    refuses a round that failed its machine preflight, writes atomically and
    re-reads and re-validates its own candidate before replacing anything.
  - **Three scenarios never reached their intended code path.** Grouping-expand
    looked for `button[title="Expand"]` (the row-detail expander, not the group
    toggle), found nothing, and a silent `if` published `expandLatencyMs: 0`.
    Editing looked for `data-column-id`, an attribute that does not exist, and a
    broad fallback selected the first gridcell of row 0. The column-virtualization
    pair mounted identical DOM in both branches and was still presented as
    virtualization evidence. All three are fixed, and there is no
    `if (element) { measure } else { report 0 }` left anywhere in the suite.
  - **The statistics now carry the conclusion.** Three warm-ups instead of one,
    N=15 instead of 5, raw samples kept, and median/p95/stddev/coefficient of
    variation/minimum detectable effect reported per metric. Counters are
    observed 15 times and must be exactly equal.
  - **Counters are the gate; timings are a trend.** The universal `+5%` rule is
    withdrawn: measured coefficients of variation ran to 68% on timings and past
    800% on memory, so that threshold fired on noise while being far too loose
    for `domNodes`. A timing difference never produces a non-zero exit code.
  - **`benchmarks/` was never typechecked.** It sat outside `tsconfig.json`.
    Adding it surfaced that every client scenario passed an
    `enableVirtualization` prop that does not exist on `ActiveGrid` and was
    silently ignored: row virtualization is an automatic decision above
    `virtualizationThreshold`, not an opt-in.
  - **Memory is measured for real or not at all.** `forceGc()` was a no-op
    because Vitest starts its workers without `--expose-gc`; the published
    negative heap deltas of hundreds of megabytes were an artefact. The harness
    now resolves `gc()` through `v8.setFlagsFromString` plus
    `vm.runInNewContext`, verifies it by calling it, and records
    `memoryPolicy: "trend"` or `"omitted"` accordingly.

#### Added

- **A browser benchmark round** (`pnpm benchmark:browser`) under the existing
  Playwright harness, in its own configuration with `retries: 0` and its own
  `**/*.bench.ts` discovery, so the ordinary e2e suite never picks it up. Six
  scenarios measure what only a real viewport can show: the mounted column
  window with the flag on and off, horizontal scroll, the row-virtualization
  latch, and a grouped grid collapsed versus genuinely expanded. It shares the
  10.2.1 layout-readiness contract rather than duplicating it, and adds one new
  shared browser-side contract (`e2e/helpers/grid-census-contract.ts`) with its
  own jsdom unit tests.
- **A validated result and baseline format** with scenario fingerprints,
  integrity evidence, full environment metadata, an explicit memory policy and
  a stated list of limitations; plus `pnpm benchmark:compare`, which reads and
  never writes.

#### Fixed

- `vitest.benchmark.ts` no longer uses the `poolOptions` shape Vitest 4 removed.

#### Known issue found by this work

- **`enableColumnVirtualization` does not bound the mounted column window**
  (TD-36). Measured in Chromium at 1600x900 on `/test-column-virtualization`:
  with the flag on, all 120 declared columns are mounted (1 200 cells, 4 835
  DOM nodes) — exactly as many as with the flag off — and scrolling 6 000px
  horizontally moves no column in or out of the window. Row virtualization is
  unaffected and demonstrably works. This is measured, not fixed: changing
  product behaviour to make a benchmark pass was an explicit non-goal.

  **Fixed separately in 10.2.2** (see above and ADR-030). The finding stands as
  written: it is what this round measured, and the round is the reason the
  defect was found at all.

## \[10.2.1\] - 2026-08-28 - De layoutwacht van het e2e-harnas wacht op een signaal, niet op een venster

Harnasreparatie. **Geen productcode gewijzigd, geen publieke API geraakt**; de
grid gedraagt zich exact zoals in 10.2.0.

### Fixed

- **`waitForGridLayoutSettled` accepteerde een layout die nog niet BEGONNEN was
  te bewegen.** De wacht toetste één ding: de geometriesignature ongewijzigd
  gedurende `stableForMs` (250 ms). Dat onderscheidt "beweegt nog niet" niet van
  "beweegt niet meer", en op `/test-showcase-grid` liggen die twee
  milliseconden uit elkaar. Gemeten over zeven mounts, elke keer identiek:

  | fase | venster | stil | toestand |
  | --- | --- | --- | --- |
  | pre-meting | ~7-265 ms | **247-265 ms** | header 8px breder dan het lichaam, `--grid-edge-clearance` op de stylesheetstandaard van 8px, inline vars leeg |
  | tussenstand | ~350-430 ms | 61-78 ms | vars gepubliceerd, totaal nog niet definitief |
  | definitief | vanaf ~465 ms | stabiel | header en lichaam komen overeen |

  Het venster van 250 ms lag middenin de spreiding van het plateau dat het moest
  uitsluiten. Landde een run in fase 1, dan las
  `grid-content-geometry.spec.ts` een header die 8px breder was dan zijn lichaam
  (gemeten `1876` tegen `1868`, twee keer identiek) en drukte
  `column-resize.spec.ts` waar de handle straks niet meer zou zijn. Dat is de
  flake die over de 10.1.2-runs werd gemeld; gemeten faalpercentage op de
  gerapporteerde test: 2 fouten in 23 runs.

  De wacht stelt nu eerst POSITIEF vast dat het product zijn geometrie heeft
  gepubliceerd -- `--grid-total-width` en `--grid-edge-clearance` als inline
  stijl op zowel de body-scrollcontainer als de headerwrapper -- en meet daarna
  pas stabiliteit. Alle vier zijn gemeten leeg gedurende fase 1 en gezet vanaf
  fase 2, dus fase 1 is per constructie onbereikbaar. Het venster van 250 ms
  blijft ongewijzigd: de reparatie is een signaal, geen langer getal.

  Na de reparatie: 85 runs van de reproductievorm zonder fout (was 2 fouten in
  6 runs), en 10 van 10 directe metingen op de definitieve geometrie (was 1 van
  8 in de tussenstand).
- **De timeoutmelding scheidt de twee faalwijzen.** Liep de wacht af, dan stond
  er "waitForFunction timed out" en verder niets -- terwijl "het product komt
  niet op gang" en "het product blijft herlayouten" verschillende problemen
  zijn. De melding noemt nu de gereedheid, de vier inline properties, het aantal
  headercellen, hoe lang de signature al stilstaat en welke van de twee
  voorwaarden het niet haalde.

### Changed

- **Eén settlecontract in plaats van drie.**
  `resize-handle-scroll-preservation.spec.ts` had een eigen `settleGrid()` --
  het enige dat de gereedheidshelft al eiste -- en gebruikt nu de gedeelde
  helper, die er positie én een venster aan toevoegt.
  `last-column-clearance.spec.ts` droeg dertien vaste waits: elf wachtten op
  layout na een navigatie, een style-injectie of een pin en zijn de gedeelde
  helper geworden; de andere twee paceden de wielloop in `scrollToEnd` en zijn
  een poll op de UITKOMST geworden (de scroller staat aan zijn einde en de
  header is gevolgd). Er staat geen `page.waitForTimeout` meer in de suite.
- **`horizontal-scroll-sync.spec.ts` wacht op het gedeelde contract in
  `beforeEach`.** De synchronisatieasserties zelf zijn relationeel en hielden in
  elke fase; wat op toeval rustte was de premisse -- de eenmalige
  "er is horizontale overflow"-controle draaide vóór publicatie en slaagde
  alleen omdat het pre-meettotaal van 1868px de viewport van 1024px óók
  overschrijdt. De `toPass`-lus om de scrollinteractie is bewust NIET vervangen:
  een tweede polling-laag zou alleen vertroebelen welke van de twee faalde.
- **De tolerantie-asymmetrie in `grid-content-geometry.spec.ts` is verklaard en
  behouden.** `TD-34` vergelijkt een som van vijftien FRACTIONELE celbreedtes
  met één geheel getal: sub-pixelophoping is daar echt en 1px is de kleinste
  tolerantie die haar toelaat. `TD-35` vergelijkt twee GEHELE getallen uit
  dezelfde layout die volgens het contract dezelfde ruimte beschrijven: daar
  bestaat geen ophoping, elk verschil is structureel, en exacte gelijkheid is de
  juiste vorm. Beide testnamen dragen dat nu, met de grond in de code. De
  TD-35-invariant is niet versoepeld.
- **De paginatekst werd buiten de paginator gezocht.** De vier
  `Pagina N van`-asserties in `virtualization-scroll.spec.ts` zochten die tekst
  overal onder de gridwrapper, en de grid draagt daar ook een
  `aria-live`-statusregio die een paginawissel 150 ms na de klik aankondigt en
  haar tekst daarna nooit wist. Na een navigatie heen en terug staat dezelfde
  zin twee keer in de boom -- sr-only en zichtbaar -- en dan valt de assertie om
  op een strict-mode-schending; of dat gebeurde hing af van de vraag of de
  assertie die 150 ms voor was. Gevonden in de volledige verificatierun van deze
  ronde. De selector noemt nu de paginator. Testfout, geen productfout: een
  statusregio die de huidige pagina aankondigt is precies goed.

### Added

- **`e2e/helpers/grid-layout-contract.ts`** -- de browserkant als één pure,
  zelfstandige functie. Zelfstandig omdat Playwright alleen de functiebody
  serialiseert en een geïmporteerde hulpfunctie in de browser `undefined` is;
  en juist daardoor in jsdom aanroepbaar, zodat de unittests EXACT de functie
  draaien die de browser uitvoert in plaats van een meelopende kopie.
- **19 unittests** (`grid-layout-contract.test.ts`) met een gecontroleerde klok
  (`performance.now` onder een spy), dus zonder één echte wachttijd: een lang
  stil maar ongepubliceerd plateau wordt geweigerd, het venster begint pas bij
  publicatie, de gemeten tussenstand van 78 ms haalt het niet, een definitief
  stabiele toestand slaagt, het rapport blijft diagnostisch, en twee grids op
  één pagina delen geen venster. `vitest.config.ts` draait ze mee; `tsconfig.json`
  typecheckt de twee bestanden.

## \[10.2.0\] - 2026-08-28 - Elk publiek GridConfig-veld doet werkelijk iets

Drie velden van `GridConfig` waren gedeclareerd, hadden een standaardwaarde,
werden door de merge doorgegeven en stonden in `docs/api/columns.md` -- inclusief
een providervoorbeeld dat consumenten aanmoedigde ze te zetten -- terwijl geen
enkel runtimepad ze las. Ze zijn nu alle drie aangesloten, met een wachter die
voorkomt dat er ooit een vierde bij komt.

Publieke GEDRAGSUITBREIDING, geen breuk in de aanroep: wie de velden niet zet
merkt niets, en geen enkele consument in deze monorepo zet ze. Wie ze wél al
zette kreeg tot nu toe stil de pakketstandaard en krijgt vanaf nu wat hij vroeg
-- zie **Migration** onderaan.

### Fixed

- **`pagination.pageSize` is de globale startpaginagrootte.** De keten is nu
  `URL / persisted → initialPageSize → GridConfig.pagination.pageSize → 10`, met
  de arbitrage tussen URL en localStorage (`statePriority`) ongewijzigd. Dat de
  prop van de config wint is niet nieuw bedacht: het is de vorm die élk levend
  `GridConfig`-veld al had. De waarde wordt uitsluitend in het deterministische
  zaad van de store gelezen, dus SSR blijft deterministisch, de eerste render
  draagt de maat al, en een provider-rerender reset een lopende grid niet.

  Een aanwezige maar ongeldige waarde (nul, negatief, gebroken, `NaN`) wordt
  GEWEIGERD en niet geklemd of afgerond, en telt daarna als AFWEZIG: de keten
  gaat door naar de volgende bron. Een tikfout in één grid slaat de bewust
  ingestelde platformwaarde dus niet weg. Een maat buiten `pageSizeOptions`
  blijft toegestaan -- die lijst valideert URL-invoer, niet de configuratie van
  de ontwikkelaar.
- **`export.csvSeparator` bepaalt werkelijk het scheidingsteken.** Het type is
  aangescherpt van `string` naar `CsvDelimiter`: precies één teken uit `,` `;`
  `\t` `|`, een POSITIEVE lijst die door het type gesloten is en op runtime
  opnieuw wordt getoetst, omdat een providerconfiguratie uit gewoon JavaScript
  kan komen. Een verbodslijst zou een spatie of een letter doorlaten, en een CSV
  met `a` als scheidingsteken ziet er geldig uit tot de eerste waarde die de
  letter bevat.

  De quoting hangt NIET van het teken af: elk CSV-veld is onvoorwaardelijk
  gequote, dus een waarde die het scheidingsteken, een aanhalingsteken of een
  regelovergang bevat is per constructie al veilig. De
  formule-injectiebescherming staat onveranderd op dezelfde grens, en het
  TD-22-gedrag rond een leidende plus is niet geraakt. De klembord-TSV deelt
  bewust geen configuratie met de CSV-export: een plak is een tab.
- **`filtering.operatorPresets` is een werkend configuratiecontract.**
  `filter-utils` las de moduleconstante rechtstreeks en ging om de config heen;
  de effectieve presets worden nu door de filtercomponenten doorgegeven, zodat
  de pure filterlaag geen React-context importeert. Een provider mag ingebouwde
  presets overschrijven en eigen presets declareren; de map wordt PER SLEUTEL
  gemerged, zodat één preset toevoegen de ingebouwde niet wegneemt.

  `meta.operatorPreset` is `BuiltInOperatorPreset | (string & {})`: de
  ingebouwde namen houden autocomplete, een eigen naam is een gewone
  tekenreeks, en de buurvelden (`filterType`, `allowedOperators`) blijven
  gesloten unions. Een preset kan uitsluitend VERSMALLEN, dus een eigen preset
  kan nooit een operator activeren die de servercapability verbiedt. Vier
  foutgevallen zijn expliciet en dev-gewaarschuwd: een onbekende naam (geen
  versmalling), een ongeldige operatornaam (die ene wordt gedropt), een dubbele
  operator (ontdubbeld, eerste plaats behouden) en een lege preset (geweigerd --
  een lege versmalling laat een filter zonder keuze achter).
- **`withEmpty` compileert.** De docblock in `types/filters.ts` noemde
  `meta.operatorPreset: 'withEmpty'` als de gedocumenteerde opt-in, terwijl de
  union van vijf namen hem niet toestond. De union wordt nu AFGELEID uit
  `OPERATOR_PRESETS`, dus de twee kunnen niet meer uiteenlopen.
- **Vier verouderde standaarden en een ontbrekende sectie in
  `docs/api/columns.md`:** `layout.rowHeight` 40 → 42, `layout.headerHeight`
  36 → 48, `layout.filterBarHeight` 32 → 36, `columns.minWidth` 100 → 120, en de
  `ui`-sectie ontbrak volledig. Alle overige standaarden zijn tegen
  `DEFAULT_GRID_CONFIG` gecontroleerd en kloppen.

### Changed

- **Een geneste `GridConfigProvider` legt zijn overrides over de EFFECTIEVE
  configuratie van de boom waarin hij staat**, niet over de kale
  pakketstandaarden. De documentatie zei al "wrap your app (or a section)", en
  een sectie die één ding bijstelt hoorde niet stil alles kwijt te raken wat de
  app-provider erboven had gezet. Voor de buitenste provider verandert er niets.
- **`GridConfig.export.csvSeparator` is versmald van `string` naar
  `CsvDelimiter`.** Type-technisch een versmalling op een publiek veld; runtime
  onmogelijk te merken, want het veld deed niets. Gemeten aantal consumenten dat
  het zet: nul.
- **De publieke configtypen tellen mee in `lint:api-drift`.**
  `GridPaginationDefaults` stond als "internal engine" op de uitsluitingslijst
  en de zeven zustervormen onder een regex als "implementation detail", terwijl
  ze uit `src/index.ts` worden geëxporteerd. Die uitsluiting was precies de
  reden dat de enige controle die naar deze vorm keek, er juist niet naar keek.
  `ActiveGridUIConfig`, `ActiveGridMenuConfig`, `CsvDelimiter` en
  `BuiltInOperatorPreset` zijn erbij geëxporteerd en gedocumenteerd.

### Added

- **Een wachter op dode publieke configvelden**
  (`config/__tests__/config-liveness.test.ts`). Hij inventariseert elk blad van
  `DEFAULT_GRID_CONFIG` en eist per blad een CONFIG-GEWORTELDE lezing: de
  leesplek moet beginnen bij een identifier waarvan in datzelfde bestand blijkt
  dat hij de gridconfiguratie draagt. Een naïeve zoektocht op de veldnaam gaat
  hier aantoonbaar vals groen -- `gridState.pagination?.pageSize` is
  engine-state -- en dat geval staat als test in de suite. Twee registers dragen
  de uitzonderingen, elk met een verplichte reden; het non-goal-register is leeg
  en hoort dat te blijven. De analyse is een pure functie, zodat de laatste
  beschrijving haar met een verzonnen dood veld voedt: een wachter die zelf
  nooit rood is geweest bewijst niets.
- **Tests voor `mergeConfig` en `GridConfigProvider`**, die tot nu toe volledig
  ontbraken: gedeeltelijke secties, mergediepte, de per-sleutel-uitzondering,
  nesting, isolatie tussen bomen, en dat een provider `DEFAULT_GRID_CONFIG` niet
  muteert.
- **Een documentatiecontract-test** die elke gedocumenteerde standaard in
  `docs/api/columns.md` tegen `DEFAULT_GRID_CONFIG` legt en die eist dat elk
  publiek configtype in het document genoemd wordt. Dit is de regressie op de
  vier verouderde getallen en op de drift-uitsluiting.
- **Een type-regressietest** (`types/__tests__/operator-preset-typing.test.ts`)
  die met `@ts-expect-error` vastlegt dat de vrije presetnaam zijn buurvelden
  niet verbreedt.
- `FILTER_OPERATORS` en `isFilterOperator` in `types/filters.ts`: het
  operatorvocabulaire als runtimewaarde, gesloten door het type, nodig zodra een
  operatornaam van buiten TypeScript binnenkomt.

### Migration

Geen enkele consument hoeft iets te doen. Twee gevallen verdienen aandacht:

1. **Wie `pagination: {pageSize: N}` of `export: {csvSeparator: X}` al in een
   `GridConfigProvider` zette**, kreeg tot nu toe stil de pakketstandaard (10,
   respectievelijk een komma) en krijgt vanaf 10.2.0 de waarde die er staat. Als
   die waarde ooit als decoratie is neergezet, is dit het moment om haar te
   controleren.
2. **Wie een geneste `GridConfigProvider` gebruikt** om een sectie bij te
   stellen, erft vanaf nu de instellingen van de provider erboven in plaats van
   terug te vallen op de pakketstandaarden.

`csvSeparator` accepteert type-technisch alleen nog `,` `;` `\t` `|`. Een andere
waarde was al zonder effect, dus er is geen gedrag dat verandert -- alleen een
compilerfout die er eerder niet was.

### Internal

- `@workspace/active-utils` kreeg een optionele delimiterparameter op `csvRow`
  (standaard komma) plus `DEFAULT_CSV_DELIMITER` en een eigen testsuite. De
  helper blijft dom: welke scheidingstekens zijn toegestaan is een
  productvraag, en die woont in `active-grid/src/config/csv-delimiter.ts`. Geen
  gekopieerde serializer, geen wrapper. Zie de CHANGELOG van dat pakket.
- De drie CSV-exportfuncties kregen een optionele trailing `csvDelimiter`-
  parameter. Bewust geen veld op `GridExportRunOptions`: die opties gelden voor
  élk formaat, en een scheidingsteken zegt niets over JSON, XLSX of PDF.

## \[10.1.3\] - 2026-08-28 - `initialPageSize` is de startwaarde, ook zonder bewaarde stand

`initialPageSize` deed niets op precies de grid waarvoor het woord "initial"
staat: een verse grid, zonder gedeelde link en zonder bewaarde voorkeur. De prop
werkte alleen daar waar zij per definitie verliest -- naast een URL- of
localStorage-stand die haar overschrijft.

Interne bugfix plus de bijbehorende documentatie. Geen nieuwe publieke prop,
geen gewijzigde URL-sleutel, geen nieuwe opslagversie, en geen enkele consument
in deze monorepo verandert van gedrag: geen van hen geeft de prop mee.

### Fixed

- **De startstand van de store draagt de prop.** De store werd gebouwd met
  `getInitialGridState(allFeatures, {})` -- de kale featurestandaard,
  `{pageIndex: 0, pageSize: 10}` -- en `initialPageSize` bereikte de state
  uitsluitend via de pagineringstak van `loadInitialGridState`. Die tak draait
  alleen wanneer de URL of localStorage al een pagineringsstand DRAAGT. Zwegen
  beide, dan raakte de prop de state nooit aan. Zij wordt nu in het zaad van de
  store gezet, in de `useState`-initializer, en de hydratie legt daar alleen
  EXPLICIETE externe stand overheen.

  Het zaad is deterministisch -- de prop is op de server net zo bekend als in de
  browser -- dus dit verandert niets aan het SSR-gedrag en er is geen zichtbare
  sprong van 10 naar de ingestelde maat: de eerste render draagt haar al. De
  reparatie zit bewust niet in een effect, een `setTimeout` of een tweede
  layout-effect, om precies die sprong niet te introduceren.
- **De precedentie staat nu opgeschreven en is getoetst:** een expliciete
  geldige URL-stand, dan een expliciete geldige bewaarde stand (die twee
  onderling geordend door `statePriority`), dan `initialPageSize`, dan de
  pakketstandaard 10. De prop overschrijft dus nooit wat een gebruiker zelf koos
  of deelde, en een latere render met een andere waarde reset een lopende grid
  niet: de startstand wordt één keer bepaald.
- **Een ongeldige `initialPageSize` wordt geweigerd, niet gerepareerd.** Nul,
  negatief, gebroken of `NaN` levert de pakketstandaard plus een dev-waarschuwing
  die de grid noemt -- niet een klemming naar 1 en niet een afronding, want
  beide veranderen een tikfout in een werkende grid. Dezelfde discipline als de
  per-slice-validatie van bewaarde stand. Er is bewust GEEN toets tegen
  `pageSizeOptions`: die lijst valideert URL-invoer, en een maat buiten de lijst
  toont de paginator al bij.

### Internal

- **Vier losse literals `10` zijn één autoriteit geworden.** `DEFAULT_PAGE_SIZE`
  in `engine/features/row-pagination/rowPaginationFeature.utils.ts` is de enige
  plek waar dat getal staat; de featurestandaard, de nieuwe resolver
  (`state/initial-pagination.ts`), de URL-serializer die de standaard juist
  WEGLAAT, de terugval van de paginator en `DEFAULT_GRID_CONFIG.pagination`
  lezen haar. Geen gedragswijziging -- vier defaults die onafhankelijk konden
  verlopen zijn er nu één.
- Nieuwe module `state/initial-pagination.ts` met
  `resolveInitialPaginationState()`, `resolveInitialPageSize()` en
  `isValidInitialPageSize()`. Intern; niet uit een barrel geëxporteerd.
- Nieuwe suites: `state/initial-pagination.test.ts` (26),
  `hooks/use-grid-state.initial-page-size.test.ts` (22, door de ECHTE
  hydratielaag heen, inclusief de vier `statePriority`-standen) en
  `components/__tests__/initial-page-size-contract.test.tsx` (6, op de
  gemonteerde grid, met een render-opnemer voor het flikkerbewijs).
  `state/load-initial-state.test.ts` kreeg drie gevallen voor de samengestelde
  URL-slice erbij.

## \[10.1.2\] - 2026-08-28 - Een toets hoort bij het element dat hem kreeg

`Enter` activeerde geen enkel besturingselement binnen een raster. De wisknop
van een actief kolomfilter deed niets, het filtermenu ging niet open, het
kolommenu evenmin, en de caret in een filterveld bewoog niet naar rechts --
terwijl Spatie en de muis wel werkten. In 10.1.1 stond bij dat gedrag de
aantekening dat het buiten dit package lag. Dat was onjuist: het lag hier, in
`use-keyboard-navigation`.

Interne bugfix. Geen publieke API-wijziging, geen nieuwe prop, geen
consumer-migratie, en geen wijziging in enige app.

### Fixed

- **De INGANG van het raster geldt alleen voor de eigen tabstop van de
  container.** `containerProps.onKeyDown` bestaat voor één ding: staat de focus
  op de gridcontainer en draagt nog geen cel focus, dan brengt `ArrowDown`,
  `ArrowRight` of `Enter` de focus naar de eerste cel. Die voorwaarde stond in
  `guides/keyboard.md` maar niet in de code. Zonder haar
  annuleert de handler `Enter` voor élke toets die via de React-boom bij de
  container aankomt -- en de container omsluit de héle grid, inclusief de
  headerrijen, de filterrij en (via de React-boom, niet de DOM) elke popover die
  vanuit de grid door een portal wordt gerenderd.

  De grens is nu `e.target === e.currentTarget`, plus een uitzondering voor
  Ctrl, Meta en Alt. Er is bewust GEEN lijst van knoppen, links, invoervelden,
  contenteditables of interactieve rollen: alles wat niet de container zelf is,
  hoort bij iets anders, en een onbekend doel valt daarmee per constructie aan
  de veilige kant. `onFocus` op diezelfde container trok die grens al (`AG-06`);
  `onKeyDown` deed het niet.
- **`Enter` en `Spatie` op een besturingselement binnen een BODYcel zijn van dat
  besturingselement.** Het tweede pad, in `handleKeyDown`: zodra de action mode
  een knop of link binnen de cel focus had gegeven, annuleerde de cel de
  volgende `Enter` en `Spatie` alsnog. Gemeten op `/test-showcase-grid` met de
  focus op de uitklapknop van een rij: nul clicks, op beide toetsen. Spatie
  ontsnapte hier niet, want een geannuleerde keydown haalt ook de
  keyup-activering van een `<button>` weg.

  Dezelfde grens, dezelfde grond. `Escape`, `Tab` en de pijltoetsen blijven van
  het raster: die dragen de action mode zelf.
- **De gevolgen die dit had.** Per grid-instantie was het EENMALIG en daarmee
  moeilijk te reproduceren: de ingang zette bij haar annulering `focusedCell` op
  (0,0), waarna de volgende Enter wel doorkwam. Zij verplaatste bovendien de
  focus, dus een toetsenbordgebruiker verloor zijn plek in de tabvolgorde.
  `ArrowRight` stond in dezelfde lijst, dus ook de caret in een filterveld bleef
  staan. Het raakte elk raster in elke app, en niet één schil.

### Changed

- **De release-notitie van 10.1.1 is gecorrigeerd** (zie hieronder). Drie
  waarnemingen leidden destijds de verkeerde kant op, en zij staan bij die
  release toegelicht zodat de conclusie niet nog eens getrokken wordt.
- **De e2e-suite toetst de wisknop nu met Enter**, met Spatie en de muis
  ernaast; de omweg via alleen Spatie is opgeheven. De unittesthelper van
  `use-keyboard-navigation` modelleert `target` en `currentTarget` voortaan
  apart -- een helper die er één van maakte, kon het verschil niet uitdrukken en
  liet dit defect groen.

## \[10.1.1\] - 2026-08-27 - De waarde wijkt, de bediening nooit

Een filter met een actieve waarde kon zijn eigen wisknop uit de cel duwen. Op
`/media?view=list` stond in een kolom van 130px `2 geselecteerd`, en de X was
onvindbaar: gedeeltelijk of volledig weggeknipt, of getekend bovenop de laatste
tekens van de waarde. Het filter was daarmee niet meer op te heffen zonder eerst
de kolom breder te slepen.

Interne bugfix. Geen publieke API-wijziging, geen consumer-migratie, geen nieuwe
prop -- een consumer hoeft hiervoor niets te declareren en geen eigen `minWidth`
of workaround toe te voegen.

### Fixed

- **De filtercel heeft één anatomie, en die is nu een component.** Alle zes de
  ingebouwde filters (`text`, `number`, `date`, `select`, `boolean`, `interval`)
  droegen byte-identieke, zes keer gekopieerde markup voor de wortel, het
  waardegebied, de wisknop en de trechterknop. Zij delen nu `FilterCellShell`
  (intern), met het contract **de waarde wijkt, de bediening nooit**:

  ```
  [ waarde / invoer          ][ wissen 24px ][ menu 24px ]
    min-w-0, krimpt tot nul     shrink-0       shrink-0
  ```
- **De hoofdoorzaak: het waardegebied miste `min-w-0`.** Daardoor was zijn
  minimumbreedte de MIN-CONTENT van de waardetekst -- voor een `truncate`-span
  is dat `white-space: nowrap`, dus de VOLLE tekstbreedte -- en `truncate` kwam
  nooit aan bod. Gemeten: een waardegebied met `2 geselecteerd` weigerde onder
  99px te krimpen, waarna de filterwortel breder werd dan de cel en
  `.grid-header-cell` (`overflow: hidden`) de bediening wegknipte. Alleen de
  span-gebaseerde filters (`select`, `date`, `boolean`) konden zo overlopen; een
  `<input>` schaalt nooit mee met zijn waarde.
- **De wisknop is een flex-BROER in plaats van een absolute overlay.** De oude
  `absolute right-1` had geen gereserveerde ruimte naast zich, dus de X werd
  bovenop de waarde getekend: aanwezig, aanklikbaar, en visueel onvindbaar. Een
  broer kan per constructie niet overlappen, en hij volgt bovendien de
  schrijfrichting in plaats van `right` hard te coderen.
- **Een filterbare kolom krijgt een effectieve minimumbreedte van 80px**, zodat
  de bediening bij élke toegestane breedte volledig binnen de cel valt. Afgeleid
  en niet gekozen: 2x14 celpadding (`--grid-cell-padding-x`, de coarse-pointer
  waarde) + 2x24 doelvlak + 2x2 gap. De vloer beschermt UITSLUITEND de
  bediening en houdt vertaalde waarden bewust niet leesbaar -- `2
  seleccionado(s)` is 15px breder dan `2 geselecteerd` en de telling erin is
  dynamisch, dus een vloer die tekst garandeert bestaat niet. De vloer VERHOOGT
  alleen: een kolom met een ruimere eigen of grid-brede `minSize` blijft
  ongemoeid, en met `settings.showFilterRow: false` geldt hij niet.
- **Toegankelijkheid van de wisknop.** Drie zelfstandige defecten die op ELKE
  kolombreedte golden, ook op een brede: de knop droeg `tabIndex={-1}` en was
  daarmee op geen enkele breedte met het toetsenbord te bereiken; hij had geen
  toegankelijke naam; en zijn doelvlak was zo groot als zijn pictogram (12x12).
  Hij is nu focusbaar, heet `Filter <kolom> wissen` (nieuwe sleutel
  `grid.filter.clearFilterFor`, in alle vijf de beschikbare talen), en meet
  24x24 -- WCAG 2.2 SC 2.5.8 Target Size (Minimum), AA. De trechterknop kreeg
  hetzelfde doelvlak en een zichtbare focusring.
- **De kolomnavigatie laat de header met rust.** In `handleKeyDown` kwam een
  vroege return voor toetsen uit `.grid-header-wrapper`. *Correctie in 10.1.2:*
  die wachter is ONBEREIKBAAR. `getCellProps` wordt uitsluitend op BODYrijen
  toegepast, dus een headertoets komt daar niet langs. Hij staat er sindsdien
  als vangnet, met die vaststelling erbij.

### Bekende beperking (buiten dit package) -- INGETROKKEN in 10.1.2

> **Deze notitie was onjuist.** De oorzaak lag wél in dit package, in
> `use-keyboard-navigation`. Zie [10.1.2](#1012---2026-08-28---een-toets-hoort-bij-het-element-dat-hem-kreeg).
> De oorspronkelijke tekst blijft hier staan met haar weerlegging, want de drie
> waarnemingen eronder zijn stuk voor stuk plausibel en leiden alle drie de
> verkeerde kant op.

De oorspronkelijke bewering luidde dat `Enter` de wisknop niet activeerde in de
admin-schil terwijl Spatie en de muis dat wel deden, en dat dit aantoonbaar
buiten `@workspace/active-grid` lag. Drie waarnemingen droegen die conclusie, en
zo staan zij ervoor:

1. *"Een IDENTIEKE gekloonde DOM-knop in dezelfde filtercel activeert wél op
   Enter."* **Vals negatief.** `cloneNode` kopieert geen React-fiber, dus React
   vindt voor die knop geen doelinstantie en voert de handlers van de
   React-voorouders helemaal niet uit. De proef kan alleen NATIVE luisteraars
   uitsluiten, nooit gedelegeerde.
2. *"Met de kolomnavigatie volledig uitgeschakeld blijft Enter onderdrukt."*
   **Kan niet kloppen.** `enableKeyboardNavigation: false` zet zowel `enabled`
   als het spreiden van `containerProps` uit; dan kan deze handler niet draaien.
3. *"Zij wordt pas op DOCUMENT-niveau geannuleerd, dus door een handler die niet
   op de DOM-keten van de grid staat."* **De premisse klopt, de gevolgtrekking
   niet.** In de Next.js App Router hydrateert React het hele document, dus élke
   React-`onKeyDown` draait daar -- ongeacht waar hij in de boom hangt.

De vierde bewering, dat het "elke gewone knop in die schil" raakte, was ook
onjuist: buiten een raster werkte Enter gewoon.

## \[10.1.0\] - 2026-08-27 - The grid owns the bulk bar, and an action says what it can act on

Two additions in one release, and they answer the same question from opposite
ends: what may a reader select, and what happens when they act on it.

The grid now draws its own selection action bar -- `[N selected] [Actions v]`,
one shape whatever the number of actions -- and an action declares whether it
can execute a DATASET-WIDE selection. That second half closes a real gap: a
grid could offer "select all 5000 rows" beside an action that only accepts
explicit ids, and every measured bulk action in this repository accepts
explicit ids. Measured on `/media`: the banner said 32 selected, the bulk bar
said 10, and the delete removed 10.

Alongside that, the exclusive selection model now carries the QUERY that gives
it meaning. Minor and not major: everything is additive, and a grid that
declares no `selectionActions` behaves exactly as it did.

### Added

- **`selectionActions` -- the declarative bulk-action contract**
  (ADR-026).
  A non-empty array plus `enableRowSelection` makes the grid render its
  selection action bar above the body. The grid owns the bar, the count, the
  fixed Actions menu, keyboard behaviour, the default and destructive
  presentations, and the busy state -- an async handler holds the whole menu
  until it settles, so one action cannot be started twice and two cannot
  overlap. The consumer owns which actions exist, their labels and what they
  do.

  **The shape never varies with the number of actions.** One action lives in
  the same menu as five. There is no `ReactNode` slot: a free slot would let
  every consumer design its own bulk bar again, which is the divergence this
  contract replaces. There is no "Clear selection" item either -- the header
  checkbox already does that -- and no disabled item standing in for a missing
  permission, which is expressed as absence.
- **`GridSelectionAction.supportsDatasetWideSelection`** (default `true`).
  Whether an action can execute an `exclusive` selection. It works in both
  directions: an action that cannot is left OUT of the menu while such a
  selection is active, and when NO declared action can, the grid does not offer
  dataset-wide select-all in the first place. An invitation into a state where
  nothing works is worse than no invitation.
- **`SelectionQuery` on the exclusive selection model.** "Everything except
  these ids" is meaningless without the query that produced it, so the model
  now carries it: the grid's own `filters` and `search`, plus the consumer's
  `serverQueryKey` as `externalKey`. Carried, never interpreted. A mutation
  must still re-derive and re-authorize all of it server-side and must never
  compute its effect from `totalCount`, which is a display number that arrived
  from the client. See `guides/selection.md`
  § Executing an exclusive selection.

### Changed

- **The footer stops repeating the selection count** while the selection action
  bar is showing it. Both rendered the identical string, one selection reading
  as two facts. Only grids that declare `selectionActions` are affected; the
  footer counter is unchanged everywhere else.

### Fixed

- **A server-mode grid could stall on its first load and never ask again**
  (R-32, ADR-025, 2026-08-27). The fetch-trigger
  effect owned a cleanup that aborted the in-flight request whenever one of its
  dependencies moved. `debouncedPageIndex` is such a dependency, and a page
  change does not invalidate the dataset — so the invalidation token stayed
  equal, the re-fired effect skipped on the in-flight guard, and the aborted
  request's `finally` set no state and therefore scheduled no further render.
  The grid sat on `isLoading: true` with no rows, no error and no second
  request, indefinitely. Reachable by hydrating to a page index in another
  block (`enablePersistence` / `enableUrlSync`) and by paginating to an
  uncached block while the first request was still open. This is the residual
  of the ADR-015
  defect class, on the dependency ADR-015 itself added.

  The repair: the trigger effect has no cleanup; cancellation of a superseded
  request stays in `fetchData` (which already aborts its predecessor before
  creating the next controller); cancellation on unmount moved to a dedicated
  no-deps effect; and the in-flight identity became `{token, blockIndex}`
  instead of the token alone, so a different block under the same token is
  recognised as a legitimately new request. The double-fire guard that
  `refetch()` / `retry()` depend on is unchanged, and the previously rejected
  synchronous guard reset stays rejected.

  Not a cause, despite the original diagnosis: `settings.showFilterRow: false`,
  an explicit `serverOptions.blockSize`, and `meta: {filterType: undefined}`.
  All three are supported presentation-side configuration with no path to the
  datasource, now pinned as a five-case contract matrix.
- **Server mode rendered filter controls the server would never honour.** The
  filter row and each filter widget existed on `meta.filterType` alone. In
  server mode `enableColumnFilter` is already forced to `false` for every
  column without a `server.filter` mapping, so a column type preset that
  injects a `filterType` produced a fully interactive input that did nothing —
  and, since server mode is `manualFiltering`, did not filter the loaded page
  either. Both now also require the resolved capability
  (`column.getCanFilter()`). `settings.showFilterRow` remains a visibility
  override in both directions; forcing the row on no longer manufactures
  filters that are not declared.

  **Consumer note:** a server-mode grid whose columns carry `meta.filterType`
  but declare no `server.filter` mapping loses its filter row. If the server
  does accept those filters, declare them — `fieldFilter()` on `column.server`,
  or the field in the resource capability map. The `/test-server-grid` harness
  in `apps/admin` was corrected this way in the same change.

### Added

- **`serverQueryKey` — the identity of the EXTERNAL query** (R-32, 2026-08-27).
  A grid owns its own page, sort, column filters and search, and already treats
  a change in any of those as a new result set. A page can carry a SECOND set of
  controls outside the grid that narrows the same query server-side — a toolbar
  search box, a folder picker, a kind filter in the URL — and the grid cannot see
  those. Without a signal it keeps serving the previous scope's blocks, and it
  leaves the reader on page 5 of a result set that now has one page: an empty
  body with nothing on screen explaining why.

  The prop is an opaque string; the grid never parses it. A change joins the
  dataset key, so the existing invalidate-and-refetch path runs unchanged — there
  is no second fetch mechanism — and `useGridSetup` adds the two consequences the
  engine already applies to its own filters: `pageIndex` back to 0 and the row
  selection cleared. Build it from the values themselves so returning to a
  previous scope is genuinely the same key.

  Fully additive: `BlockCache.buildDatasetKey` takes the key as an optional
  `externalKey`, and `JSON.stringify` omits it when undefined — an existing
  dataset key is byte-identical to what it was before. Server mode only; inert in
  client mode.
- **The page-index debounce now resets on a dataset change.** The 150 ms debounce
  protects against a request per page while someone click-holds through
  pagination WITHIN one result set; across result sets there is nothing to
  protect, and the previous page index belongs to a question that no longer
  exists. Measured on a grid sitting on page 3 (`blockSize: 10`) whose external
  query changed: blocks 2, 3 and 1 went out before 0 followed — four requests
  where one was needed, three of them for a result set nobody asked for. The
  built-in filters never had this problem because their `onChange` writes
  `pageIndex: 0` in the SAME state update, well before their own 300 ms debounce
  flips the dataset key; an external query arrives in one render and has no such
  head start. `useDebouncedPageIndex` gives it back.
- **`serverOptions` validation.** `blockSize` and `cacheSize` must be a finite
  integer of at least 1; anything else throws a `ValidationError` with code
  `GRID_SERVER_OPTIONS_INVALID` naming the field. Previously `0` and negatives
  quietly collapsed `pagesPerBlock` to 1 and `NaN` reached the server as
  `fetchSize: NaN`, both without a message. `prefetch` is deliberately left to
  the type system.
- **Dev-only warning for an inert `blockSize`** — a block smaller than the UI
  page size holds one visible page, so every page change is a server call.
  Warn-once per grid + blockSize + pageSize.
- **Dev-only warning for an idle server-mode datasource** — hydrated, no
  request in flight, no rows, no error, for several seconds. Names the grid and
  the blocking condition. Under ADR-021 a grid that never
  fetches is visually identical to one that is still fetching, which is what
  made R-32 take weeks to localise. Diagnostic only: no UI change, no retry, no
  production output.

---

## \[10.0.0\] - 2026-08-26 - A value passes one boundary, and the grid draws its own scrollbar

**Breaking: `getDefaultColumnTypes()` no longer takes a presentation.** The
release's through-line is that a value crosses exactly one boundary on its way
to the screen. Accessors stopped formatting, the presentation now enters the
grid once through the `locale` prop, and absence means the same thing in the
cell, in the aggregate and in both exports — for `null`, `undefined`, `''` and
`NaN` alike, while `0`, `false` and `Infinity` are values that stay. Alongside
that, the grid stopped showing the platform's scrollbar and started drawing its
own. See the entries below; migration for direct callers of
`getDefaultColumnTypes()` is to drop the argument.

### Fixed

- **A grid with column groups reserved twice the horizontal scroll range it
  needed** (TD-34, 2026-08-26). `--grid-total-width` was summed from
  `getFlatHeaders()`, which by contract returns every node of the header tree
  "including parent headers" — not the leaf row. Without column groups the two
  sets are identical, which is why this went unseen; with them, the group row
  contributes a placeholder for every ungrouped column plus a group header as
  wide as its leaves together, and the total came out exactly doubled. Measured
  on a 14-column showcase grid: 2888px of columns published as 5776px, so half
  the horizontal scroll range was empty space to the right of the last column.

  The source is now `getLeafHeaders()` in **both** writers
  (`useColumnSizingVars` and `writeCssVarsToContainers`). TD-12's invariant is
  untouched: what that fix protects is that the total comes from full grid
  state and never from the caller's partial `widths` map, and its docblock
  already said "sum of ALL leaf widths" — only the function it named disagreed.
  Grids without column groups are unaffected; their total is unchanged.
- **The header and the body could disagree about how far the grid scrolls**
  (TD-35, 2026-08-26). With `minGridWidth`, the grid wrote an inline
  `min-width` on the scroll content that omitted `--grid-edge-clearance` (and
  the `100%` floor) while the stylesheet included both. In the body that was
  harmless, because the clearance lives one level up on `.grid-scroll-content`;
  in the header `.grid-container` IS the scroll content, so the header lost it.
  The body could then scroll one clearance further than the header, and at
  full-right scroll a column's header stood beside its own cells — measured at
  10px on the showcase grid, with the body reaching `scrollLeft` 4812 against
  the header's 4802.

  The inline declaration now repeats every term of the stylesheet formula
  instead of replacing it with a narrower one, so `minGridWidth` adds a floor
  rather than a second geometry. Only grids that set `minGridWidth` were
  affected.

### Changed

- **The grid draws its own scrollbar, and it looks the same in every engine**
  (AG-SCROLL-01, 2026-08-26). The body, the pagination bar and the grid's own
  menu lists showed the platform's scrollbar — on Windows/Chromium the Fluent
  one, with an arrow button at each end — where they were meant to show the
  grid's.

  **Why the platform scrollbar could not be styled into shape.** Chromium and
  WebKit let a page replace it through `::-webkit-scrollbar`; Firefox offers
  `scrollbar-width` and `scrollbar-color` and nothing else — no lane, no inset,
  no radius, no hover rule — and an engine that implements both abandons the
  pseudo-elements the moment either standard property carries a non-initial
  value. Measured across the three with one stylesheet: an 8px Firefox gutter
  against a 10px Chromium one, different thumb shapes, and a hover state in one
  engine only. The obvious capability query is worse than useless: Firefox
  parses unknown `::-webkit-` pseudo-elements on purpose, so
  `CSS.supports('selector(::-webkit-scrollbar)')` is **true** there while a
  width set on that pseudo-element changes the reserved gutter by 0 — a gate
  built on it silently gave Firefox neither branch.

  **What replaced it.** `GridScrollArea` wraps Base UI's Scroll Area around the
  grid's existing scroll element. The scrollbar is a DOM element; the scrolling
  is not. **The native scroll container remains the single scroll authority** —
  Base UI's `Viewport` IS `.grid-scroll-container`, not a wrapper around it, so
  `scrollTop`/`scrollLeft`, wheel, trackpad, keyboard, programmatic scrolling,
  virtualization and the header's scroll synchronisation all keep talking to the
  same element. Verified in Chromium, Firefox and WebKit: one overflowing
  scroller per surface, lane 10px, thumb 7px, `var(--border)`, radius 9999px,
  `scrollbar-width: none` on the viewport — identical in all three. No browser
  is named anywhere in the code.

  **Layout.** The vertical scrollbar overlays the right edge, where the trailing
  clearance is now floored at the declared lane so the last column, its resize
  line and the aggregation footer stay out from under the thumb. The horizontal
  one is put back **in flow** below the viewport, where the platform's used to
  be — one override instead of a bottom clearance, a footer offset and a
  pagination adjustment.

  **`--grid-scrollbar-thickness`** carries the declared lane, with
  `GRID_SCROLLBAR_LANE` (`css-var-utils.ts`) as its one source; the `:root`
  value only covers the frame before the theme applies.
  `--grid-scrollbar-width` keeps its TD-20 meaning — the gutter the platform
  reserved — and now measures 0, which is why the header needs no padding to
  stay aligned. `trailingClearanceFor` takes the lane as a second term.

  **`theme.scrollbarWidth` keeps both values and gains a meaning it can hold.**
  `'thin'` is the compact lane (10px, matching the application's own
  ScrollArea), `'auto'` the roomier one (14px). `'auto'` asks for a roomier
  lane, not for the platform's appearance: at either setting the track, the
  thumb and the absence of arrow buttons are identical in all three engines. It
  is no longer written as the real inline `scrollbar-width`, which would change
  what a container reserves while saying nothing about the scrollbar a reader
  sees.

  **Removed:** the `@supports` engine gate, every `::-webkit-scrollbar*` rule
  (including the `-button` suppression, which is moot once nothing paints a
  platform scrollbar), the standardized-property fallback, and the
  `grid-menu-scrollbar` hook by which the grid restyled active-ui's shared popup
  scrollbars. Those popups belong to the application shell and now look like
  every other dropdown in the product; the grid's own lists go through the
  adapter instead. `.grid-header-scroll` keeps no scrollbar declarations: it is
  `overflow: hidden` and follows the body by `scrollLeft`.

  **Dependency:** `@base-ui/react` is now declared by `active-grid` because the
  adapter imports it directly. It was already in the package's runtime graph
  through active-ui (context menu, dropdown, dialog, tooltip, checkbox all use
  it), so nothing new reaches the bundle — only the manifest now says so, which
  a standalone package would have to.

  Scroll performance on the stress grid (1200 rows in the DOM, 3865px of travel,
  6s of continuous scrolling, Chromium): median frame 16.7ms before and after,
  0 frames over 32ms in both. No material regression.

### Fixed

- **`column.getAutoSortFn()` and `column.getAutoSortDir()` were declared but
  never attached** (2026-08-26). `assignPrototypeAPIs` derives the prototype
  name with `getFunctionNameInfo`, which splits on an underscore. Two entries in
  `rowSortingFeature.ts` used a dot (`'column.getAutoSortFn'`), so `fnKey`
  resolved to `undefined` and both landed on `prototype['undefined']` instead of
  on the column. `Column_RowSorting` declares both, so a call compiled and then
  threw `is not a function` at runtime; the package never noticed because it
  calls the underlying utilities directly. Fixed by using the same
  `parentName_fnKey` form as the ten sibling registrations in that block — no
  change to `assignPrototypeAPIs`, no new registration mechanism, no type
  change. These were the only dotted keys in `src/engine`.
- **`NaN` is absence on every layer, not only on screen** (REVIEW-AG-001,
  2026-08-26). TD-28 named four absent values (`null`, `undefined`, `''`,
  `NaN`) and gave them one owner, but three layers still read `NaN` as a
  number: `isEmpty` reported it present, the PRESENTATION export wrote the
  literal text `NaN`, and `sum`/`mean`/`min`/`max`/`median` let a single
  calculation error wipe an otherwise valid group aggregate. All three now
  treat `NaN` exactly as they already treated `null` — the aggregates skip it
  (`median` returns nothing, which is what it already did for `null`), the
  filter calls it empty, and the presentation export writes `''`. `0`, `-0`,
  `false` and every finite number stay data; a lifecycle matrix asserts both
  halves in one run. DATA export and the cell were already correct and are
  unchanged.
- **Automatic sort inference sampled the wrong rows and discarded its own
  result** (REVIEW-AG-002, 2026-08-26). `column_getAutoSortFn` — the default
  for every column that does not declare a `sortFn` — sampled
  `flatRows.slice(10)`, which *skips* the first ten rows instead of taking
  them, so a grid of ten rows or fewer did no detection at all and always fell
  back to `basic`. And a detected `alphanumeric` comparator was overwritten
  unconditionally by `text` further down, so `item10` sorted before `item2`.
  Detection now returns on the first match, over a bounded *leading* sample of
  ten *present* values (absent values carry no type evidence and do not consume
  a sample slot). Same four comparators, same recognition order.
- **A semantic numeric column exported `Infinity` as raw JavaScript text**
  (REVIEW-AG-004, 2026-08-26). The screen hands a `semanticType` number
  straight to `formatNumber`, which renders the infinity sign; the presentation
  export fell through to `String(rawValue)`. Both now take the same path. DATA
  export still exports non-finite numbers as empty — that is a declared machine
  contract, not a parity gap. Infinity is deliberately **not** absence.

### Changed

- **The last three accessors that formatted their own value are repaired**
  (TD-33, 2026-08-26). **No package code changed** — three consumer column
  declarations, plus one detection name added to
  `lint-accessor-presentation.mjs`. The insurance coverage period now carries an
  ISO 8601 interval, the mdm source summary carries bare numbers, and the
  pension parameter pivot carries the number, the ISO calendar day or `null`;
  all three compose their readable form in a `valueFormatter`, so the screen is
  unchanged while DATA export, filtering and sorting became
  viewer-independent. The three `// accessor-presentation-skip:` markers are
  gone, and the gate now judges every accessor with zero skips. That move
  exposed a hole in the gate itself: `formatNumber` was missing from its
  formatter list — exactly the function the pivot used — and has been added.

### Documentation

- `api/columns.md` described `emptyValueKey` as an i18n key "for null/undefined
  values" while the orchestration knows four; it now states the four and adds a
  per-layer absence table plus the note that `±Infinity` is not absence.
  `guides/export.md` separates absence (four values) from non-finite (data
  without a stable machine form) in DATA mode, and its presentation-parity
  paragraph names the two values that used to diverge.

### Changed

- **A numeric `valueFormatter` decides on presence, never on truthiness**
  (TD-32, 2026-08-25). **No package code changed** — a consumer fix, a generic
  regression suite and a narrow gate.

  Eight column declarations in two apps wrote
  `valueFormatter: (value) => (value ? format(value) : '-')`. That tests
  truthiness where presence is meant, so a measured `0` rendered as a
  placeholder. Seven of them show money on a pension surface
  (`provisionOP/PP/WZP/Total`, `entitlementOP/PP/WZP`) where zero is a computed
  outcome — `contractOP.get(c.id) ?? 0` — so a participant with only an
  old-age pension read as "partner pension not calculated". The eighth,
  `costBps` in mdm, was latent rather than measured: its formatter was annotated
  `(value: number)` over a value that is a `string` at runtime, and `'0'` is
  truthy.

  The screen was not the only surface: `resolvePresentationCell` and
  `resolvePresentationAggregate` read the same formatter, so a presentation
  export and a group total carried the same dash. The DATA export was already
  correct — by contract it consults no formatter (WS-04).

  The seven money columns dropped their formatter entirely: it duplicated
  `formatSemanticCurrency` line for line, and its absence branch has been dead
  since TD-28. `costBps` keeps a formatter, because `bps` is product vocabulary
  a declaration cannot supply; only the truthiness test is gone. Deliberate
  blankness stays available but has to say so — `value === 0 ? '' : …` passes,
  `value ? … : ''` does not.

  Guarded by `numeric-zero-value.test.tsx` (13 tests: screen, formatter
  boundary, and parity with DATA export, presentation export, `isEmpty` and
  numeric sort) and by `lint-numeric-zero-presence.mjs` in repo-tools, which
  aborts with exit 2 if `isAbsentCellValue` disappears or stops running before
  the formatter branch. No skip marker.
- **An accessor delivers a value, not a rendering** (TD-30, 2026-08-25).
  **No package code changed** — a consumer cleanup plus a documented rule and a
  narrow gate.

  28 grid accessors wrote the *presentation* of absence into the raw cell value:
  a hard-coded `'-'`, or a translated word. Nothing showed on screen — which is
  why the family could grow through four idioms — but the machine side broke.
  Measured with the real export and filter functions: with `'Niet beschikbaar'`
  in the raw value the DATA export carries that text (and `Not available` under
  an English session, so the same table exports different bytes per language),
  `isEmpty` matches nothing, and sorting places absent rows among the real values
  by their translation.

  The rule: an accessor may derive, compose and choose between fields; it may not
  inject the presentation of absence, and may not apply formatting that depends
  on the viewer's language. Absence is nullish; the column picks the word with
  `meta.emptyValueKey`. A *derived* value should be a stable key
  (`'ACTIVE'`/`'EXPIRED'`) rather than translated text, so `filterOptions` can
  supply the labels and the export stays language-independent.

  24 of the 28 columns look identical afterwards; four session-status columns
  gain a tone and lose their custom renderer. See
  `api/columns.md` § What an accessor may return, guarded by
  `lint-accessor-presentation.mjs` in `repo-tools` — literal forms only, with
  `// accessor-presentation-skip: <reason>` for cases whose repair needs its own
  design decision.

  Deliberately untouched: the 38 `|| ''` accessors (measured: no live defect),
  and the AuditLog principal column, which needs `principalType` rather than a
  placeholder and belongs to the logging workstream.
- **Boolean columns are a state or a property, and the declaration says which**
  (TD-29, 2026-08-25).
  **No package code changed** — this is a consumer convergence plus a
  documented rule, and the fact that active-grid needed no new API is the
  evidence that the model fits the existing one.

  While `BadgeCell` discarded `false`, every consumer with a boolean column had
  to work around it, and that happened six different ways across 34 columns in
  seven apps. The rule is now one question per column:

  | The column is | Declare |
  | --- | --- |
  | a **state** carrying a judgement | `type: 'badge'` + `filterOptions` + `statusTones` |
  | a **property** whose values are peers | `type: 'badge'` + `filterOptions` |
  | a binary answer with no domain vocabulary | `type: 'boolean'` |

  Three rules come with it: the key space of a boolean column is the **string**
  `'true'`/`'false'` (raw booleans in `filterOptions` still resolve the cell
  label through key coercion, but the select filter then matches nothing);
  every state carries a **word**, with colour and icons allowed only as
  reinforcement; and a boolean badge column carries **no `valueFormatter`**,
  because the formatter branch runs first and turns `type: 'badge'` into a dead
  word.

  See `api/columns.md` § Boolean columns. The families this
  repository aligned are guarded by `lint-boolean-column-convergence.mjs` in
  `repo-tools`; whether a given field is a state or a property remains a
  consumer decision.
- **An empty string is absence, on every orchestration path**
  (TD-28 phase 2, 2026-08-25;
  ADR-024).
  **This is a deliberate, visible product change.** `GridBodyCell` tested
  emptiness with `value === null || value === undefined` while the primitives
  tested with `isAbsentCellValue` — two answers to one question, differing
  exactly on `''`. The **accessor** therefore decided presentation:
  `(row) => row.field` rendered `Not available` and `(row) => row.field || ''`
  rendered nothing. That difference is one token wide, lives in a file the
  reader never sees, and 35 columns across six apps wrote the second form. Two
  grids in the same app showed the same missing IP address differently.

  The orchestration now uses the same predicate: `null`, `undefined`, `''` and
  `NaN` are absent; `false`, `true`, `0`, `-0` and every other finite number are
  data. **33 columns visibly change from a blank cell to a placeholder** (35
  affected, two of them on a non-nullable source).

  Four silent defects disappear with 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, while `toFiniteNumber` in the DATA export explicitly calls
    it empty (CORR-08);
  - `formatDateOnly('')` returns a translation **key**, and it leaked verbatim
    into the cell *and* into the presentation export;
  - `'' ? yes : no` produced a **fabricated `No`** on a boolean column;
  - `formatNumber(NaN)` returns `''`, so a calculation error looked like an
    empty cell instead of a missing value.

  **Nothing about the data changes.** Filtering, sorting, grouping, aggregation
  and both export modes read the raw value. `isEmpty` already counted `''` as
  empty, the select filter already keyed `null` and `''` alike, and the DATA
  export already resolved `''` to an empty cell. A machine value still never
  carries a translated placeholder (ADR-022). The absence branch also keeps its
  position: after grouping, placeholder and aggregate handling, before every
  formatting path.

  **`TextCell` is unchanged**: as a primitive it still renders `''` as a valid
  empty string. Deliberate blankness keeps two explicit carriers — a
  `valueFormatter` returning `''` (it runs *after* the absence branch and its
  result is never re-tested) and
  `emptyValueKey: 'common.placeholder.empty'`. `emptyValueKey` selects the
  label, never the emptiness, and no new metadata was introduced.

  Consumers were deliberately left alone: `row.x || ''` is now redundant rather
  than wrong, so the verification round could prove the fix sat in the right
  layer. That cleanup — together with the sharper sibling family of 14 accessors
  that write a *presentation* value (`'-'` or a translated word) into the row
  data — is recorded as `ROADMAP.md` TD-30.

### Fixed

- **`false` and `0` no longer disappear from categorical cells**
  (TD-27 phase 1, 2026-08-25).
  `BadgeCell`, `StatusCell`, `DateCell` and `LinkCell` all opened with
  `if (!value) return null`. That guard is meant for an empty cell, but it
  rejects every falsy value — including `false` and `0`, which are data. Since
  `body-cell.tsx` already catches `null`/`undefined` first, the guard only ever
  saw the values it handled wrongly.

  Measured impact: six live columns across three apps rendered an empty cell
  for `false`, including a fiscal-audit result column where `Gefaald` was
  invisible and an entitlement column headed `Status` whose ordinary value
  (`Opgebouwd`) never showed. All six became correct without a single line of
  consumer code.

  Narrowing the guard alone is **not** the fix. `displayValue` fell back to the
  raw value, and React ignores a boolean child — so a `false` without a
  `labelMap` hit would have rendered an empty badge outline instead of nothing:
  the same silent defect in another shape. `BadgeCell` therefore derives a
  string key (`String(value)`) and looks the label up on that key, the same
  derivation the select filter already used (`String(cellValue ?? '')`). Filter
  and cell can no longer disagree about the same column.

  The empty set is now stated once, in the internal `utils/cell-value.ts`:
  `null`, `undefined`, `''` and `NaN` are absent; `false`, `0`, `-0` and every
  other finite number are values. `DateCell` accepts epoch `0` as
  1 January 1970 — its declared type always allowed a numeric epoch, and the
  active-i18n boundary it delegates to already accepted it.

  `GridBodyCell` keeps sole ownership of the placeholder: primitives return
  nothing for an absent value, and `meta.emptyValueKey` is unchanged. A caller
  who uses a primitive directly in a custom `cell` renderer has bypassed the
  orchestration and therefore gets no placeholder — see
  `api/columns.md` § Empty and falsy cell values.

  `BadgeCell` accepts `string | number | boolean | null | undefined` (additive;
  it replaces an `as string` cast in `body-cell.tsx` that was never true).
  `StatusCell` stays deliberately string-typed: the status key belongs to
  `meta.statusTones` and is resolved by the orchestration, and widening the
  prop would put a second key derivation next to the first.

  Two follow-ups were recorded and are **not** part of this change: TD-28 (`''`
  bypasses the `GridBodyCell` placeholder), shipped separately under **Changed**
  above, and `ROADMAP.md` TD-29 (four idioms for one boolean
  badge column across consumers).
- **`enableUrlSync` no longer deletes the page's own query parameters**
  (ADR-023, 2026-08-23).
  The write-back built `pathname + serialize(...)` and replaced the entire
  search string, so every parameter the grid does not own — a tab id under
  `?section=`, an app-owned view under `?view=` — disappeared on the first sort,
  page, filter or search change. A page-owned deep link therefore worked exactly
  once and then silently stopped working, and the failure was invisible because
  the grid itself kept behaving correctly and only the application state
  vanished. `enableUrlSync` and app-owned URL state were mutually exclusive,
  which is a composition defect and not a design decision.

  The driver now declares the five keys it owns (`GRID_URL_PARAM_KEYS`:
  `page`, `pageSize`, `sort`, `filters`, `search`) and `mergeGridQuery` merges
  its serialized state into the existing search string. Foreign parameters keep
  their value, their multiplicity and their relative order and are never
  re-encoded; grid-owned keys are still added, replaced and removed exactly as
  before. `replaceState` is unchanged, so back/forward semantics are unchanged.

  **Backward compatible.** On a route that carries no parameters besides grid
  state — which is every consumer in this repository today — the merge produces
  the same string the replacement did. No public API change: `url-driver` is
  internal and is not exported from `src/index.ts`. New documentation:
  URL state, including the pre-existing
  limitation that the five keys are route-global and not namespaced per
  `gridId`, so at most one grid per route should enable URL sync.
- **`docs/guides/server-grouping.md` claimed expanded-group state syncs to the
  URL** (2026-08-23). It never did: there is no URL key for it and `expanded`
  is a localStorage-only slice. Corrected, and the owned-key list is now
  documented in one place.
- **One date shape, not three** (WS-13, PP-038, 2026-08-17). The package
  carried three hidden and mutually contradictory date defaults, and which one
  a cell got depended on the rendering path: the `date` column-type preset
  formatted `short`, `DateCell` defaulted to `numeric`, and the presentation
  export fell back to `numeric` as well. A column that declared only
  `filterType: 'date'` therefore rendered differently from one that declared
  its semantics. All three now resolve through `formatSemanticDate`, and the
  default is the one PP-038 names for a dense tabular list: short textual for
  a calendar day (`9 mrt. 2026`), that same short date plus the time for an
  instant. DATA export is untouched — it never read a formatter.
- **A blank semantic value no longer becomes a real zero in DATA export**
  (CORR-08, 2026-08-15). `resolveDataCell` converted with a bare `Number()`,
  and JavaScript turns `''`, whitespace, `null` and `[]` into a perfectly
  finite `0`. A missing amount therefore left the machine export as a
  measured zero — for a currency column even with an identity companion
  column beside it, so as a real amount of 0 in a concrete currency. Missing
  is now missing (`semantic: 'empty'`, no currency); a real zero (`0`, `'0'`,
  `'0.00'`) is unchanged, and a boolean is no longer coerced into an amount.

### Changed

- **The delimited-escaping boundary moved to
  `@workspace/active-utils/helpers`** (CORR-10, 2026-08-15) and is re-exported
  here unchanged. Nothing about the quoting, the RFC 4180 handling or the
  formula neutralisation changed, and the behaviour tests stay in this
  package. The move exists so a second consumer — the message export in
  `active-actions` — can use the same boundary instead of hand-rolling a
  thirteenth variant.

### Changed (breaking for direct callers)

- **`getDefaultColumnTypes()` no longer takes a presentation** (WS-13,
  2026-08-17). It bound a locale into a `valueFormatter` closure on the two
  date types; those closures are gone, so all five value semantics are now
  declarations and the presentation enters the grid exactly once — through the
  `locale` prop, which the cell orchestration, the footer, the group
  aggregates and the presentation export all read. The second presentation
  feed that callers had to keep in sync by hand no longer exists to keep in
  sync. Migration: drop the argument.

### Added

- **`meta.datePreset`**: the declarative semantic date shape of a
  `semanticType: 'date' | 'dateTime'` column (WS-13, PP-038), alongside
  `meta.numberFormat` and `meta.percentPreset`. Closes the known asymmetry
  registered as OP-39; its first concrete consumer is a calculation result
  column carrying its own `formatOptions.dateFormat`. A calendar-day column
  accepts only day-shaped presets — a time-bearing preset yields a dev warning
  and the default, because a calendar day has no time to show.
- **`meta.percentPreset` accepts `{decimals: n}`** besides a preset name, so a
  runtime-supplied domain precision (an actuary-configured output column at
  three or six decimals) can reach the cell. Same shape as
  `meta.numberFormat`; notation still comes from the presentation context.
- **`locale` accepts a presentation-context object besides a locale code**
  (WS-07). The new public type `GridPresentation`
  (`string | (DatePresentationContext & NumberPresentationContext)`)
  applies to the `locale` prop of `ActiveGrid`/`LookupGrid`,
  `getDefaultColumnTypes`, the presentation-export options
  (`GridExportRunOptions`, `GridPdfExportOptions`, `GridClipboardOptions`)
  and the date-filter helpers. The value is forwarded untouched to the
  central `@workspace/active-i18n` formatters, so a consumer can carry
  language and notation preferences independently; a bare locale code
  remains fully valid ("notation follows the language") and existing
  consumers are unchanged. The package stays tenant-blind: no
  administration, profile or settings knowledge — the caller composes the
  context. DATA export is untouched by construction (its resolvers take no
  presentation input, PP-013).
- **`meta.currencyDecimals`**: deliberate per-column decimals override for
  `semanticType: 'currency'` columns (PP-007's documented exception, e.g.
  whole amounts on a dashboard). Applied by body cell, footer, group
  aggregates and presentation export alike via the new shared
  `formatSemanticCurrency` helper, so one column can no longer show a
  0-decimal body under a 2-decimal footer. Omitted: the currency's natural
  minor units decide. DATA export ignores it.

### Changed

- **Grid data export defaults to machine/raw semantics; presentation export
  is an explicit mode (WS-04, PP-013/PP-014 — BREAKING for every export
  consumer).** CSV/XLSX/JSON export used to serialize the `valueFormatter`
  string literally (`"€ 1.234,56"` as a text cell, locale-dependent dates,
  translated column headers as JSON keys), while `docs/guides/export.md`
  claimed the opposite — the export bytes differed per viewer language. The
  export functions now take a `GridExportRunOptions` argument: the default
  `{mode: 'data'}` serializes column ids as headers/keys, raw semantic
  values, machine dates (`yyyy-MM-dd` for declared calendar days, ISO 8601
  UTC for instants), native numbers/booleans, percent values in their
  declared stored unit (no factor-100 normalization), and currency amounts
  as bare numbers with the ISO 4217 identity in a `<id>_currency` companion
  column (skipped when the `currencyKey` field is itself a visible column;
  suppressed on aggregates without a provably shared currency, PP-010).
  `{mode: 'presentation', locale}` is the explicit human-readable form and
  mirrors the cell orchestration (badge labels, `valueFormatter`, semantic
  currency/number/percent formatting). XLSX data mode writes **typed
  cells**: numeric/boolean cells, calendar days as timezone-free date
  serials with a `yyyy-mm-dd` format, instants as stable ISO strings
  (Excel serials carry no timezone), and fraction percents with a native
  `%` display format that never alters the stored value. New generic column
  meta: `exportValue` (explicit machine value for DATA export) and
  `semanticType: 'date' | 'dateTime'` (declared by the `date`/`dateTime`
  presets). `exportToPDF*` now requires `{locale}` — PDF is presentation
  output by definition. `copyToClipboard(rows, grid, {locale,
  includeHeaders?})` replaces the boolean third parameter: the clipboard is
  deliberately classified as a **presentation copy** (you copy what you
  see) and is not the machine-export contract. Grouped exports emit raw
  aggregates in data mode and no longer indent data cells there (an
  indented number is no longer a number). Rationale: `docs/DECISIONS.md`
  ADR-022.
- **The `number`, `threshold`, `count` and `percent` column types carry
  declarative semantics; body and footer format from one source (WS-02,
  BREAKING for percent columns without their own `valueFormatter`).** The
  bare `number` column body used to render `String(value)` — no grouping, no
  locale — while footer aggregations of the same column were locale-formatted
  (presentation-audit §5.5). The presets now declare
  `meta.semanticType: 'number'` (with optional `meta.numberFormat`, default
  `'decimal'`; `count` declares `'integer'`) and the cell orchestration,
  footer, group-footer and server-group aggregation paths all format through
  the same `formatSemanticNumber`. The `percent` preset no longer sets a
  fraction-assuming `valueFormatter`; it declares `semanticType: 'percent'`
  with an **explicit stored unit** — `percentSource: 'fraction'` (0-1 data,
  the preset default and the historical contract) or
  `'percentage-points'` (0-100 data) — plus optional `percentPreset`
  precision. A percent column without a declared source renders a plain
  number without a percent sign and logs a dev warning, mirroring the WS-01
  currency no-identity-no-symbol rule. A column-level `valueFormatter` still
  outranks all of this. Aggregation fallbacks that previously skipped
  formatting for `filterType: 'number'` columns now format numbers through
  the central `'decimal'` preset.
- **Group-row aggregated cells honor the column's formatter and semantics
  (WS-02).** The engine's `getDefaultColumnDef` no longer injects a
  `getValue()?.toString?.()` `aggregatedCell` template into every column.
  That default short-circuited the React layer's aggregated-cell rendering,
  so a grouped row showed the raw float (`26907.12000000001`) next to body
  cells that showed `€ 26.907,12`. A consumer-supplied `aggregatedCell`
  still wins; without one, aggregates now render through the column's
  `valueFormatter` or declared currency/number/percent semantics, with
  `String(value)` as the unchanged final fallback.
- **The `currency` column type carries currency identity as data instead of
  deriving it from the viewer's language (WS-01, BREAKING for currency
  columns).** The preset no longer sets a `valueFormatter` that called
  `formatCurrency(value, locale)` — a path that could only ever render the
  language-derived currency (EUR) regardless of what the amount was. It now
  declares `meta.semanticType: 'currency'`; the cell orchestrator resolves the
  identity from `meta.currencyKey` (a row field carrying the ISO 4217 code) or
  `meta.currencyCode` (a fixed domain contract supplied by the app layer). A
  currency column without either source renders a plain locale-formatted
  number **without** a symbol, plus a dev-warning: no identity means no
  symbol. Footer and group aggregations of a `currencyKey` column only carry a
  symbol when every aggregated row shares one currency; mixed-currency sums
  render as a bare number (PP-010) — and server aggregations only format with
  a fixed `currencyCode` contract. Exports now emit the raw value for these
  columns instead of a presentation string. The grid stays tenant-blind: codes
  come from rows or props, never from settings. Guarded by
  `src/config/__tests__/column-type-presets-currency.test.ts`.
- **`DateCell` renders a calendar day in the reader's language instead of a
  fixed notation.** Its `formatString` default moves from `'dd-MM-yyyy'` to
  date-fns' `P`, and when no `locale` object is passed the cell now resolves one
  itself — the same chain the date filter in the column header already used:
  an explicit prop wins, then the consumer's `calendarLocale` on `<ActiveGrid>`,
  then the mapping of the grid's `locale` string, and `enGB` without a provider.

  **This is a visible change for four of the five languages.** `'dd-MM-yyyy'` is
  the Dutch notation under a language-neutral name: a German user read
  `09-03-2026` in the column while the filter in that same column header and the
  date field in the form beside it both said `09.03.2026`. In Dutch nothing
  changes — `P` renders `09-03-2026` there.

  A column that passes its own `formatString` or `locale` keeps it; only the
  default moved. The fallback without a provider is `enGB` and deliberately not
  `en-US`, which would swap day and month.

  Guarded by `src/components/cells/date-cell.test.tsx`, which pins the notation
  per language, the provider-less fallback, and the precedence of an explicit
  prop. Companion change: the `numeric` preset in `@workspace/active-i18n` moved
  the same way, and the two are tested against the write side there.
- **The pagination row counter says nothing when there are no rows.** It read
  "No results" while the data area directly above already showed "No results
  found" — the same message twice inside one frame, and on a narrow screen that
  repetition cost the space "next page" needed. A populated counter
  ("1-10 of 100") still shows at every width, because it states something that
  appears nowhere else.

  The duplication is measured, not assumed: `sections/body.tsx` renders its
  empty state under exactly the condition the footer used to speak under (zero
  rows, no fetch in flight), so wherever the counter fell silent the area above
  always speaks. `grid.pagination.noResults` is removed from all five locales;
  `grid.empty.noResults` is unaffected.

  **Two other shapes were tried and dropped.** `flex-wrap` on the bar broke it
  into three separate lines (counter, page size, button row), which reads as a
  list rather than as one control bar; hiding the counter below `sm` regardless
  of content threw away a useful count on mobile. The bar stays one horizontally
  scrollable line.

### Fixed

- **`autoSizeOnMount` no longer skips an empty client-mode grid.** The effect
  required `hasData` (`rows > 0 || server mode`), so a client grid with zero
  rows kept its default column widths and truncated its own headers. Visible on
  every empty list; the passkey overview of a user without passkeys showed
  "Toegevoeg…" and "Laatst gebr…" beside a screen full of empty space.

  The condition had a reason — the mount latch fires once, so measuring an empty
  grid and latching would freeze a grid whose rows arrive later at header width.
  There are now two latches instead of one condition: one empty measurement is
  allowed (the header is the best thing there is to measure), and the first
  render with real rows measures once more and latches for good. The second
  latch also prevents a render loop: `autoSizeOnMount` reaches most consumers as
  an object literal, so the effect's dependencies change every render.

  **Not covered by a test.** jsdom performs no layout, so `scrollWidth` is 0 and
  the measurement this fix restores cannot be asserted there. The change is
  verified by reading and in the browser, not by a guard.
- **The pagination bar's scrollbar is styled like every other one.** It was the
  grid's only scroll container without scrollbar rules, so it fell back to the
  operating system's: thicker, with arrow buttons. A narrow grid showed two
  different horizontal scrollbars stacked — the body's thin one and the footer's
  native one.

---

## \[9.0.0\] - 2026-08-10 - The grid stops guessing what a value means

**Breaking: `BadgeCell` loses its `variant` prop.** UI-EXP-013, the workstream
that follows directly on `active-ui`'s UI-EXP-001. Status presentation in the
grid now runs through the central `StatusBadge`, and the grid no longer decides
by itself which values deserve which colour.

**What was wrong.** The grid picked its badge variant hardcoded per filter type
in `sections/body-cell.tsx`: `filterType: 'select'` always got `outline`,
`filterType: 'boolean'` got `default` for `true` and `secondary` for `false`.
That last rule is the sharpest form of the problem — a presentation layer with
no domain knowledge deciding that `true` earns the accent colour. And there was
no column contract along which a meaning could travel at all: `variant` was
reachable only by importing `BadgeCell` directly, which happened in six files.

**Restoring the old behaviour is not offered, on purpose.** The `variant` axis
carried no semantics to restore: `default` is the theme's accent colour, not
"good". All ten call-sites are in this repo and migrated in the same change
(Critical Rule 12 — no dual API as steady state).

### Added

- **`meta.statusTones`** — `Record<string, StatusTone>` on a column, mapping
  each raw cell value to one of the five generic tones of `@workspace/active-ui`
  (`neutral`, `info`, `success`, `warning`, `danger`). Presence of this map is
  the **only** signal that turns a cell into a status badge; no filter type,
  enum name, colour or label does. A value that is not in the map falls back to
  `neutral`. It sits next to `filterOptions` and mirrors it: that one maps value
  to label, this one maps value to tone, and each has exactly one home. See
  `api/columns.md` § Status columns.
- **`StatusCell`** — the imperative counterpart, for a custom `cell` renderer
  whose value is not a stable key (a derived or already-translated value, where
  a value→tone map would break per locale). Wraps `StatusBadge` and adds the
  grid's label resolution and badge typography.

### Changed

- **`BadgeCell` is the categorical badge and has no colour axis.** Type,
  category, department, source: one fixed presentation. A custom `cell` that
  genuinely needs a coloured categorical badge renders `<Badge>` from
  `@workspace/active-ui` directly, so the choice is visibly outside the grid's
  badge contract.
- **Booleans no longer carry implicit status** (visible change, five columns
  repo-wide). `true` and `false` render the same categorical badge; the yes/no
  text is the information. A consumer who wants `true` to read as success says
  so: `statusTones: {true: 'success', false: 'neutral'}`.
- **`select` columns are unchanged** unless they declare `statusTones`. The
  roughly 150 `type: 'badge'` columns in the repo keep the presentation they
  had; adopting tones there is per-column domain work in each app, not part of
  this contract change.

### Removed

- **`BadgeCellProps.variant`.** Migrated in the same change: six files in
  `apps/admin` and seven local `status → variant` helpers. Five status columns
  and two priority scales moved to `statusTones` or `StatusCell`; four
  categorical columns (category ×2, department, form name) lost their decorative
  colour coding and are now the uniform categorical badge.

### Testing

- **The status contract has its own suite** (`src/components/__tests__/status-tones.test.tsx`,
  11 tests). It asserts on `data-tone` / `data-appearance` — the attributes
  `StatusBadge` sets — so it measures the contract ("the tone from the column
  configuration reaches the central capability") instead of another package's
  classes. The presentation itself, including contrast, stays tested in
  `active-ui` and is not duplicated here.
- **Unmount/remount cleanup is now measured** (`src/components/__tests__/unmount-cleanup.test.tsx`,
  5 tests — roadmap R-30). No product change: the grid already released its
  listeners and disconnected its `ResizeObserver`s, and this suite pins that
  instead of assuming it. The tracker matches on listener *identity*, so a cleanup
  that hands `removeEventListener` a fresh closure would be caught. Proven by
  mutation: reverting the `visibilitychange` cleanup in `use-grid-state` or an
  `observer.disconnect()` turns the suite red. Two paths are deliberately or
  unavoidably out of reach and named in the file header — listeners on the grid's
  own (detached) subtree, and `use-flex-layout`'s window `resize` listener, which
  jsdom never registers because `measureElement` stays `null`.
- **Byproduct, not fixed here**: a paginated grid leaves nine `window` listeners
  behind on unmount, traced to `active-ui`'s `Select` (a standalone `<Select>`
  render reproduces it without any grid). Tracked as LBL-005 on the active-ui
  roadmap; every variant in this suite therefore runs with pagination disabled.

  > **Correction, 2026-08-11 (LBL-005).** The trace was wrong and so was the
  > conclusion drawn from it. Those nine listeners belong to **jsdom itself** —
  > `@asamuzakjp/dom-selector`, the engine behind `Element.matches()`, registers
  > them on `window` to track `:focus-visible`/`:hover`/`:active`, lazily on the
  > first `matches()` call in a document and cached from then on. `Select` was
  > merely the first caller. They do not accumulate (nine after one mount/unmount,
  > nine after five) and they do not exist in a real browser, where the selector
  > engine is native: measured in Chrome across 2,927 `addEventListener` calls,
  > 40 open/close cycles and 10 mount/unmount cycles, `resize` is the only
  > `window` listener type ever registered and none of the nine appears. The
  > suite now runs paginated variants as well; see `unmount-cleanup.test.tsx`.

### Documentation

- `api/columns.md` gains a § Status columns; `api/cells-utils.md` explains the
  split between status and categorical; `guides/columns.md` no longer teaches a
  hand-rolled `bg-green-100` / `bg-red-800` status cell as its custom-renderer
  example — that snippet was the exact pattern this release removes.
- **`LoadingDisplayValue` is now named in `docs/api/grid-props.md`.** The type
  shipped as a public export in 8.1.0 but the prop documentation spelled its
  definition out inline (`LoadingIndicator | React.ReactNode`) in both places it
  appears, so a consumer reading the docs could not tell there was a name to
  import. The `### Type` block now mirrors the source and shows the import.
  No code change; the exported surface is unchanged since 8.1.0.

---

## \[8.1.1\] - 2026-08-03 - The editing-error icon styles itself from the trigger

`EditableCellWrapper` passed the error icon's styling on the render-element
(`<AlertCircle className="text-destructive h-3.5 w-3.5" />`) while the
`TooltipTrigger` above it carried `pointer-events-auto`. Base UI reads
`render.props` before it unwraps a lazy element, so that arrangement drops the
icon's className the moment the JSX crosses an RSC boundary — the package is
client-only today, so it never fired here, but the shape is the one that broke
41 call-sites across the apps. Both class sets now sit on the trigger and the
render-element is bare.

No behaviour change: `TooltipTrigger` is a pass-through, so the final element
receives the same four classes either way. Verified in the browser — identical
token set and identical computed box, colour and `pointer-events` for both
orderings. Class order inside a `class` attribute never decides the cascade;
CSS source order does.

Guarded repo-wide by `lint-render-prop-classname.mjs`; rationale in `active-ui`
`ARCHITECTURE.md` § Base
UI-composeer-richtlijn.

---

## \[8.1.0\] - 2026-08-03 - The loading display is yours; the footer tells the truth

Two changes that look like one, and are not.

**The grid no longer states a result count it does not have.** A grid has three
states — not loaded yet, loaded with nothing, loaded with rows — and counting
rows cannot tell the first two apart. The pagination footer only had the count,
so during a first fetch it printed "No results": the grid saying *"I looked and
there is nothing"* while the truth was *"I am still looking"*. That is
correctness, it is now fixed, and it is not configurable.

**How loading looks is now entirely yours, and the default is nothing.** Only
you know whether your data arrives in 50 ms or in 3 seconds. An indicator that
appears and vanishes inside 80 ms is more disturbing than an empty area, and a
library that guesses is wrong for half its consumers. So `loadingDisplay`
defaults to `{firstLoad: 'none', refetch: 'progress'}` — silent on first load,
a 2-pixel bar on a refetch because rows on screen have just gone stale and a
reader deserves to know.

Full reasoning, including the option that was tried and dropped:
ADR-021.

### Added

- **`loadingDisplay` accepts an options object.**

  ```ts
  type LoadingIndicator = 'none' | 'progress' | 'skeleton' | 'spinner' | 'text';

  interface LoadingDisplayOptions {
    firstLoad?: LoadingIndicator | React.ReactNode; // default 'none'
    refetch?: LoadingIndicator | React.ReactNode; // default 'progress'
    delayMs?: number; // default 0
  }
  ```

  First load and refetch are different situations — one has an empty grid, the
  other has rows that just went stale — so they are configured separately
  instead of through one mode that guesses.
- **`'none'` and `'text'` indicators.** `'none'` shows nothing; `'text'` shows
  one non-blocking line at the top edge ("Loading…" / "Refreshing data…"),
  which covers nothing and is therefore safe in both states.
- **A `ReactNode` may be passed instead of an indicator name**, rendered inside
  the grid box.
- **`delayMs`** holds an indicator back until the fetch has been running that
  long. A faster fetch shows nothing at all — this is what removes the flash.
  Defaults to `0`; 200-300 ms is the recommended companion to any visible
  indicator. Not defaulted to a non-zero value on purpose.
- **`prefers-reduced-motion` is respected.** The progress bar and the skeleton
  stop animating; they stay visible, they just hold still.
- **Exported types:** `LoadingIndicator`, `LoadingDisplayValue`,
  `LoadingDisplayOptions`.

### Changed

- **`loadingDisplay` defaults to `{firstLoad: 'none', refetch: 'progress'}`.**
  Was `'progress'` in both states (ADR-016). Nothing in this repo sets the prop
  (measured), so no existing opt-out changes meaning.
- **The short form is symmetric.** `loadingDisplay="skeleton"` now means
  skeleton in *both* states. It previously meant "skeleton on first load, bar on
  refetch" — an asymmetry you could only discover by reading the source. Write
  `{firstLoad: 'skeleton', refetch: 'progress'}` for the old behaviour; it reads
  the way it works.
- **`'skeleton'` without resolved column widths renders nothing**, where it used
  to fall back to the spinner. "I asked for a skeleton and got a spinner" is not
  explainable.
- **The skeleton's row count follows `pageSize`** instead of a fixed eight, so
  the table does not jump when the data lands.
- **The pagination footer suppresses the row count and the page indicator**
  while `totalCount === 0 && (isLoading || isRefetching)`. `GridPagination`
  receives `isLoading` / `isRefetching` — the same signals `body.tsx` has always
  used for its empty-state guard. During a refetch that still has rows on
  screen the previous count stays, so the footer does not flicker.
- **`skeletonColumns` was never a consumer concern.** `grid.tsx` derives it from
  the visible leaf columns and always has. The prop documentation said
  otherwise.

### Tests

- `components/__tests__/loading-states.test.tsx` — the three states, the
  defaults, the symmetric short form, the object form, a consumer node, the
  text variant, the skeleton's row count and its no-columns behaviour, and
  `delayMs` under fake timers (including: a fetch that finishes inside the
  threshold never flashes). Both guards were mutation-checked — removing the
  footer suppression fails the correctness tests, making the first-load default
  visible fails the appearance tests.

### Not changed

- `use-data-orchestrator.ts` — untouched. `isLoading` already means "first load"
  and `isRefetching` "reload"; no new state was needed and `isInitialized` stays
  private.

---

## \[8.0.0\] - 2026-07-29 - The grid role is made true

**Breaking: behaviour changes without a code change on the consumer side.**
Second session of `R-27`. `enableKeyboardNavigation` now defaults to `true`, so
every grid that never set it gains cell navigation, and two callbacks that
keyboard navigation used to suppress now fire.

**Restoring the old behaviour is one prop:** `enableKeyboardNavigation={false}`.
Measured before the change: zero consumers in this repo set the flag to `false`,
so no existing opt-out silently changes meaning.

### Changed

- **`enableKeyboardNavigation` defaults to `true`.** The grid declares
  `role="grid"`, and a grid is keyboard-navigable by definition — shipping the
  role without the navigation is an incorrect declaration, not a missing nicety
  (WCAG 2.1.1, level A). Measured before the change: of 192 files in `apps/`
  rendering `<ActiveGrid>` plus 7 elsewhere in `packages/`, four set the flag,
  and all four are admin test pages. No production screen could reach a grid
  cell by keyboard.
- **Arrow keys now move cell focus in grids that previously ignored them.**
  There is no behaviour being replaced: with the flag off the grid exposed no
  focusable element for the body at all, so there was no prior arrow-key
  behaviour to break. `Home`, `End`, `Ctrl+Home`, `Ctrl+End`, `PageUp` and
  `PageDown` become active on the same terms. See
  `guides/keyboard.md`.
- **`onRowClick`, `onRowDoubleClick` and `onCellClick` fire while keyboard
  navigation is on.** They were suppressed whenever navigation was enabled
  (`base-row.tsx` for the row callbacks, `body-cell.tsx` for the cell one),
  together with the `cursor-pointer` affordance on clickable rows. A row click
  and a cell focus are events at different levels; there was no reason for the
  exclusion beyond an early simplification. This is a change in **both**
  directions: the four grids that already set the flag gain callbacks they did
  not previously receive.
- **The grid's tab stop moved from `.grid-scroll-container` to the `role="grid"`
  element.** This supersedes the 7.8.0 decision to keep the scroll container as
  the tab stop. A focusable element with no role directly inside `role="grid"`
  is an `aria-required-children` violation, rated critical — it appeared in
  three of the five gate configurations the moment navigation was switched on.

### Added

- **`Enter` starts an edit on an editable cell.** This was the whole point of
  the session: there was previously *no* keyboard route into editing. The only
  triggers were `onClick` and `onDoubleClick` on the cell, and a
  `<div role="gridcell">` does not synthesise a click from `Enter`. Verified for
  all three editing modes — `cell`, `row` and `batch`.

  `onCellActivate` is deliberately **not** also called for editable cells: one
  keypress must not both activate and edit. Non-editable cells are unaffected.
- **The skip link is an exit instead of an entrance.** It now lands on a
  sentinel placed directly after the `role="grid"` element, so the header,
  filter row and body are skipped while pagination and the batch toolbar stay
  reachable. Measured motivation: a grid with selection and expanding renders
  two tab stops per row, so a 10-row page adds 20 and a 100-row page adds 200,
  with no way out but to tab through all of them.
- **An accessible name on the remove-grouping button**
  (`controls/grouping-toolbar.tsx`), naming the column it removes rather than
  saying "Remove grouping" once per grouped column. Third instance of the
  icon-only-named-only-by-`title` class, after the header menu trigger in 7.8.0.
- **Guard tests for accessible names axe cannot check**, and **gate coverage for
  keyboard navigation in both states**.

### Fixed

- **The skip link moves focus.** It targeted the `role="grid"` element, which
  had no `tabIndex`; a fragment link to a non-focusable element does not move
  focus. Same root cause as the item below, not a separate defect.
- **The focus indicator is visible when entering the grid.** The
  `focus-visible:ring-*` classes sat on the `role="grid"` element while the tab
  stop was the inner scroll container, so `:focus-visible` could never match and
  a keyboard user entering the grid saw nothing (WCAG 2.4.7, level AA). Moving
  the tab stop onto the element that already carried the classes fixed this and
  the skip link together — two symptoms of one cause.

### Translations

Two keys replaced and one added, in `src/i18n/defaults.ts` and all five datyx
locales (nl, en, de, fr, es):

| key | status |
| --- | --- |
| `grid.skipLink` | removed |
| `grid.skipPastRows` | added (replaces `grid.skipLink`) |
| `grid.endOfRows` | added (skip-link landing point) |
| `grid.grouping.removeGroupingFor` | added (takes a `{{column}}` value) |

### Migration

- Grids that want the pre-8.0.0 behaviour: pass `enableKeyboardNavigation={false}`.
- Consumers supplying their own `t`: add the three new keys above, or accept the
  English defaults. `grid.skipLink` is no longer read.
- Consumers that set `enableKeyboardNavigation` and relied on `onRowClick`,
  `onRowDoubleClick` or `onCellClick` *not* firing: those callbacks now fire.

---

## \[7.8.0\] - 2026-07-29 - The controls have names, and a gate keeps them

**New behaviour for existing consumers, plus a new dependency.** Minor rather
than patch: nine translation keys are added, so a consumer supplying its own `t`
must feed them or fall back to the English defaults; and the package gains an
axe-core dev-dependency for the accessibility gate.

First session of `R-27`, the accessibility audit. The finding fixed here is the
one the roadmap did not list and the only one that reached users today rather
than latently.

### Fixed

- **The pagination controls have accessible names.** All four page buttons
  (first, previous, next, last) were icon-only with no `aria-label` and no
  `title`, which axe reports as `button-name` at impact **critical**
  (WCAG 4.1.2, Name Role Value, level A). A screen-reader user could not tell
  what any of them did — and they are the only way to reach page two. The
  page-size select had the same problem.
- **The per-column filter buttons have accessible names.** All six filter
  variants (text, number, date, boolean, select, interval) render the same
  icon-only trigger. They are now named after the column they filter
  (`Filter {{column}}`), so a screen-reader user can tell the per-column
  triggers apart instead of hearing six identical unnamed buttons.
- **The system columns are no longer nameless headers.** `row-number` and
  `expander` declare `header: () => null`, producing `columnheader` elements
  with no text — announced as a blank column, and reported as
  `empty-table-header`. They now carry visually hidden text rather than only an
  `aria-label`, because an empty header is a problem for sighted keyboard users
  too, and because the rule looks for text rather than for an accessible name.

### Changed

- **The filter row exposes `gridcell`, not `columnheader`.** Measured cause: with
  `columnheader` there were two of them at every `aria-colindex` — ten for five
  columns — so a screen reader resolving a cell's column header found the real
  header and an empty filter cell at the same index. Naming those cells would
  have made it worse, not better: the column would then appear to have two
  different headers. The filter row holds controls, not column names, and the
  widget inside each cell carries its own name.

### Added

- **An axe-core accessibility gate** (`components/__tests__/a11y-axe.test.tsx`),
  over five grid configurations. It runs in vitest, and therefore in CI — the
  Playwright suite needs two apps up and stays manual verification.

  **The gate documents its own limit, in the test file.** Measured during the
  R-27 Stop 1 session: axe reported `tabindex` PASS (16 nodes) and
  `nested-interactive` PASS (15 nodes) while `primitives/resize-handle.tsx` has
  no `tabIndex`, no `role`, no `onKeyDown` and no `aria-*` — it cannot be
  operated by keyboard at all. A green axe run would have missed the entire
  keyboard axis. This gate catches regression; it is not evidence of conformance.

### Migration

Nine keys were added under `grid.pagination.*`, `grid.filter.*` and
`grid.header.*`. A consumer passing its own `t` should add them; without them the
English defaults in `src/i18n/defaults.ts` apply, so nothing breaks silently —
the controls just stay English. The datyx locales carry all nine in nl, en, de,
fr and es.

## \[7.7.0\] - 2026-07-28 - The right edge is measured, and the token says what it means

**Breaking: `theme.scrollbarWidth` changes type.** It took a CSS length
(`'12px'`); it now takes `'thin' | 'auto'`. Measured over `apps/` and
`packages/`, no consumer sets it, so nothing in this repo has to change. See the
migration note below.

This closes `TD-20` and `TD-18`. Both came down to the same mistake: two
different quantities were called 8 and had to agree by hand.

### Fixed

- **The header's right edge lines up with the body's, at every zoom level.** The
  header reserves a strip matching the body's scrollbar gutter through
  `padding-right: var(--grid-scrollbar-width)`, but that token was a fixed 8px
  while the gutter is a platform constant in DIPs divided by the page zoom.
  Measured on Windows/Chromium with `scrollbar-width: thin`: 10 CSS px at 100%
  zoom, 8 at 125%. The grid now measures `offsetWidth − clientWidth` on the body
  container and writes it back, so the two edges meet wherever the zoom sits.

  | configuration | gutter | header pad (before) | delta (before) | delta (after) |
  | --- | --- | --- | --- | --- |
  | `thin`, 100% zoom | 10 | 8 | **-2** | **0** |
  | `thin`, 125% zoom | 8 | 8 | 0 | 0 |
  | `auto` | 15 | 8 | **-7** | **0** |

  The drift was `min(0, pad − gutter)`, not a constant — it vanished at 125%
  zoom, which is why it was never reported.
- **The content clearance can no longer be moved out from under the flex fill.**
  `--grid-edge-clearance` is declared on `:root` as
  `var(--grid-scrollbar-width)`, so it resolved there. A `:root` override of the
  scrollbar token therefore moved the clearance while the JS reserve stayed at 8,
  breaking ADR-020's INV-18 and producing a phantom horizontal scrollbar in the
  fit state — measured 4px at `12px` and 8px at `16px`. The grid now writes both
  variables on its own containers, so an override cannot reach them. Measured
  after: 0 in both cases.

  Note this was never reachable through the documented prop: a theme setting
  `scrollbarWidth: '12px'` moved only the header padding, because inline styles
  on the wrapper come too late to change a `:root` substitution. The breaking
  route was the `:root` snippet in `docs/guides/styling.md`.
- **Trailing clearance survives platforms that reserve nothing.** Deriving the
  clearance from the measurement alone collapsed it to 0 where scrollbars overlay
  the content, satisfying INV-18 while breaking INV-3 — the last column's
  boundary, and the resize line on it, landed on the card edge. The clearance now
  carries a floor (`MIN_TRAILING_CLEARANCE`, 8px) while the header padding keeps
  tracking the raw gutter. Measured with `scrollbar-width: none`, fit state:
  clearance `0px` before, `8px` after, with `delta` and phantom scroll 0 in both.

### Changed

- **`theme.scrollbarWidth: string` → `'thin' | 'auto'`.** A length could never
  work: `scrollbar-width` overrides `::-webkit-scrollbar { width }` in Chromium,
  so the value never reached the scrollbar — measured, the token moved 8→24 with
  no change to the reserved gutter. What it did instead was silently shift the
  header padding and, via `:root`, the clearance. The two-valued preference is
  the only lever the engine honours, and the geometry now follows from measuring
  its effect.

  `'none'` is not offered: it removes mouse-scrolling on most platforms. It stays
  available to a consumer through plain CSS, documented with that caveat.
- `scrollbarWidth` is no longer in `THEME_TO_CSS_VAR`. `scrollbar-width` is a
  real CSS property and is not inherited (measured: set on an ancestor, the
  container's computed value stays `auto`), so it cannot travel in the wrapper's
  style object. `use-flex-layout.ts` applies it to the scroll containers.
- `EDGE_RESERVE` is gone. The flex fill and the CSS clearance both come from
  `trailingClearanceFor()`, so INV-18 holds by construction rather than by two
  constants agreeing. The one remaining constant is `MIN_TRAILING_CLEARANCE`, and
  it is documented as a design choice rather than a guess at the gutter. It also
  serves as the pre-measurement reserve, which needs no separate constant: "at
  least the design minimum" is the honest answer before the gutter is knowable.
- `docs/guides/styling.md`: the `:root` override snippet is replaced with an
  explicit warning; the WebKit/Firefox labels are corrected (the rule marked
  "Firefox" is the one that governs Chromium); the token table row now names the
  theme preference instead of presenting the variable as the scrollbar width; and
  a "Right-edge layout" section sets out the two quantities and why they differ.
- `e2e/last-column-clearance.spec.ts` gained a token matrix — `:root` overrides
  at 12px and 16px, both preference values, and the zero-reservation case — all
  asserting the same four invariants. Playwright is not part of CI, so this is
  manual verification.

### Migration

`theme.scrollbarWidth` no longer takes a length.

```tsx
// Before — a length that never reached the scrollbar
<ActiveGrid theme={{scrollbarWidth: '12px'}} />

// After — a preference the engine honours
<ActiveGrid theme={{scrollbarWidth: 'auto'}} />   // thicker, native width
<ActiveGrid theme={{scrollbarWidth: 'thin'}} />   // the default; omit it entirely
```

The type change is caught at compile time; there is no silent behaviour change to
look for. If you were setting it to make the grid's right-hand strip wider or
narrower, that is now automatic: the layout measures whatever the preference
costs and adjusts the header padding and trailing clearance to match.

If you override `--grid-scrollbar-width` in your own CSS, remove that override.
The grid writes the variable at runtime, so yours is ignored for layout, and on
earlier versions it caused a phantom horizontal scrollbar.

## \[7.6.0\] - 2026-07-28 - The filter axis stops accepting work it discards

**Behaviour change for existing consumers.** Minor rather than patch: a
server-mode grid with `enableFilterBuilder` used to render a toolbar button and
a filter panel, and now renders neither. Measured over `apps/` and `packages/`,
the only consumer is a test harness
(`apps/admin/app/(main)/test-server-grouping/grid/grid.tsx:130`) and
`externalFilter` has no consumer at all, so the practical blast radius is zero.
The version still moves because a visible affordance disappears.

This closes `TD-15`, the last pair of `API-002` findings and the two the audit
actually named.

### Fixed

- **`externalFilter` dev-warns in server mode instead of being ignored.** The
  predicate is applied in exactly one place -- `use-grid-setup.ts`, under
  `mode === 'client'` -- so on a server-mode grid it was built and never called,
  with no signal of any kind. It now warns once per `gridId`.

  Warn-only, deliberately: there is no affordance to withhold, and applying the
  predicate to the loaded page instead would filter one page of a server dataset
  and make the row counts disagree with the data. Where the predicate is used to
  *hide* rows, a server-mode grid shows more than the consumer intended -- a
  visibility gap, not a data leak, since permission and tenant scoping are
  enforced server-side by the resource layer and never by this predicate.
- **`enableFilterBuilder` is fail-closed in server mode.** The toolbar button and
  the panel used to render; a user could compose a full nested AND/OR expression,
  which was evaluated into the same client-only predicate as `externalFilter` and
  then dropped. `ServerFetchParams` has no expression field, so nothing reached
  `fetchFn` either. Button and panel are now withheld, with a dev-warn per
  `gridId` explaining the absence and pointing at `F-12`.

  Fail-closed rather than warn-only because a *user*, not a developer, does the
  work here -- a console warning never reaches the person losing it. Same
  reasoning as the editing fail-closed in 7.5.0.

  Client mode is unchanged for both props, including a seeded
  `initialFilterExpression` on a grid that does not enable the builder.

### Changed

- `docs/guides/filter-builder.md` claimed the composed expression was forwarded
  in `ServerFetchParams.filterExpression` and translated server-side by the
  resource view. No such field exists, in this package or in
  `@workspace/active-resources`. The guide now states the client-mode-only
  reality and points at `F-12` for the planned server route.
- The same guide claimed the expression was persisted -- serialised to a
  `?filterExpression=` URL parameter under `enableUrlSync`, and stored under the
  grid's namespaced key under `enablePersistence`. Neither driver carries it:
  `url-driver.ts` handles `page`, `pageSize`, `sort`, `filters` and `search`;
  `validate-persisted-state.ts` accepts `sorting`, `columnFilters`,
  `columnOrder`, `columnSizing` and `globalFilter`. Documented as
  consumer-owned, with the round-trip via `onFilterExpressionChange` +
  `initialFilterExpression`.
- `docs/api/grid-props.md` gained a "Filter axis: client mode only" section and
  per-prop mode notes; the JSDoc on `externalFilter`, `enableFilterBuilder`,
  `initialFilterExpression` and `onFilterExpressionChange` carries the same.

## \[7.5.0\] - 2026-07-28 - Two silent misconfigurations now announce themselves

**Behaviour change for existing consumers.** Minor rather than patch: two
configurations that used to render a working-looking grid now render a
different -- and honest -- one. Measured over `apps/` and `packages/`, no
consumer configures either combination today, so the practical blast radius
is zero; the version still moves because the contract changed.

Both cases come from the verification of audit finding `API-002`, not from the
audit itself. The audit named only `externalFilter` and `enableFilterBuilder`
(still open as TD-15); these two were found by compile-probing the 24
combinations the `GridProps` union permits.

### Fixed

- **`pagination.mode="infinite"` is clamped to server mode.** On a client-mode
  grid the flag used to hide both pagination bars while the client paginated
  row-model stayed active, so the grid showed one page of 10 with no control to
  reach the rest -- and then rendered an "All N rows loaded" footer reporting
  the *full* row count. A 500-row grid displayed 10 rows and claimed all 500
  were loaded. It now falls back to standard pagination, with a dev-warn once
  per `gridId` explaining that infinite scroll needs `fetchFn`.

  Server-mode infinite scroll is untouched -- that is the supported
  configuration and the only live consumer.
- **Editing is fail-closed when the commit callback does not match the mode.**
  `editMode` and its callback are a pair: `'cell'`/`'row'` commit through
  `onDataCommit`, `'batch'` through `onBatchCommit`. A mismatch used to leave
  the cells editable while the commit path dead-ended -- batch with only
  `onDataCommit` showed a "Save all" button that returned `false` and discarded
  the buffer, and cell mode with only `onBatchCommit` swallowed every commit.
  Either way the user's work vanished without a signal.

  The grid now withholds the editing meta (no `startEdit`, hence no editable
  cells) and the BatchToolbar, and dev-warns once per `gridId` naming the
  missing callback. A read-only grid with no editing callbacks at all is
  unaffected and never warned about.

### Changed

- `docs/api/grid-props.md` documented `editMode` as `'cell' | 'row'`; the type
  has carried `'batch'` since batch editing landed, and `onBatchCommit` was
  absent from the table entirely. Both corrected.

## \[7.4.0\] - 2026-07-27 - Server refusals are visible at every grouping level

**Behaviour change for existing consumers, and a removed constant.** Minor
rather than patch: a failed drill-down now surfaces an error where it used to
pass unnoticed.

### Fixed

- **A refused CHILD fetch or LOAD-MORE no longer fails silently.** Both paths
  logged the error and then, in the child case, collapsed the group again --
  from the user's seat indistinguishable from a group that simply would not
  open. Both now set the same `error` state the top-level fetch already used, so
  the grid's overlay shows the server's message with a retry.

  This matters now that `@workspace/active-resources` (0.4.0) can refuse a group
  listing outright with `GROUP_CARDINALITY_EXCEEDED` when the grouped column has
  too many distinct values. Adding that server cap without this fix would have
  produced exactly the silent-failure class the resource mechanism exists to
  remove.

### Removed

- **The hardcoded `fetchSize: 10000` for non-leaf group fetches.** Measured, it
  reached nothing: the server ignores `fetchSize` on the group-listing path
  entirely (a listing returns every group or is refused, it does not page), and
  the value was clamped to `maxFetchSize` on the way in and then unused. Both
  levels now ask for the configured block size.

## \[7.3.0\] — 2026-07-26 — Column validation is actually run (EDT-001)

**Behaviour change for existing consumers. Commits that succeed today can be
blocked after this release.** Minor rather than patch for that reason, even
though no API changed: `meta.validate` has been declared and documented since
the beginning and was never called.

### Fixed

- **`meta.validate` now runs, before the consumer is told anything.** It was
  declared on `GridColumnMeta` and documented in `guides/editing.md` and
  `api/columns.md`, but nothing read it. Measured before the fix: the validator
  ran 0 times, the commit succeeded, and the rejected value was delivered to
  `onDataCommit`.

  It now runs on all three commit paths:

  - **cell mode and row mode** — in `useEditingState.commit()`, before
    `onDataCommit`. In row mode every column with a validator is checked, all
    failures are reported at once, and one failing cell blocks the whole row.
    The entered values stay in the editor.
  - **batch mode** — in `useBatchEditingState.commit()`, when the cell is
    flushed to the dirty buffer rather than at "Save All". Validation belongs
    to the input: the message can appear on the cell the user is still in, and
    an invalid value never enters the buffer. Known limitation: a value that
    reaches the buffer by any other route is not validated.

  Sync and async validators are both supported. A validator that **throws**
  blocks the commit and reports `grid.edit.validationFailed` -- a crashing
  validator is not an approval.
- **A `valueParser` returning `undefined` no longer commits an empty value.**
  Such a parser is saying it cannot parse the input. That `undefined` used to
  be stored and committed, so the consumer received the column key with
  `undefined` as its value -- which a Prisma update reads as "leave this field
  alone" and `JSON.stringify` drops entirely, failing silently on every layer.
  The input is now rejected visibly with `grid.edit.invalidValue` and the
  commit is blocked until the value parses.

### Added

- `grid.edit.validationFailed` and `grid.edit.invalidValue` translation keys
  (English defaults; override through your own `t`).
- `data-slot="editing-error"` and `aria-label={message}` on the cell error
  affordance, so the message is reachable for screen readers and assertable in
  tests.

### Changed

- The editing spinner (`isValidating`) now covers validation **and** the
  consumer commit. One indicator for both phases; the guide only ever promised
  that there is an indicator.
- `guides/editing.md` corrected: the error is an icon with a tooltip inside the
  cell, not text below the editor. That was already the case; the guide
  described something that did not exist.

### What to check before upgrading

If you declare `meta.validate` on any column, that validator now decides
whether a commit proceeds. A validator written when it was dead code may
reject more than you expect. The same applies to a `valueParser` that returns
`undefined` for values you currently rely on being written.

Known limitation, unchanged by this release: `valueParser` runs on every
keystroke against a controlled input, so a parser that rejects intermediate
values makes incremental typing impossible. See `ROADMAP.md` TD-24.

---

## \[7.2.0\] — 2026-07-26 — Structurele validatie van persisted state (TD-21)

**Gedragswijziging voor bestaande consumenten.** State die uit `localStorage`
wordt hersteld, wordt vanaf nu ook op **vorm** gecontroleerd, niet alleen op
versienummer. Een slice die niet aan zijn structuur voldoet wordt genegeerd en
valt terug op de default; de overige slices worden gewoon hersteld.

Voor een consument met gezonde opgeslagen state verandert er niets. Voor een
consument met corrupte state verandert er twee dingen, beide ten goede:

- Vier slices lieten de grid **crashen bij mount** (`sorting`, `columnFilters`,
  `columnOrder`, `columnPinning.left` als niet-array → `.filter is not a
  function`), en bleven dat doen bij elke herlaadbeurt tot de gebruiker zijn
  storage handmatig wiste. Die grid komt nu gewoon op.
- Acht slices kwamen **corrupt en zonder signaal** door tot in de store
  (`sorting.desc` als string, `pagination` als array, `globalFilter` als object,
  …). Die worden nu genegeerd in plaats van doorgegeven.

Wie vertrouwde op het doorgeven van een niet-conforme slice — bijvoorbeeld een
`pageSize` als string — krijgt vanaf nu de default. Dat is de enige manier
waarop dit als breuk voelt, en daarom een minor bump en geen patch.

### Toegevoegd

- `src/state/validate-persisted-state.ts` met per-namespace validator-maps
  (`MAIN_STATE_VALIDATORS`, `LOCK_STATE_VALIDATORS`, `PINNING_STATE_VALIDATORS`).
  Intern; geen publieke API.
- Dev-only `console.warn` per genegeerde slice, met slice-naam, grid-id en het
  soort afgewezen waarde. In productie wordt de slice wél genegeerd maar niets
  gelogd.

### Gewijzigd

- `persistenceDriver.load` accepteert een optionele validator-map en is daarmee
  de enige poort waar opgeslagen data binnenkomt — ook voor de twee
  ADR-006-namespaces (lock-state, pinning-state), die tot nu toe niets
  valideerden.
- Een niet-object payload (`null`, array, string, getal) wordt als afwezig
  behandeld in plaats van als leesbaar object.
- Het hydratie-opschoonblok in `loadInitialGridState` draait onvoorwaardelijk.
  Het hing aan `options.columnIds`, waardoor een directe consument zonder die
  optie ongefilterde — en vóór deze release crash-gevoelige — state kreeg.

---

## \[7.1.0\] — 2026-07-26 — Eén uitvoergrens voor CSV/TSV + formule-sanitizer (TD-19)

**Gedragswijziging voor bestaande consumenten.** Een geëxporteerde waarde die een
spreadsheet anders zou lezen dan als letterlijke tekst wordt vanaf nu voorzien van
een leidende apostrof. Dat raakt CSV-export én klembord-kopie, inclusief
kolomkoppen, groepskoppen en groepsvoetteksten. `=1+1` komt in de cel aan als
`'=1+1` in plaats van als de uitkomst `2`. De sanitizer staat standaard aan en
heeft in deze tranche geen opt-out; dat is fail-closed en later additief uit te
breiden.

Wat **niet** verandert: de uitvoer van `formatNumber` en `formatPercent`. Een
negatief bedrag blijft onaangeroerd — gemeten in vijf locale-varianten
(`-1.234,5`, `-1,234.5`, `-1 234,5`, `-1234,5`, `-50 %`). De regel toetst gedrag,
geen tekenlijst, en hoeft daarvoor geen getallen te parsen.

### Toegevoegd

- `src/utils/export-delimited.ts` — de enige plek waar nog een CSV-veld of
  TSV-veld wordt gerenderd (`csvField`, `csvRow`, `tsvField`, `tsvRow`,
  `neutralizeFormula`). Vóór deze release deden twaalf losse plekken dat zelf.

### Gewijzigd

- CSV-**kolomkoppen** worden nu gequote zoals datacellen. Een header met een komma
  leverde eerder meer kolommen in de kopregel dan in de datarijen.
- Groepskoppen worden nu geëscaped; een groepswaarde met een `"` brak eerder de CSV.
- Het klembordpad quote nu velden die een tab, newline of `"` bevatten. Eerder
  verschoof één cel met een tab alle volgende kolommen van die rij.
- `exportToCSVGrouped` en `exportToCSVServerGrouped` deelden byte-identieke
  CSV-opbouw; die is samengevoegd tot één renderer.

### Ongewijzigd, bewust

- XLSX. Gemeten niet kwetsbaar: SheetJS schrijft een string als `t="str"` zonder
  `<f>`-element, dus er valt niets te evalueren. Zie DONE voor de drie
  bewijsniveaus.
- JSON- en PDF-export. Die vallen buiten de grens; een neutralisatieteken is daar
  ongewenst.

---

## \[7.0.0\] — 2026-07-25 — Verwijderingstranche Deel B: het `/server`-subpad weg

**Breaking.** Het hele `@workspace/active-grid/server`-subpad is verwijderd. Nu de
sessie-grid ontkoppeld is en de twee insurance-grids naar `@workspace/active-resources`
zijn gemigreerd, had het oude serveroppervlak geen consument meer. `active-grid` is
daarmee een puur UI/state/client-adapter-package zonder serveroppervlak.

- **Verwijderd (`/server`-subpad, bestaat niet meer):** de wire-contract-types
  (`GridServerActionRequest`/`Response`, `GridActionResult`, `GridAuthCheck`,
  `PrismaModelDelegate`), de raw-closure-escape-hatches (`rawSort`/`rawFilter`/
  `rawGlobal`/`rawGroupBy`/`rawAggregate` + hun `Raw*Mapping`-types), en de
  contract-resolver (`resolveColumnContract`/`resolveAllColumnContracts`,
  `ResolveAllArgs`, `ResolvedColumnContract`/`Map`, `ServerColumnOverride`).
- **Verwijderd (root-export):** de `createServerFetchFn`-client-adapter
  (+ `CreateServerFetchFnConfig`) en `queryBuilder` (+ `PrismaWhereClause`/
  `PrismaFilterCondition`). Server-mode loopt uitsluitend via
  `createResourceServerFetchFn` + `@workspace/active-resources`.
- **Behouden (client-/UI-materiaal):** de per-kolom-`column.server`-declaratie met
  `scalarField`/`byField`/… en het `ServerColumnContract`-type — nu **uitsluitend
  client-side hydration-materiaal** (`extractWireContract` in `use-grid-setup`,
  TD-3-snoeiing van een verouderde sort/filter). Er is geen server-side resolver meer.
- **Migratie:** een grid dat nog `createServerFetchFn` gebruikte migreert naar een
  resource-view (`active-resources`) + `createResourceServerFetchFn`; de enige
  niet-resource-uitzondering is de Redis-sessie-grid (ADR-004), een hand-rolled
  `ActionResult`-action zonder wire-contract.
- ADR-013/017-statussen bijgewerkt (server-resolutie nu volledig weg); de docs onder
  `docs/api` en `docs/guides` chirurgisch bijgewerkt (drift-detector groen),
  `DATA-ACCESS.md` op de eindtoestand.

## \[6.0.0\] — 2026-07-23 — Verwijderingstranche Deel A: de grid-server-factory weg

**Breaking (`@workspace/active-grid/server`).** Nu alle 26 grids naar
`@workspace/active-resources` zijn gemigreerd, is het dode deel van het oude
serveroppervlak verwijderd:

- **Verwijderd:** de factory `createGridServerAction` (+ `CreateGridServerActionConfig`),
  `generateDmmfDefaults` (+ `DmmfFieldMeta`/`DmmfModelMeta`), de group-by-runtime
  (`handleGroupByRequest`), en de publieke `warnReserved`/`warnStripped`-exports.
  Alle zes per-database grid-actions die op de factory leunden zijn mee verwijderd
  (`getActiveGridDataAction`, `getPaginatedPensionDataAction`, plus de vier dode
  pension-acties).
- **Behouden (gereduceerd oppervlak):** de wire-contract-types
  (`GridServerActionRequest`/`Response`, `GridActionResult`, `GridAuthCheck`,
  `PrismaModelDelegate`), `queryBuilder`, `resolveAllColumnContracts`, de
  raw-mappings, `extractWireContract` en de `createServerFetchFn`-client-adapter —
  levend gehouden door de Redis-sessie-grid (ADR-004) en de geparkeerde
  insurance-grids. Volledige verwijdering in Deel B; voorwaarden in
  `docs/DATA-ACCESS.md` (merged into `ARCHITECTURE.md` § Datatoegang en eigendom in 2026-09).
- ADR-011 ingetrokken; ADR-013 + ADR-013-A + ADR-017 (deels) superseded.

## \[5.2.0\] — 2026-07-23 — LookupGrid capability-doorgifte

- `LookupGrid` accepteert een optionele `serverCapabilities`-prop en geeft die
  door aan de interne `ActiveGrid`. Additief en niet-breaking: legacy-lookups
  zonder de prop gedragen zich ongewijzigd. Nodig voor resource-backed lookups
  (person-lookup, participant-lookup) die de capability-map moeten doorgeven.

## \[5.1.0\] — 2026-07-22 — Capability-keten tot op operatorniveau

**Breaking (resource-grids):** `GridServerCapabilities` is niet langer de
drie-key-array-vorm (`{sort, filter: string[], group?}`) maar de volledige
structurele spiegel van active-resources' `ResourceCapabilities`
(`{filter: Record<id, {filterType, nullable?, operators}>, sort, group,
aggregate, search, presets}`). Consumers geven de `deriveCapabilities`-map nu
ongesplitst door; de eerdere hand-mapping verdwijnt.

- Filtercomponenten leiden hun operatorlijst af uit de capability-map
  (gestampt als `meta._serverFilterCapability`), niet meer uit de ingebouwde
  per-type-defaults. `allowedOperators`/`operatorPreset` blijven als
  presentatie-narrowing: doorsnede met de map, kan alleen versmallen
  (expliciete entry buiten de map → genegeerd + dev-warn).
- Kolom-`type:` is presentatie; wijkt hij af van het capability-`filterType`
  dan wordt de filter fail-closed uitgeschakeld met dev-warn
  (`select`-presentatie is compatibel met elk type — set-vorm).
- Hydratie-snoei nu ook op operator-granulariteit: een persisted/URL-filter
  met een niet-meer-toegestane operator wordt vóór de eerste fetch gesnoeid.
- Toolbar-zoekbox is capability-gestuurd: bij `search: false` onderdrukt +
  dev-warn; `useToolbarSearchWarning` checkt op het resource-pad
  `capabilities.search` (de legacy `server.global`-check vuurde daar onterecht).
- De dev-only drift-check in `createResourceServerFetchFn` vergelijkt de
  volledige map canoniek (tot op operatorniveau), niet alleen key-sets.
- Nieuwe exports: `GridServerFilterCapability`, `GridServerFilterType`,
  `GridServerAggregateOp`.

---

## \[5.0.0\] — 2026-07-20 — Base UI-migratie

Alle Radix-afhankelijkheden zijn vervangen: de grid-menu's en de
filter-popover zijn skins over de active-ui-componenten (Base UI) geworden.
Dit lost de klasse runtime-fouten op waarbij een Radix-item in een Base
UI-root zijn context niet vindt ("MenuItem must be used within Menu") — de
menu-roots kwamen al uit active-ui, de items nog uit eigen Radix-primitives.

**Vereist `@workspace/active-ui` >= 2.0.0** (Base UI). Zie active-ui's
CHANGELOG \[2.0.0\] voor de volledige API-verschillen.

### Migratie-instructie (voor de geneax-werkstroom)

**De complete migratieroute — inclusief de stille faalklassen die typecheck
en tests niet vangen, detectie-greps en de handmatige-naloop-checklist —
staat in `packages/active-ui/docs/MIGRATION-BASE-UI.md`.** De stappen
hieronder zijn de active-grid-specifieke samenvatting.

Deze release wordt gesynct in de gecoordineerde geneax-werkstroom, sámen met
active-ui 2.0.0 — nooit los (het sync-script blokkeert beide tot die
werkstroom; zie DATYX-WERKWIJZE.md par. 11.9). Stappen per repo:

1. Sync active-ui én active-grid in één werkstroom
   (`node scripts/sync-package.mjs <naam> <repo>` na het opheffen van de
   `SYNC_BLOCKED`-entries).
2. package.json's daar: verwijder alle `@radix-ui/*`-deps en `radix-ui` uit
   active-ui/active-grid/active-content-equivalenten; voeg
   `@base-ui/react` (^1.6.0) toe aan active-ui én aan elk package dat zelf
   Base UI-parts componeert; `pnpm install`.
3. Migreer de repo-eigen consumers volgens active-ui CHANGELOG \[2.0.0\]
   (Breaking-lijst): `asChild` → `render`, menu-`onSelect` → `onClick`,
   Select/ToggleGroup/Checkbox/Accordion/Tooltip-signaturen, focus-props,
   `--radix-*`-CSS-variabelen.
4. Draai de eigen typecheck + tests; de fouten wijzen exact de resterende
   call-sites aan (de typecheck vangt alles behalve `onSelect`-props — die
   verdwijnen stilzwijgend, grep er expliciet op).

### Breaking

- **`FilterPopoverTrigger`**: was Radix' `Popover.Trigger` (met `asChild`),
  is nu active-ui's `PopoverTrigger` (Base UI) — compositie via
  `render={<El/>}`. Alle zes ingebouwde filters zijn mee-gemigreerd;
  host-apps die zelf filter-popovers bouwen migreren hun `asChild`-sites.
- **`FilterPopoverContent`**: props zijn nu active-ui `PopoverContent`-props;
  `onOpenAutoFocus` bestaat niet meer (gebruik `initialFocus={false}`).
- **`GridMenuItem`/`GridContextMenuItem` en verwanten**: props volgen de
  active-ui menu-items (Base UI): `onSelect` → `onClick`, keep-open via
  `closeOnClick={false}`. `GridContextMenuSubTrigger`/`GridMenuSubTrigger`
  renderen geen eigen chevron meer (de active-ui sub-trigger brengt die mee).
- **`GridContextMenu`** (context-menu-wrapper): trigger-compositie via
  `render`; het `children`-element moet een enkel React-element zijn.

### Removed

- Dependencies `@radix-ui/react-context-menu`, `@radix-ui/react-dropdown-menu`
  en `@radix-ui/react-popover`.

### Fixed

- **"MenuItem must be used within Menu" bij het kolom-kiezer-menu** — de
  gemengde Radix/Base UI-menustack; opgelost door de stack-uniformering
  (integratie-test `enableHiding: false` is weer groen zonder
  testaanpassing).

---

## \[4.4.1\] — 2026-07-01

### Fixed

- **Double server query on a hydrated filter (server mode)** — a server-mode
  grid whose filter came from the URL (`enableUrlSync`) or localStorage
  (`enablePersistence`) fired two queries on cold mount: an unfiltered fetch
  from the empty first render, then a filtered fetch once hydration and the
  300ms typing-debounce settled (a brief unfiltered flash, and the heaviest
  query run for nothing). The data-orchestrator now holds its first server fetch
  until the initial state is resolved (an internal `hydrated` signal from
  `use-grid-state`) and reads the settled (raw) filter for that first fetch
  instead of the debounced value, so exactly one already-filtered query fires.
  Subsequent (typing) fetches stay debounced; a grid with no hydrated filter
  fetches immediately with no added delay; client mode has no server fetch and
  is unaffected. Internal only, no public API change. The v4.4.0 forward-sync
  and `statePriority` machinery (`loadInitialGridState`,
  `resolve-initial-state`, `use-filter-sync`) is untouched. Covered by a
  fetch-count regression guard (URL- and localStorage-hydrated → one filtered
  fetch; no-filter → one immediate unfiltered fetch).

## \[4.4.0\] — 2026-07-01

### Added

- **`statePriority` prop** — a per-slice conflict-resolution strategy between
  the URL (`enableUrlSync`) and localStorage (`enablePersistence`) for the four
  contested state slices (`sorting`, `columnFilters`, `globalFilter`,
  `pagination`). Four values: `'url-first'` (default), `'localstorage-first'`,
  `'url-only'`, `'localstorage-only'`. Resolution is per-slice, so a URL that
  carries only `sorting` still lets localStorage supply the `columnFilters`; a
  per-source-block merge could not express this. The column-shape slices
  (`columnVisibility`, `columnOrder`, `columnSizing`, `columnPinning`,
  `rowPinning`, `expanded`) live only in localStorage and are outside the
  strategy — they always follow localStorage while `enablePersistence` is on.
  The default `'url-first'` reproduces the pre-4.4.0 merge behaviour exactly, so
  existing consumers are unaffected. New pure resolver in
  `state/resolve-initial-state.ts`; `GridStatePriority` is exported. Covered by
  a full strategy-by-conflict-combination matrix plus a regression anchor
  proving the default equals prior behaviour.

### Fixed

- **`select` (multi-value) filters lost on URL rehydration** — a `select`/badge
  filter holds a bare array value (`string[]` / `number[]`), but the URL driver
  serialized it through the scalar `contains` fallback, so it came back as an
  operator-object and the select filter (which reads `Array.isArray`) silently
  dropped it when both `enableUrlSync` and `enablePersistence` were on (e.g. on
  back-navigation). The URL driver now round-trips array values losslessly via
  an `in` transport token (`col:in:v1,v2`), preserving the array shape and
  coercing numeric-looking elements back to numbers so numeric selects
  round-trip as numbers. Sorting and pagination were never affected. Links
  produced by older versions using the `contains:a,b` form still deserialize
  (fail-safe backward-compat), so existing shared links keep working. Covered by
  new round-trip and backward-compat tests.
- **Inline filters (`text` / `number` / `date`) wiped or desynced a
  URL-hydrated value on cold mount** — these components keep a debounced local
  input state that was seeded once, before the async engine hydration, so it
  captured the empty pre-hydration value. `useFilterSync` only reset local
  state on an external clear, never adopting an externally-SET value. For
  `text`/`number` the debounce apply-effect then pushed `setFilterValue(undefined)`
  with the stale empty value and cleared the freshly-hydrated filter (the URL
  lost its `filters=` param a moment after load); `date` showed a blank picker
  while the filter stayed active. `useFilterSync` is now a forward-sync hook: it
  adopts the engine value into local state on every external change (set and
  clear) via a `syncFromExternal` callback, keyed on `[currentFilter]` so typing
  never triggers a re-adopt and a debounced commit converges to a no-op. The
  apply-effect's clear branch no longer clobbers an externally-set value the user
  has not touched, while still allowing user clears and the harmless empty no-op.
  `interval`/`select`/`boolean` were already controlled-from-store (immune) and
  the toolbar global-search shares the same forward-sync shape. Covered by new
  integration tests that mount the real filter component with a URL/localStorage-
  hydrated value (the exact gap that hid this defect) plus a typing-still-applies
  test.

## \[4.3.1\] — 2026-06-28

### Fixed

- **Numeric `select` (dropdown) filters** — a multi-value `select` filter on a
  numeric column (`filterType: 'number'`, inherited from the DMMF for
  `Int`/`Float`/`Decimal`/`BigInt` fields) now coerces its values to numbers
  before building the `{in: [...]}` predicate. The select path previously
  stringified every value unconditionally, so a fixed-value dropdown on a numeric
  column emitted strings that the database rejects on a numeric column. Numeric
  dropdown filters are now supported generically, alongside the existing
  string-enum dropdowns; string `select` filters are unchanged. Covered by two
  new unit tests.

## \[4.3.0\] — 2026-06-17

### Added

- **Generic `interval` filter** — a new `ColumnFilterType` for matching a row's
  interval (two numeric boundary fields) against a numeric query range, with a
  `within` (containment) / `overlap` mode toggle. Strictly numeric and
  domain-agnostic: the grid knows nothing about what the numbers represent
  (day-numbers, prices, versions). Pieces:

  - **`IntervalFilterValue`** (`{from: number | null, to: number | null, mode: 'within' | 'overlap'}`)
    and **`IntervalMode`** in `types/filters.ts`. The filter is inactive when
    both bounds are `null`.
  - **`IntervalFilter`** component (`components/filters/interval-filter.tsx`),
    wired into `FilterWrapper` for `filterType: 'interval'`. Renders the mode
    toggle, the popover and the apply/clear plumbing; the two-bound entry
    defaults to a pair of numeric inputs and is replaceable via
    **`meta.intervalInput`** (`IntervalInputProps`) — the seam where a host
    plugs in a domain entry (e.g. a year picker that maps to day-numbers)
    without the grid knowing what the numbers mean.
  - **`buildIntervalWhere(value, {loField, hiField})`** in
    `utils/build-interval-where.ts` — a pure, framework-neutral helper that
    turns an `IntervalFilterValue` into a Prisma-shaped `where` fragment. No
    Prisma import, no domain knowledge; a server resolver composes the result.
    Semantics: `within` emits `lo >= from AND hi <= to` per given bound (a
    `gte`/`lte` excludes a null row bound by construction, so open-ended rows
    never match a closed query side); `overlap` is null-tolerant on both bounds.
  - i18n keys `grid.filter.intervalWithin` / `grid.filter.intervalOverlap`
    (host-translated; English defaults provided).

### Notes

- The declarative `column.server` query-builder path skips `interval` columns
  with a dev-warning (they carry a value, not an operator); resolve them
  server-side via `buildIntervalWhere`. A declarative `filter:interval` mapping
  may follow if a project adopts the contract for interval columns.

## \[4.2.2\] — 2026-06-08 — Laatste-kolom resize-lijn op de grens + structurele viewport-clearance

Patch release. De zichtbare resize-lijn van de **laatste kolom** staat nu op de
kolomgrens (consistent met de middenkolommen), en de card-rand-vrijwaring komt
van een **structurele viewport-clearance** in plaats van de oude lijn-inset.
Geen API-wijziging; de CSS leeft in `@workspace/active-ui` (gespiegeld). Architectuur-besluit en
meet-onderbouwing: `DECISIONS.md` ADR-020.

### Changed

- **TD-11 inset-rol vervangen door structurele viewport-clearance (HD-holder).**
  NIET teruggedraaid — het onderscheid is essentieel: de TD-11-inset
  (`--grid-resize-handle-edge-gap`, 8px) trok de laatste-kolomlijn naar binnen om
  hem van de card-rand te houden, waardoor hij los van de kolomgrens "zweefde"
  (inconsistent met middenkolommen — GAP-2). Die card-rand-rol verhuist nu naar
  een clearance die in de **scroll-content-breedte** is gereserveerd
  (`--grid-edge-clearance`, default = `--grid-scrollbar-width` = 8px), gekoppeld
  aan de bestaande flex-`EDGE_RESERVE` (8px) in `use-flex-layout.ts`. De
  resize-`::after`-lijn van `[data-edge="last"]` staat daardoor op de kolomgrens
  (`left:100%; translateX(-100%)`, edge-gap 0); de 12px hit-area en de TD-11
  e2e-geometrie blijven ongemoeid.
- **Waarom dit nodig was (gemeten, niet beredeneerd):** de oude container-gutter
  (`scrollbar-gutter:stable` + header `padding-right`) is **niet onvoorwaardelijk**
  — bij een horizontaal-overflowende grid zónder verticale scrollbar collapst de
  gutter aan het scroll-einde tot ~1px, waardoor een naïeve edge-gap-0 de lijn op
  de card-rand zou zetten (de TD-11-regressie). Alleen clearance in de
  scroll-content-breedte (niet container-padding/-margin, die worden gedropt)
  overleeft het scroll-einde.

### Added

- **e2e `last-column-clearance.spec.ts`** — regressie-net: lijn-op-de-grens
  (`L→cell ≈ 0`, geen sliver) en card-clearance in álle staten: niet-gescrold
  (`L→card ≈ 19`), h-scroll-einde zónder vscroll (`L→card ≈ 9`), h-scroll-einde
  mét vscroll (`L→card ≈ 9`, scrollbar flush), `fit-h-scroll = 0` waar kolommen
  passen, en pinned-right (sticky + pin-shadow + lijn op de grens). De
  INV-3-drempel is op het productiegetal gepind (clearance 8 → `L→card ≥ 4`,
  waardoor de 1px-scroll-einde-regressie de test rood maakt). De bestaande TD-11
  e2e (`resize-handle-last-column.spec.ts`) blijft groen.

### Invariant

- **INV-18 (structurele laatste-kolom-clearance):** de scroll-content reserveert
  `--grid-edge-clearance` trailing-breedte bovenop de kolom-total, en de flex-fill
  reserveert exact hetzelfde (`EDGE_RESERVE`). **Beide MOETEN gelijk blijven** —
  lopen ze uiteen, dan breekt óf `fit-h-scroll = 0` (content > viewport) óf INV-3
  (clearance \< fill-reserve). Zie DECISIONS ADR-020.

---

## \[4.2.1\] — 2026-06-07 — Regressietest: default-paginering-contract

Patch release. Geen gedragswijziging — borgt bestaand gedrag met een test.

### Added

- **Regressietest `default-pagination-contract.test.tsx`** die vastlegt dat een
  grid zónder paginering-props opent op page-size **10** met selector
  **`[10, 20, 50, 100]`**. Dit gedrag komt uit twee gescheiden code-paden die
  gereconcilieerd moeten blijven: de start-grootte uit `state/load-initial-state.ts`
  (`initialPageSize || 10`) en de selector-opties uit `controls/pagination.tsx`
  (`config.pageSizeOptions ?? globalConfig.pagination.pageSizeOptions` →
  `DEFAULT_GRID_CONFIG`). Ze vallen alleen samen omdat 10 lid is van de
  default-set. Consumers (datyx-v3 P1/P1b) leunen platform-breed op deze default;
  de test faalt zodra één van beide defaults wegdrijft.

---

## \[4.2.0\] — 2026-06-02 — Context-menu sluit by default bij actie-klik (+ `keepOpen` opt-in)

Minor release. Een nieuwe additieve `keepOpen`-prop op context-menu-items
(minor) plus een gecorrigeerde default voor het sluitgedrag (Changed). Gevonden
tijdens het groen maken van de e2e-suite (R-3 spec-onderhoud).

### Changed

- **Een custom context-menu-actie-item sluit nu het menu bij klik** (web-conventie),
  in plaats van open te blijven. Voorheen riepen álle custom items (en submenu-
  children) onvoorwaardelijk `e.preventDefault()` aan in `onSelect`
  (`components/menus/context-menu-content.tsx`), waardoor het menu open bleef —
  terwijl alle **ingebouwde** items (copy, export, row-edit, row-pin, reset-
  columns) wél sluiten. Die inconsistentie binnen hetzelfde menu was het bewijs
  dat het geen bewuste keuze was. Custom items sluiten nu net als de ingebouwde;
  de actie vuurt ongewijzigd.
- **Niet-brekend in de praktijk:** een grep over alle consumers (admin/pension/
  insurance/pepp/portal/mdm) toonde dat geen enkele custom menu-item op het open-
  blijven-gedrag leunt (alle acties zijn one-shot: navigeren, dialog openen,
  server-action; 0 items gebruiken `isLoading`/`keepOpen`). Sluiten-bij-klik is
  daar de correcte UX (het menu blijft o.a. niet meer achter een confirm-dialog
  hangen).

### Added

- **`keepOpen?: boolean` op `ContextMenuItem`** — opt-in om het menu na een klik
  open te houden, voor items die herhaald gebruikt worden (multi-select-stijl).
  Geldt voor top-level items en submenu-children. Items met `isLoading` houden
  het menu eveneens open (defensief vangnet; merk op dat `isLoading`-items in de
  praktijk `disabled` zijn en dus niet selecteerbaar — `keepOpen` is de
  functionele opt-in).

### Tests

- `e2e/context-menu.spec.ts` — de bestaande "klik sluit het menu"-test klopt nu
  zonder wijziging; nieuwe test borgt dat een `keepOpen`-item het menu open houdt
  én de actie vuurt. De `/test-client-grid`-harnas heeft een `keepOpen`-demo-item
  gekregen (5 locales).

---

## \[4.1.9\] — 2026-06-02 — Escape annuleert (committeert niet) met commitOnBlur + keyboard-navigatie

Patch release. Correctness-fix in de keyboard-navigatie Escape-afhandeling; geen
publieke API-wijziging. Gevonden tijdens het bouwen van de keyboard-nav-E2E
(R-3-subset).

### Fixed

- **Escape in een open cel-editor committe de waarde i.p.v. te annuleren wanneer
  `commitOnBlur` én `enableKeyboardNavigation` aanstonden.** De keyboard-nav
  Escape-tak (`hooks/use-keyboard-navigation.ts`) riep `target.blur()` aan om de
  focus terug naar de cel te brengen. Een programmatische `blur()` vuurt een
  `focusout` met `relatedTarget = null`; `handleGridBlur` (`components/grid.tsx`)
  leest dat als "focus verliet het grid" en committe — met `commitOnBlur` aan —
  de waarde vóórdat de `cancelEdit()` van de editor was doorgewerkt (de
  blur-commit won van de annulering). De handler herfocust nu de cel **zonder**
  `target.blur()`: focus verplaatst naar een element ín het grid
  (`relatedTarget` = de cel), zodat `handleGridBlur` een no-op is en de
  annulering wint. Escape annuleert nu ongeacht `commitOnBlur`. Raakt o.a. de
  pension glide-path-editor (de enige productie-grid met `commitOnBlur`).

### Tests

- `components/__tests__/active-grid-integration.test.tsx` — nieuwe regressietest
  (Escape + commitOnBlur + keyboard-nav → geen commit, originele waarde terug);
  bewezen faalt-zonder-fix (committe "Typed but escaped"), slaagt-ermee.
- Tevens geborgd in de nieuwe E2E `e2e/keyboard-nav.spec.ts` (zie DONE — R-3
  keyboard-nav-subset).

---

## \[4.1.8\] — 2026-06-02 — Single-click-edit reset de cel niet meer bij interactie met de open editor

Patch release. Correctness-fix in de cel-edit-start-logica; geen publieke
API-wijziging.

### Fixed

- **Met `editTrigger="click"` ging een gekozen `<select>`-optie verloren: de cel
  bleef op de oude waarde staan.** Een klik op een optie (de native `<select>`
  zit ín de cel) bubbelt naar de `onClick` van de cel, die met `editTrigger="click"`
  opnieuw `handleEditStart` aanriep. `startEdit()` her-initialiseert de
  editing-state vanaf de **originele** rij-waarde, waardoor de net-gekozen
  waarde werd weggegooid (`components/sections/body-cell.tsx`). `handleEditStart`
  doet nu niets meer wanneer de cel al in bewerking is (`isSameCell`), zodat een
  klik die vanuit de open editor omhoog bubbelt de bewerking niet herstart. Bij
  `editTrigger="doubleClick"` speelde dit niet (enkele klikken starten daar geen
  bewerking) — vandaar dat het alleen met enkele-klik-bewerken zichtbaar was.
  Raakt ook tekst/getal-editors waar een tweede klik in het veld de
  tussentijdse invoer zou resetten.

### Tests

- `components/__tests__/active-grid-integration.test.tsx` — nieuwe regressietest
  (single-click select-edit → gekozen optie blijft staan); bewezen
  faalt-zonder-guard (reset naar origineel), slaagt-ermee.

---

## \[4.1.7\] — 2026-06-02 — Datum kiezen committeert niet langer de oude waarde (TD-13 focusout-pad)

Patch release. Voltooit de TD-13-portal-uitsluiting; geen publieke API-wijziging.

### Fixed

- **Een datum kiezen in de cel-editor-kalender verwierp de keuze en behield de
  oude datum.** TD-13 (\[4.1.5\]) gaf de document-`mousedown`-handler een
  portal-uitsluiting, maar het **oudere `focusout`-pad** (`handleGridBlur`,
  `components/grid.tsx`) had die niet. Bij het klikken op een kalenderdag
  verschuift de focus van de editor-input naar een knop in de Radix-popover-
  portal *buiten* de grid-DOM; `handleGridBlur` zag een `relatedTarget` buiten
  het grid en committe — met de **oude** waarde, want de `onChange` van de
  kalender (de nieuwe datum) draait pas op de `click` ná de `focusout`. De
  editor sloot, de keuze ging verloren. Beide commit-paden delen nu één
  `isWithinEditorPortal`-helper, zodat focus/klik in een editor-eigen
  floating-layer (de date-picker) noch committeert noch annuleert; de edit
  blijft open tot de gebruiker de datum kiest en daarna echt buiten klikt.

### Tests

- `components/__tests__/active-grid-integration.test.tsx` — nieuwe regressietest
  voor het `focusout`-pad (focus naar de date-popover → géén commit); bewezen
  faalt-zonder-fix, slaagt-ermee. De bestaande TD-13-`mousedown`-popover-test
  blijft groen.

---

## \[4.1.6\] — 2026-06-02 — Focus-steal bij single-click-edit + keyboard-navigatie

Patch release. Correctness-fix in de keyboard-navigatie focus-afhandeling; geen
publieke API-wijziging.

### Fixed

- **Met `editTrigger="click"` én `enableKeyboardNavigation` kreeg de
  cel-editor wel de bewerk-ring maar geen cursor.** Eén klik zette tegelijk de
  focused-cell én opende de editor; de editor-input auto-focuste tijdens de
  commit-fase, waarna de "scroll-to-focus"-`useEffect` in
  `hooks/use-keyboard-navigation.ts` de cel-`<div>` herfocuste en de caret weer
  uit de net-gemounte input trok (ring zichtbaar, geen cursor; pas een tweede
  klik plaatste de cursor). De effect herfocust de cel nu alleen wanneer de
  focus nog niet ín de cel zit (`!el.contains(document.activeElement)`), zodat
  pijltjes-navigatie tussen cellen onveranderd werkt maar een interactief kind
  (de open editor-input) zijn focus behoudt. Gevonden tijdens het bouwen van de
  admin editing-test-harnas.

### Tests

- `components/__tests__/active-grid-integration.test.tsx` — nieuwe regressietest
  (single-click + keyboard-navigatie → editor-input heeft focus); faalt zonder
  de guard, slaagt ermee. `vitest.setup.ts` polyfillt nu
  `Element.prototype.scrollIntoView` (jsdom implementeert het niet), zodat de
  scroll-to-focus-effect onder test draait.

---

## \[4.1.5\] — 2026-06-02 — Commit open cel-edit bij klik buiten het grid (TD-13)

Patch release. Correctness-fix voor `commitOnBlur`; geen publieke API-wijziging
(de `commitOnBlur`-prop bestond al — deze fix laat hem zijn gedocumenteerde
contract "clicking away should save" ook nakomen bij een klik op een
niet-focusbaar element buiten het grid).

### Fixed

- **Een klik buiten het grid op een niet-focusbaar element (pagina-achtergrond,
  een kop, een kaart) commit nu de openstaande cel-edit wanneer `commitOnBlur`
  aan staat.** Voorheen was het enige commit-op-blur-mechanisme de
  wrapper-`onBlur` (`handleGridBlur`, `components/grid.tsx`), die alleen vuurt
  bij een echte `focusout` — d.w.z. wanneer de focus naar een ander
  *focusbaar* element verschuift. Een klik op een niet-focusbaar element
  verschuift de focus in de browser niet, dus er kwam geen `focusout` en de
  edit bleef stil openstaan ondanks `commitOnBlur`; een daaropvolgende
  navigatie/refresh/unmount kon de pending edit alsnog verliezen (TD-13,
  Spoor B #25). Headless geverifieerd (mousedown-helft); de focus-helft is
  gedocumenteerd DOM-gedrag.

### Added

- **Document-niveau `mousedown`-handler** (`components/grid.tsx`), alleen
  geattacht wanneer `commitOnBlur` aan staat. Hij spiegelt de commit-beslissing
  van `handleGridBlur` en hergebruikt dezelfde `commit()` (L7 — geen nieuw
  commit-pad). Klikken binnen het grid worden genegeerd (cel-logica regelt die
  al), en klikken binnen een editor-eigen portal — met name de date-picker-
  Popover, die Radix buiten de grid-DOM rendert
  (`[data-radix-popper-content-wrapper]` / `[data-slot="popover-content"]`) —
  committen bewust niet, zodat een datumkeuze in de kalender de edit niet
  voortijdig afsluit.

### Changed

- **Re-entrancy-guard in `useEditingState.commit`** (`hooks/use-editing-state.ts`).
  Eén klik op een focusbaar element buiten het grid kan zowel de nieuwe
  document-`mousedown`-handler als de daaropvolgende `focusout` in dezelfde tick
  triggeren; zonder guard zouden beide `onCommit` aanroepen (dubbele
  server-write). Een `committingRef`-vlag dedupliceert de in-flight commit en
  wordt in een `finally` weer vrijgegeven.

### Tests

- `components/__tests__/active-grid-integration.test.tsx` — vier nieuwe TD-13-
  tests: commit-bij-klik-buiten (faalt-bij-regressie), géén commit bij klik in
  de date-popover-portal, géén commit bij mousedown binnen het grid, en géén
  outside-commit wanneer `commitOnBlur` uit staat (Escape blijft cancelen). De
  bestaande `use-editing-state-blur.test.ts`-ankers blijven ongewijzigd groen.

---

## \[4.1.4\] — 2026-06-01 — Scroll-to-top bij nieuwe query in interne-scroll-grids (R-28)

Patch release. Gedrags-correctie in een vaste-height server-mode-grid; geen
publieke API-wijziging (een interne `datasetKey` is door de `useGridSetup`-
return geregen, niet geëxporteerd).

### Fixed

- **Bij een nieuwe query (filter/sort/search) scrollt de interne viewport van
  een vaste-height server-mode-grid terug naar de top.** Voorheen bleef de
  interne scrollbar staan waar hij stond: na een nieuwe query laadde een verse
  page-0, maar de viewport keek nog naar de onderkant in plaats van de eerste
  rij van het zoekresultaat — afwijkend van de conventie (AG Grid e.a. brengen
  je bij een nieuwe query naar de top). De omgekeerde-richting-bevinding van de
  R-28-verificatie: de hypothese was "scroll verspringt", de werkelijkheid was
  "scroll werd te goed behouden → er ontbrak een reset".

### Changed

- **Trigger is de orchestrator-`datasetKey`** (`use-data-orchestrator.ts`),
  geëxposed via de `useGridSetup`-return en geconsumeerd door een
  `useLayoutEffect` in `components/grid.tsx` (vlak naast — en in dezelfde
  pre-paint-`scrollTop`-familie als — de bestaande group-collapse
  scroll-correctie, L7). Omdat `datasetKey` op de **gedebouncede**
  filter/search-waarden gebouwd is, vuurt de reset één keer per toegepaste
  query, **nooit per toetsaanslag**. `datasetKey` bevat geen `pageIndex` en
  verandert niet bij rij-mutaties (`patchRow`/`addRow`/`removeRow`), dus
  pagineren en rij-mutaties resetten de scroll niet — zonder extra guard.
- **Scope strikt: server-mode + vaste height (interne overflow-auto-viewport).**
  `height="auto"`/document-scroll-grids vallen erbuiten (guard
  `!isAutoHeight`); daar zou een reset de hele pagina laten verspringen — een
  aparte UX-vraag, geparkeerd als ROADMAP R-31.

### Verification

- typecheck 0, lint clean (`eslint .` exit 0), 1379/1379 tests groen (98 files;
  +4 t.o.v. de 1375-baseline). Nieuwe test `scroll-to-top-on-query.test.tsx`:
  query-reset post-debounce (niet per toetsaanslag), rij-mutatie-behoud,
  auto-height-uitsluiting, en coëxistentie met de group-collapse-correctie.
  Geneax-1 mirror gesynct (§11.9, src + docs, geen npm-dep): typecheck + test
  groen identiek.

---

## \[4.1.3\] — 2026-06-01 — Server-grouped JSON-export-consistentie (TD-16)

Patch release. Correctness-fix in een download-pad, geen API-wijziging.

### Fixed

- **`exportToJSONServerGrouped` past nu de `valueFormatter` toe op leaf-data,
  consistent met zijn sibling-formats.** De server-grouped JSON-leaf-export
  schreef leaf-waarden rauw via `item[col.id]`, terwijl alle siblings de
  formatter wél toepassen: de server-grouped CSV/Excel/PDF-varianten via
  `buildServerGroupedExportRows` (`utils/export-utils.ts:713`) en de
  client-`exportToJSON` via `formatCellValue`. De JSON-leaf-tak
  (`utils/export-utils.ts:970`) is nu door dezelfde formatter/value-resolutie
  geroute als de 713-sibling: `applyFormatter(col, val, item)` met fallback op
  de rauwe `val` en `''` voor null/undefined. Een grid met bijv. een
  currency-`valueFormatter` levert in server-grouped JSON-export voortaan
  `"€ 1234.50"` in plaats van `1234.5`.
- **Reikwijdte-noot (713 vs 970).** De ROADMAP-entry suggereerde dat de directe
  property-read `item[col.id]` op zowel 713 als 970 een gedeelde root was en
  "beide samen" gefixt moest worden. Tegen de code blijkt dat onjuist: in
  server-grouped mode is leaf-data platte `TData[]` van `fetchFn`, geen
  `Row<TData>`-objecten, dus `getValue`/de kolom-accessor bestaat daar niet —
  de directe property-read is op beide plekken noodzakelijk en legitiem. 713
  past de formatter al correct toe en is het te kopiëren sibling-patroon; het
  enige defect zat op 970 (ontbrekende formatter). 713 is ongemoeid gelaten.

---

## \[4.1.2\] — 2026-06-01 — Stale-while-revalidate fallback verhuisd naar de cache-store (TD-17)

Patch release. Interne refactor, geen consumer-zichtbaar gedrag.

### Changed

- **Stale-while-revalidate fallback van een render-`useRef` naar de
  `BlockCacheStore` verhuisd.** De "last good page"-snapshot die de vorige
  rijen zichtbaar houdt tussen `invalidate(...)` en de fresh-fetch-resolve
  (ADR-016 stale-while-revalidate) lag in een `lastGoodPageRef` in
  `use-data-orchestrator.ts`, tijdens render geschreven én gelezen. Die ref is
  vervangen door een plain-field mirror op de store, gelezen via een nieuwe
  interne methode `BlockCacheStore.peekPageWithFallback(...)` — een render-time
  write in dezelfde categorie als het bestaande
  `pageSliceCache`-render-write-precedent (geen `notify()`, geen re-render).
  Het gedrag is **byte-identiek**: dezelfde fallback-mechaniek, alleen een
  andere drager. De ADR-016-anti-flicker-garantie
  blijft behouden (geen empty-flash / geen extra blank frame bij
  filter/sort/cross-block-transitie) en de publieke `OrchestratorResult`-shape
  is **ongewijzigd**. `BlockCacheStore` is internal-only — geen publieke
  API-wijziging.
- Regressie-anker: de geharde `GRID-ARCH-16`-tests in
  `use-data-orchestrator.test.ts` leggen de fallback-zichtbaarheid in het
  invalidatie-venster nu met **harde** asserts vast (de oude `GRID-ARCH-06`
  liet dat achter een conditionele `if (... && isRefetching)` die vacuüm kon
  slagen).

---

## \[4.1.1\] — 2026-05-30 — Filter/a11y closeout (TD-7, TD-8, TD-9)

Patch release. Formele afhechting van drie items waarvan de code al in de
working tree zat — gebundeld gecommit (repo-tag `v1.1.116`) maar nog niet in
een eigen active-grid-release vastgelegd; de `[4.1.0]`-entry hieronder
markeerde ze daar nog als deferred backlog. De fixes zijn in eerder werk
ontstaan; deze release verifieert (tests groen) en documenteert ze.

### Fixed

- **TD-9** — Client-side `evaluateFilter` deed voor `equals`/`notEquals` een
  string-vergelijking, ook op `number`- en `date`-kolommen. Een toegewijd
  equals/notEquals-blok routeert nu op het runtime-type van de filterwaarde
  (number → numeriek, `Date` → dag-gelijkheid, string → case-insensitive
  text), zodat randgevallen als cel `"5.0"` vs filter `5` en leading zeros
  correct matchen. Client-mode-only; het server-/contract-pad was al correct.
  Tests: `src/utils/filter-utils.value-shape.test.ts` +
  `src/utils/filter-utils.test.ts`.
- **TD-7** — Header-sort was muis-only: de sort-actie zat op een `<div>`
  zonder `tabIndex`/`role`, dus keyboard-only-gebruikers konden een kolom
  niet sorteren. De label is nu keyboard-bereikbaar (`role="button"` +
  `tabIndex` + Enter/Space-handler), naast de al bestaande `aria-sort`.
  Test: `e2e/header-sort-keyboard.spec.ts`.
- **TD-8** — Drempel-stijl numerieke kolommen (score, bedrag, leeftijd,
  percentage) hadden geen manier om standaard op ">=" te filteren — een
  gebruiker die "4" typte kreeg exact-match. Opgelost met een nieuw
  `threshold` column-type-preset in `getDefaultColumnTypes` (een `number`-
  variant met `defaultFilterOperator: 'greaterThanOrEqual'`). Het `number`-
  preset blijft ongewijzigd op exact-match. Patch, geen minor:
  `getDefaultColumnTypes` is een `@internal` Geneax-helper buiten de externe
  semver-surface, en `defaultFilterOperator` bestond al als publiek veld.
  Test: `src/config/column-type-presets.threshold.test.ts`.

### Notes

- Provenance + de drift-correctie op de stale TD-9-regelnummers uit de oude
  ROADMAP-tekst: zie de gecombineerde entry in DONE.md.

---

## \[4.1.0\] — 2026-05-25 — Filter-correctness sweep + resize-scroll root-fix

Minor release. Bundles four related changes that landed in one
investigation arc: a UX-coherence improvement on right-aligned columns,
two filter-correctness fixes from **different bug classes**, and a
root-cause fix for a scroll-position regression on resize-handle
mousedown.

Two notes for later readers, made explicit so the arc is reconstructable:

1. **The two filter-bug fixes pak verschillende klassen aan.** Variant 3
   (DMMF `filterType`-merge + `Number`-cast) is a **value-shape** fix:
   a string-typed value flowing through an operator that expects a
   number. The type-bound operator-defaults aanscherping is an
   **operator-availability** fix: an operator offered by the UI on a
   type where it is semantically meaningless or unsafe. Both surfaced
   as "filter werkt niet op number-kolom" but require different
   solutions — see TD-10 in `docs/ROADMAP.md` for the systematic
   value-shape-audit that catches the remaining members of variant 3's
   class.
2. **TD-12's fix neemt de root weg, geen symptoom-herstel.** Five
   iterations of runtime instrumentation eliminated four plausible
   mechanisms before pinning the actual root: `--grid-total-width` was
   being computed from `Object.values(widths).reduce(sum)` inside
   `writeCssVarsToContainers`, and the live-resize-subscriber's
   partial single-column write made that sum collapse to one column's
   width — triggering a CSS-cascade min-width collapse that shrunk the
   body content below viewport-width and clamped scrollLeft to 0. The
   fix decouples `--grid-total-width` from the input widths-map and
   reads it from `grid.getFlatHeaders()` so partial writes can no
   longer corrupt the total. No RAF-save-restore, no FIXME pleister.

### Header-flip + filter-row flip — shared `getColumnControlPosition` helper

Right-aligned columns now have their header-menu trigger AND their
filter-row icon on the **left** of the cell, with the column label /
filter input flowing into the cell to the right. The drag-grip stays
on the constant left position regardless of alignment. Before this
release the header menu followed `headerAlign` but the filter row
always rendered controls on the right, breaking visual coherence on
right-aligned numeric columns.

- New helper `getColumnControlPosition(meta)` in
  `utils/control-position.ts` — pure function that returns `'left'`
  or `'right'` based on `headerAlign`. Single source of truth for
  every control-positioning decision in the header and filter row.
- `header-cell.tsx` uses it to flip the menu-trigger position.
- All five inline filter components (`text-filter.tsx`,
  `number-filter.tsx`, `select-filter.tsx`, `date-filter.tsx`,
  `boolean-filter.tsx`) consume it via `flex-row-reverse` on their
  outer container — keeps DOM-order stable (so tab-order and
  accessibility don't drift) while flipping visual order.
- Tab-order intentionally preserved as DOM-order — flipping is purely
  visual via Tailwind `flex-row-reverse`. This matches how a sighted
  user reads the cell (label → icon for left-aligned, icon → label
  for right-aligned) without disturbing keyboard navigation.

### Variant 3 — DMMF `filterType`-merge in resolver + `Number`-cast in `NumberFilter` (value-shape class)

The "spam-score-filter laadfout" bug: typing a value into a numeric
column's inline filter triggered a Prisma type-validation error
("Argument `equals`: Got invalid value `"5"`. Reason: Provided value
is string, but the field is of type Float"). Root: the wire-contract
filter mapping (built via `scalarField()` / `fieldFilter(path)`)
omitted `filterType`, the server's `inferFilterType` fallback
misclassified `{operator: 'equals', value: '5'}` as `'text'` (because
the value is a string), and `buildTextCondition` produced a
`{equals: '5', mode: 'insensitive'}` Prisma fragment that fails type
validation on a `Float` column.

Two-layer fix (defense-in-depth on the same value-shape mismatch):

- **`server/resolve-column-contract.ts`** — new `resolveFilterSubKey`
  helper with a **targeted property-merge**: when both wire and DMMF
  declare a `filter:field` mapping, properties that DMMF supplies but
  wire does not are inherited from DMMF onto wire. Currently
  inherited: `filterType`, `nullable`. The merge fires only when the
  `_kind` matches — cross-`_kind` merges (e.g. wire `filter:multiField`

  - DMMF `filter:field`) are rejected to avoid incoherent objects.
    Consumer-explicit values still win.
- **`components/filters/number-filter.tsx`** — strict `Number()`-cast
  on the inline-input's `debouncedValue` before `setFilterValue`.
  `Number()` was chosen over `parseFloat()` because the former rejects
  `"5abc"` (yields `NaN`) while the latter would silently produce `5`.
  The cast plus a NaN-guard ensures the filter payload's `value` is
  always a real number, not a string-that-looks-like-one. Range mode's
  `valueTo` gets the same treatment.

Either layer alone is sufficient for the spamScore case; both are
present so future filter components that don't cast at the source
still produce correct queries via the resolver-merge, and future
consumers using `scalarField()` without options still get type-safe
queries because the resolver supplies the DMMF type.

### Type-bound operator-defaults aanscherping (operator-availability class)

The "isEmpty-laadfout" bug: clicking the `isEmpty` operator in a
numeric column's filter popover crashed Prisma with "Provided value
is null, but the field is not nullable" on a `Float @default(0)`
column. Aggregate Prisma-schema-meting (n=950 fields across four
databases): `Float` and `Boolean` are 0% nullable in our schemas;
`Int` 11%, `DateTime` 8%, `Decimal` 18%. Most columns where the UI
offered `isEmpty/isNotEmpty` had no semantically meaningful "empty"
state to query — the operator was an attractive nuisance.

Tightening (the largest visible part):

- **`types/filters.ts`** — `isEmpty`/`isNotEmpty` removed from the
  default operator-lists for `getNumberOperators` (9→7),
  `getDateOperators` (6→4), and `getBooleanOperators` (3→1). Kept in
  `getTextOperators` (text columns *do* have a meaningful empty state
  — `null` and `""` — and the server-shape handles both since this
  release). Module-level JSDoc documents the rationale.

New API surface and infrastructure:

- **`config/operator-presets.ts`** — new `withEmpty` preset for the
  rare nullable number/date column where `isEmpty` IS meaningful.
  Consumers opt-in per-column via `meta.operatorPreset: 'withEmpty'`.
- **`types/server-contract.ts`** — `FilterFieldMapping.nullable?:
  boolean` field added. Used by the server-side condition builder to
  pick the right Prisma shape per nullability.
- **`server/dmmf-defaults.ts`** — `DmmfFieldMeta.isRequired?:
  boolean` field added. `generateDmmfDefaults` populates
  `FilterFieldMapping.nullable = !isRequired` automatically.
- **`server/resolve-column-contract.ts`** — `resolveFilterSubKey`
  (added in Variant 3 above) also inherits `nullable` from DMMF to
  wire.

Server-side condition shapes per `(filterType × nullable)`:

| Case | Prisma shape for isEmpty |
| --- | --- |
| nullable text | top-level `AND: [{OR: [{path: {equals: null}}, {path: {equals: ''}}]}]` |
| nullable text + isNotEmpty | top-level `AND: [{AND: [{path: {not: null}}, {path: {not: ''}}]}]` |
| non-nullable text | flat `{path: {equals: ''}}` (only `""` can be empty for a required `String`) |
| nullable other (date/number/boolean) | flat `{path: {equals: null}}` / `{not: null}` |
| non-nullable other (date/number/boolean) | dropped + `warnStripped` (defense-in-depth, see below) |

Defense-in-depth: on a non-nullable non-string column, an `isEmpty`
filter that somehow arrives at the server (URL-state manipulation, a
persisted older grid-state) is silently dropped with a dev-warn. The
UI no longer offers the operator on those types since this release,
so this path only fires on edge cases. No Prisma error, no silent
unfiltered dataset (the operator is already invisible in the popover).

This bug-class is distinct from Variant 3's value-shape class: here
the operator never should have been offered on the column type, full
stop. TD-10 covers the systematic value-shape audit for variant 3's
class — different class, different fix.

### TD-12 — `--grid-total-width` decoupled from partial widths-map (resize-scroll regression)

When mouseclicking a resize-handle on a column that had only become
visible via horizontal scroll, the body's `scrollLeft` would clamp to
`0` for one frame, undoing the user's scroll-position. Mechanism (only
nailed after five iterations of runtime instrumentation):

1. Mousedown → engine sets `columnResizing.isResizingColumn = X`
2. React commit → `useFlexLayout`'s live-resize-subscriber
   (`use-flex-layout.ts:296-355`) registers a store-subscribe
3. That subscriber writes `{[X]: width}` (single-entry map) to
   `writeCssVarsToContainers` on every store-change
4. **Inside `writeCssVarsToContainers`, `--grid-total-width` was
   computed as `Object.values(widths).reduce(sum)` = the resizing
   column's width alone** (~186px for an Ontvangen-column)
5. CSS-cascade `min-width: max(100%, calc(var(--grid-total-width) *
   1px))` evaluates to `max(viewport, 186px)` = `viewport`, the
   body's content wrapper shrinks to viewport-width, the row gets
   clipped, browser sees content fits viewport, clamps `scrollLeft`
   to 0

Root-fix (`utils/css-var-utils.ts`): `--grid-total-width` is now
computed from `grid.getFlatHeaders().reduce((sum, h) => sum +
h.getSize(), 0)` — the same source `useColumnSizingVars` uses for the
outer grid div's inline-style total. Partial widths-maps (live-resize
single-column writes; main-effect's resize-start `writeWidths` that
excludes the resizing column) **can no longer corrupt the total**.

`CssVarGrid` interface extended with `getFlatHeaders` to match the
new computation. Existing tests that pinned the old buggy semantic
(`"Total width reflects only the widths in the map"`) inverted to
assert the new invariant — partial maps produce the full grid-state
total.

Symmetric latent bug fixed in the same wijziging: the main-effect's
`writeWidths` filter excludes the resizing column on resize-start to
avoid clobbering the live-subscriber's mid-drag writes. Pre-fix, that
filtered map produced `--grid-total-width = 1458 − resizingColWidth`
(≈ 1272) — a different magnitude of the same class. The single
decouple-from-input fix covers both paths.

Verified live in the running app post-fix:
`--grid-total-width` stays at the full grid total on every frame
sampled across the mousedown → mouseup window; scrollLeft stays at
its pre-mousedown value.

### Tests

- `utils/css-var-utils.test.ts` — 4 existing assertions inverted to
  the new invariant; 2 new tests cover the live-resize-subscriber's
  single-column write and the main-effect's `writeWidths`-filter
  partial-map case
- `types/filters.test.ts` — 4 length-counts adjusted (number 9→7,
  date 6→4, boolean 3→1); 4 new tests assert `isEmpty/isNotEmpty`
  absent from non-text defaults, present in text defaults
- `config/operator-presets.test.ts` — new test for the `withEmpty`
  preset
- `server/dmmf-defaults.test.ts` — 2 new tests cover `nullable`
  propagation from `isRequired` and the undefined-fallback for older
  adapters
- `server/resolve-column-contract.test.ts` — 5 new tests cover the
  `filterType` and `nullable` inheritance from DMMF, consumer-override
  precedence, and cross-`_kind` rejection
- `utils/query-builder-contract.test.ts` — 7 new tests cover all
  `(filterType × nullable)` combinations for `isEmpty`/`isNotEmpty`,
  plus combination with value-based filters on other columns, plus
  the end-to-end value-shape-safety net (numeric `equals` doesn't
  fall back to `text`)
- `utils/control-position.test.ts` — 5 new tests for the shared
  helper (added in the header/filter-flip work)
- `e2e/resize-handle-scroll-preservation.spec.ts` — new Playwright
  spec, two tests: scrollLeft-invariant on mousedown after horizontal
  scroll, and `--grid-total-width` invariant across 8 sampled frames

Full suite: 1339 / 1339 passing (was 1337 before this release).

### Migration notes

**No breaking changes for typical consumers.** All four wijzigingen
are internal or additive:

- `isEmpty/isNotEmpty` removed from default number/date/boolean
  operator-lists — no consumer in this monorepo used those defaults
  directly (verified via `grep NUMBER_OPERATORS|getNumberOperators`
  across `apps/`). External consumers relying on these defaults can
  opt back in via `meta.operatorPreset: 'withEmpty'`.
- `meta.nullable` on `FilterFieldMapping` is optional and consumers
  who omit it get pre-1.4.0 behavior (treated as nullable for
  shape-building).
- `getFlatHeaders` added to `CssVarGrid` interface — this is the
  minimal structural type used by `writeCssVarsToContainers`, not a
  consumer-exported type.

### Backlog niet meegenomen in deze tag

- **TD-7** — Header-sort keyboard-toegankelijkheid (div mist
  tabIndex/role); separate a11y work
- **TD-8** — `NumberFilter` default-operator UX (drempel-operator
  overwegen); UX-keuze per kolom of preset
- **TD-9** — Client-side `evaluateFilter` doet tekstvergelijking op
  numerieke kolommen voor `equals`/`notEquals`
- **TD-10** — Value-shape audit per operator×type — systematisch
  dekkingsonderzoek voor de variant-3-klasse
- **TD-11** — Laatste kolom mist resize-handle aan de buitenrand

> **Update (v4.1.1):** TD-7, TD-8 en TD-9 zijn alsnog afgehecht — zie de
> \[4.1.1\]-entry bovenaan. TD-10 en TD-11 blijven open.

Zie `docs/ROADMAP.md` voor de actuele backlog.

---

## \[4.0.3\] — 2026-05-22 — Low-effort cleanup sweep (traycer-v2 Phase 2 — closeout)

Patch release. Closes the remaining low-effort items from the traycer-v2
audit: TD-6 (`t?.()` audit), I-15 (FilterFieldConfig JSDoc clarity),
I-18 (GridColumnMeta grouping), and I-19 (server-mode.md drift). All
four are documentation, JSDoc, and a small internal i18n-wiring fix —
no runtime API changes, no external-consumer impact.

### TD-6 — Filter-builder switched to `useGridTranslation()` from prop-drilling

Filter-builder components consumed translations via a `t?: GridTranslateFunction`
prop. With the v4.0 i18n-coverage fix (41 keys added to `nl/grid.json`

- `en/grid.json`) the wrapper-`useGridTranslation()` always returns a
  string (real translation or English defaults.ts fallback), so the
  inline `?? "English"` fallbacks were redundant defensive code.

Switched all three filter-builder components to consume translation
via the context-hook directly:

- **`FilterBuilderPanel`** — `t` prop removed, `useGridTranslation()`
  used internally. 5 inline `?? "English"` fallbacks removed.
- **`FilterConditionRow`** — same pattern. 4 fallbacks removed.
- **`FilterGroupRenderer`** — same pattern. 6 fallbacks removed.
- **`<ActiveGrid>`** — `t={props.t}` removed from the `FilterBuilderPanel`
  call-site in `grid.tsx`.

Filter-builder components are internal (not exported via main barrel)
— this is a pure refactor with no external API surface impact.

### I-15 — `FilterFieldConfig` / `FilterFieldConfigMap` JSDoc clarified

The original JSDoc referenced `toPrismaWhereFromFilters` — a function
removed in v4.0.0 — and didn't distinguish between the **filter-builder
expression path** (which uses these types) and the **contract-aware
grid path** (which doesn't). External adopters reading the type-hover
would not know which paradigm applies. Updated to:

- Explicitly state the type belongs to the filter-builder expression
  path (`toPrismaWhereFromExpression`).
- Cross-link to ADR-013 and `migration/v3-to-v4.md` §2 for the
  contract-aware path.
- Explain the side-by-side coexistence of the two filter-configuration
  paradigms and which to use when.

### I-18 — `GridColumnMeta` JSDoc-grouping introduced

The type had ~50 fields with inconsistent inline-comments and no formal
grouping. Added an 8-category JSDoc summary at the top of the
interface plus section-marker comments between field-groups:

1. Display & alignment
2. Filtering (filterType, customFilter, operator config, filterOptions,
   date-filter config, suppressFilterIcon — previously scattered across
   three locations)
3. Editing
4. Aggregation
5. Cell formatting & tooltips
6. Layout
7. Header customization
8. System

Within these groups, all fields stay where they were — no field-order
changes, no semantic changes, only comment-delineation. Note: a few
filter-related fields previously sat outside the "Enterprise Filter
Configuration" comment (date-filter config in particular) — they've
been moved adjacent to the other Filtering fields for consistency.

### I-19 — `docs/guides/server-mode.md` drift sweep

Two findings:

- **`### toPrismaWhere` section** documented a function removed in
  v4.0. Replaced with a short note explaining the removal and
  redirecting to the contract-aware path + migration guide.
- **No documentation of `groupBy + aggregate` helpers** — symmetric
  gap after fase-3. The guide covered sort/filter/global but not the
  v4.0 additions. Added a new `## Server-side grouping (groupBy +
  aggregate)` section between Global search and Debouncing, with:

  - Intro explaining the automatic factory branching (group-level vs.
    leaf fetch).
  - Helpers-per-sub-key table covering `groupByField`,
    `groupByRelation` (with reserved-status note), `aggregateScalar`,
    `aggregateCount`, and the raw variants on `/server`.
  - Example illustrating combined `groupBy + aggregate` on a numeric
    column (`salary`).
  - Fail-closed + reserved policy summary: `groupBy` instruction
    without mapping → `warnStripped`; `groupByRelation()` mapping →
    `warnReserved`; aggregate has no DMMF defaults (S3-asymmetry).
  - Cross-links to ADR-017 and migration/v3-to-v4 §1 / §3.

### Verification

- `pnpm --filter @workspace/active-grid test` — **1312/1312 passing**
  (unchanged — pure refactor + JSDoc + docs).
- `pnpm --filter @workspace/active-grid typecheck` — clean.

### ROADMAP follow-up

TD-6 is closed by this release. The remaining traycer-v2 items
(I-15/I-16/I-17/I-18/I-19 didn't have their own ROADMAP entries —
they were closed inline as part of this sweep where appropriate).

Resterende ROADMAP after this release: R-3, R-6, R-24, R-26, TD-1,
TD-2. All trigger-driven; none urgent.

---

## \[4.0.2\] — 2026-05-22 — Layering-fix + group-by branch extraction (traycer-v2 Phase 2)

Patch release. Closes the medium-priority architecture and complexity
findings from traycer-v2 (R-25 + TD-5). Two separate kinds of change,
done in sequence so any regression can be attributed to the right
half: R-25 is a pure relocation (no semantic change), TD-5 is an
extraction (no behavior change). Together they tidy up the
`src/server/` cluster ahead of an upcoming request that will touch the
group-by branch.

### R-25 — `utils/` → `server/` import inversion resolved

Layering violation identified by traycer-v2 I-07: `utils/query-builder.ts`
imported `warnStripped` + `warnReserved` from
`server/resolve-column-contract.ts`. `utils/` should not depend on
`server/`. Closer inspection revealed the warning helpers were never
called from `resolve-column-contract.ts` itself — only from
`query-builder.ts` (5 call-sites) — so the helpers logically belonged
in `utils/` from the start.

**Changes:**

- **New file** `src/utils/contract-warnings.ts` — owns the six
  warning-helper symbols (`warnStripped`, `warnReserved`,
  `_resetWarnedKeysForTesting`, `_resetWarnedReservedKeysForTesting`,
  plus the two private `Set<string>` warning-memo registers).
- **`server/resolve-column-contract.ts`** — warning section deleted;
  replaced with a short comment noting the move. JSDoc cross-link to
  `warnStripped` repointed to the new location.
- **`utils/query-builder.ts:3`** — import path changed from
  `"../server/resolve-column-contract"` to `"./contract-warnings"`.
- **`server/index.ts`** — re-exports of the four public helpers
  (`warnReserved`, `warnStripped`, both `_reset*` test-helpers) now
  re-export from `../utils/contract-warnings`. **Public `/server` API
  surface is unchanged** — consumers importing from
  `@workspace/active-grid/server` see no difference.
- **Test imports** — `server/resolve-column-contract.test.ts` and
  `utils/query-builder-contract.test.ts` updated to import from the
  new location. Cross-folder test-import (server-test → utils-source)
  is acceptable per traycer-v2 analysis — tests are not a runtime
  layering concern.

### TD-5 — `createGridServerAction` group-by branch extracted

Per traycer-v2 I-14: the v4.0 group-by/aggregate branch in
`create-grid-server-action.ts` was ~173 lines inside an already-large
factory (~670 lines total). Extracted to a dedicated file with two
small private helpers, ahead of an upcoming request that will touch
this area. Pure refactor — no behavior change.

**Changes:**

- **New file** `src/server/group-by-handler.ts` (~230 lines, internal
  module — not exported from `/server`) with three symbols:

  - **`handleGroupByRequest(args)`** — top-level handler that branches
    between leaf-fetch (`groupKeys.length >= serverGroupBy.length` →
    `findMany`) and group-level-fetch (`prismaModel.groupBy`). Accepts
    an object-style parameter for self-documenting call-sites.
  - **`buildGroupKeyWhere(serverGroupBy, groupKeys, contractMap)`** —
    pure helper that builds the WHERE-fragment from already-drilled
    group-keys. Skips relation/raw mappings (translator already warned).
  - **`translateGroupResults(rawGroups, byField, drillDownLevel, totalLevels)`**
    — pure helper that translates Prisma's `groupBy` raw output into the
    `{data, groups}` shape that `useServerGrouping` consumes. Handles
    Prisma's three `_count` shapes (`number`, `{_all}`, `{field}`),
    flattens `_sum`/`_avg`/`_min`/`_max` blocks into camelCase
    aggregation keys, and computes `hasSubGroups` from drill-down
    depth.
- **`create-grid-server-action.ts`** — the inline 173-line branch is
  replaced by a single `handleGroupByRequest({...})` call wrapped in
  the existing `success: true` envelope. Imports `handleGroupByRequest`
  from the new module.
- **New tests** — `src/server/group-by-handler.test.ts` (27 unit
  tests) pinning the current correct behavior of `translateGroupResults`
  and `buildGroupKeyWhere`. Three describe-blocks: count-extraction
  (5 cases), aggregations-flatten (6 cases), hasSubGroups invariant
  (4 cases), data-and-key preservation (4 cases), and
  `buildGroupKeyWhere` end-to-end (8 cases). Pinning-purpose: lock the
  current correct behavior **before** the upcoming request touches
  this area. The factory-level integration tests in
  `create-grid-server-action.test.ts` are unchanged.

**Why both in one patch:** R-25 and TD-5 both touch the `src/server/`
cluster. Doing them in one commit-context kept the test-cycle and
review-context unified. R-25 was applied first (smallest blast-radius
— pure file move), validated, then TD-5 (extraction + new tests),
validated. Either change can be attributed individually by looking at
which file was touched.

### Verification

- `pnpm --filter @workspace/active-grid test` — **1312/1312 passing**
  (+27 new pinning tests for the group-by handler).
- `pnpm --filter @workspace/active-grid typecheck` — clean.
- `pnpm typecheck` (monorepo) — 38/38 packages clean.

### Cross-link

Both were identified as Phase 2 items by the Traycer-v2 audit of 2026-05-22,
§§ I-07 (R-25) and I-14 (TD-5); that transcript has since been removed. The aankomende request that TD-5 prepares
for is not part of this release — TD-5 ships as pure preparation, with
the pinning-tests as a safety net for the upcoming work.

---

## \[4.0.1\] — 2026-05-22 — Doc-drift sweep + dead-code removal (traycer-v2 Phase 1)

Patch release. Closes the high-priority doc-drift findings from traycer-v2
(the audit run immediately after v4.0.0). No runtime changes; all fixes
are documentation, JSDoc comments, dead-code removal, and dead-link
repairs.

### Removed — `calculateAutoSize` + `autoSizeAllColumns` (deprecated standalone functions)

The two `@deprecated`-marked standalone functions in
`src/utils/auto-size-utils.ts` had zero callers in Geneax (verified via
grep). The `useAutoSize` hook is the canonical replacement. Per §7.14
(no backwards-compat cruft), `@deprecated`-markers should not exist;
since these functions had no consumers, they have been **removed** in
the same commit (file deleted, barrel-export removed). The `useAutoSize`-
hook's `autoSizeAllColumns` method (different code-path; runtime-method
on the hook return-value) is unchanged.

### Removed — `isCacheHit` alias in `use-data-orchestrator.ts`

Half-removed refactor identified by traycer-v2. The `isCacheHit`-alias
was identical to `isLiveCacheHit` (`const isCacheHit = isLiveCacheHit;`)
and its comment `"Kept for naming-compatibility with downstream effects"`
described a refactor-stage that had moved on. All three downstream uses
(prefetch-adjacent effect, fetch-trigger effect, related JSDoc) are now
updated to `isLiveCacheHit`. Net: -3 lines, +0 behavior changes.

### Changed — stale phase-reservation comments brought up to v4.0

Five comment-blocks in `src/types/server-contract.ts` and one JSDoc-
block in `src/types/columns.ts` still described the schema as "phase 1
active, phase 2/3 reserved" — a v3.0.0 truth. With v4.0 all five sub-
keys are runtime-active. Comments updated:

- Top-level JSDoc of `ServerColumnContract` now states "all runtime-
  active since v4.0.0" with the phase-→version mapping.
- Per-field JSDocs of `global` / `groupBy` / `aggregate` now state
  "Runtime-active since v3.1.0" / "v4.0.0" instead of "Reserved for
  phase 2/3".
- Section headers in `server-contract.ts` say "runtime-active since
  vX.X.X (ADR-013/017)" instead of "phase 1 active" / "reserved for
  phase 2/3".
- `GridColumnDef.server` JSDoc in `columns.ts` updated symmetrically
  with the same phase-→version mapping plus cross-link to
  `ARCHITECTURE.md`'s server-side query-contract section.

### Changed — dead-link sweep for the removed `docs/internal/` folder

The v4.0.0 docs cleanup removed `docs/internal/rfc-server-query-contract.md`

- four other working documents (essence absorbed into ADRs and DONE.md)
  and moved `docs/internal/escape-hatch-patterns.md` to
  `docs/contributing/`. Cross-references in **source code** were not
  updated at that time; traycer-v2 caught it. Now updated:

- **5 references to the removed RFC** (`src/index.ts`,
  `src/server/resolve-column-contract.ts`, `src/server/dmmf-defaults.ts`,
  `src/server/create-grid-server-action.ts` twice) repointed to
  `docs/DECISIONS.md` ADR-013 / `docs/ARCHITECTURE.md` server-contract
  section.
- **8 references to the moved escape-hatch-patterns** (`src/types/columns.ts`,
  `src/engine/features/column-faceting/columnFacetingFeature.types.ts`,
  `src/engine/fns/filterFns.ts`, `src/engine/fns/sortFns.ts`,
  `src/engine/types/ColumnDef.ts`, `src/engine/types/Grid.ts`,
  `src/engine/types/GridOptions.ts`, `src/engine/types/type-utils.ts`,
  `src/react/index.ts`) updated to point to `docs/contributing/`.
- **Cross-package dead links** in `R14-R21-RELEASE.md` (repo root) and
  `docs/adrs/ADR-005-active-grid-library-style-react-19-patterns.md`
  (root-level cross-package ADR) updated to reflect the absorption of
  internal working documents into `DONE.md` § R-14 + R-21.

### Changed — `docs/INDEX.md` baseline + ADR count + ROADMAP description

- Baseline: `v3.1.0 — server-side query-contract fase 1 + 2` →
  `v4.0.1 — server-side query-contract compleet (alle vijf sub-keys
  runtime-actief)`.
- ADR count: `12 ADRs` → `18 ADRs`.
- ROADMAP description: `R-2 t/m R-15` → `R-3/R-6/R-24/R-25/R-26 + TD-1/
  TD-2/TD-5/TD-6` (the actual current set after v4.0's ROADMAP cleanup
  and v4.0.1's traycer-v2 additions).
- Migration table extended with `migration/v3-to-v4.md` (added in
  v4.0.0 but never linked from INDEX.md).

### Changed — `docs/FUNCTIONEEL-OVERZICHT.md` v4.0-alignment

- `sonner` removed from the external runtime-dependencies list
  (`sonner` was actually removed in v2.0.0, but FUNCTIONEEL-OVERZICHT
  still listed it).
- AG Grid comparison table extended with a row for the schema-aware
  per-column server-query-contract (Active Grid's strongest server-
  side differentiator vs. AG Grid Community / Enterprise).

### Documented — `GroupAggregation` / `ServerFetchParams.groupAggregations` migration

Both type definitions now carry inline JSDoc explaining that
`active-grid` itself stopped populating `groupAggregations` on the wire
in v4.0 — the field is retained for backward-compat with custom (non-
factory) `fetchFn` implementations. Per §7.14, **no `@deprecated`
marker** was added; the migration path is documented inline with a
cross-link to `docs/migration/v3-to-v4.md` §5.

### New ROADMAP items (from traycer-v2)

Four new items were added to `docs/ROADMAP.md`:

- **R-25** 🟡 — Architectuur: `utils/` → `server/` import-inversie
  oplossen. Move `warnStripped` / `warnReserved` to `utils/contract-
  warnings.ts`. Trigger: volgende contract-werk-ronde.
- **R-26** 🟢 — Benchmark-uitbreiding: contract-resolver overhead +
  server-grouped + wide-grid scenarios. Trigger: pre-v4.1-release of
  performance-claim.
- **TD-5** 🟢 — `createGridServerAction` group-by-branch extractie
  (~170-regel branch naar `handleGroupByRequest`-helper). Trigger:
  volgende wijziging in het bestand.
- **TD-6** 🟢 — `t?.()` fallback-pattern audit + selectieve upgrade
  na de v4.0 i18n-coverage-fix. Trigger: i18n-cleanup-ronde.

### Verification

- `pnpm --filter @workspace/active-grid test` — **1285/1285 passing**
  (unchanged).
- `pnpm --filter @workspace/active-grid typecheck` — clean.

### Cross-link

This release closes findings from the Traycer-v2 audit of 2026-05-22. That
transcript was temporary material and has since been removed; what it found
lives in the entries above and in `ROADMAP.md` / `DONE.md`.

---

## \[4.0.0\] — 2026-05-22 — Server-side query-contract fase 3 (group-by + aggregaten) + legacy-removal (ADR-017)

Major release. Completes the server-side query-contract: all five
sub-keys (`sort`, `filter`, `global`, `groupBy`, `aggregate`) are now
runtime-active. Removes the three legacy query-builder functions that
were preserved since v3.0.0 "tot R-23 het opruimt". Migration guide:
`docs/migration/v3-to-v4.md` (removed in 2026-09; the helpers it described are listed in `api/server-contract.md`).

### Added — group-by + aggregate helpers (phase 3)

- **Main barrel**: `groupByField(path?)`, `groupByRelation({relation, path})`,
  `aggregateScalar({path?, ops: AggregateOp[]})`,
  `aggregateCount({relation?})`. Pure object-builders without Prisma
  imports (bundle-grens-discipline).
- **`/server` subpath**: `rawGroupBy(build)`, `rawAggregate(build)` —
  server-only escape-hatches for cases the declarative shapes do not
  cover (one-to-many grouping, computed aggregates via $queryRaw).

### Added — query-builder contract-aware variants

- **`queryBuilder.toPrismaGroupByFromContract(serverGroupBy, contractMap, options)`**
  — produces Prisma `groupBy.by: string[]` from active runtime selection

  - resolved contract. Supports `GroupByFieldMapping` and
    `RawGroupByMapping` with `{type: "field", path}`. **`GroupByRelationMapping`
    is reserved** (see "T3 correction" below). `RawGroupByMapping` with
    `{type: "expr", expr}` is also reserved.
- **`queryBuilder.toPrismaAggregateFromContract(contractMap, options)`** —
  walks every column with a non-`false` `aggregate` mapping and builds
  `_count` / `_sum` / `_avg` / `_min` / `_max` blocks. Under single-
  source-of-truth (B-keuze), no separate runtime selection — every
  declared aggregate is included.

### Changed — `createGridServerAction` (factory)

- **`GridServerActionRequest`** additively extended with `groupBy?: GroupByColumn[]`
  and `groupKeys?: unknown[]`. Pre-existing flat-mode behaviour unchanged
  when both are empty/absent — strict backward-compatibility for the
  flat-fetch path. The wire-protocol extension is symmetric with how
  fase 1 added `columnContract` and fase 2 routed `globalFilter`.
- **`GridServerActionResponse`** additively extended with `isGrouped?: boolean`
  and `groups?: GroupInfo[]`. Pre-v4.0 flat-mode responses keep both
  absent.
- **`PrismaModelDelegate`** structural type extended with optional
  `groupBy?: (args) => Promise<unknown[]>`. Pre-v4.0 contractual fields
  (`findMany`, `count`) unchanged.
- **Factory branches on `request.groupBy`**: when present and non-empty,
  the factory enters the group-by-or-leaf path. Two sub-cases:
  `groupKeys.length < groupBy.length` → group-level fetch via
  `prismaModel.groupBy({by, where, orderBy?, _count, _sum, ...})`;
  `groupKeys.length >= groupBy.length` → leaf-level `findMany` with
  group-key WHERE applied alongside the column-filter / globalFilter
  conditions. Sort-within-group uses the existing
  `toPrismaOrderByFromContract`-translation of `state.sorting`
  (Q1 verification: implementation is exactly "goedkoop bovenop"
  per the stop-moment-1 inschatting).

### Changed — DMMF defaults (S3 asymmetry)

`generateDmmfDefaults` now adds `groupBy: groupByField(path)` to every
scalar default mapping. **Aggregate is intentionally NOT auto-defaulted**
(see ADR-013 phase-3-extension). Auto-aggregate
over numeric scalars is semantically dangerous; consumer must declare
aggregate explicitly per column. The test `does NOT generate aggregate
defaults (S3 asymmetry)` pins this so a well-intentioned "cleanup"
doesn't later add aggregate-defaults.

### Changed — `loadInitialGridState` (TD-3 contract-aware pruning)

Hydration's stale-state pruning now accepts `wireContract?: Record<string, ServerColumnContract>`
and `mode?: "client" | "server"` options. In server-mode with a
provided contract, sort/filter entries are dropped when either the
column-id doesn't exist (v3.1.2 behaviour) OR the wire-contract has
no corresponding `server.sort` / `server.filter` mapping (TD-3 new).
Symmetric across both sub-keys. Threading via `useGridSetup` →
`useGridState` → `loadInitialGridState`. Wire-contract extraction
moved to a shared `extractWireContract` helper (`utils/extract-wire-contract.ts`)
re-used by `createServerFetchFn`.

### Changed — `useServerGrouping` (B-keuze)

`groupAggregations` parameter **removed** from `UseServerGroupingProps`.
Aggregates are now derived from `column.server.aggregate` by
`createGridServerAction`, not transmitted as a separate wire payload.
`ServerFetchParams.groupAggregations` field remains in the type (no
shape-removal) for backward-compat, but `useServerGrouping` no longer
sets it. Custom (non-factory) fetchFns that read the field receive
`undefined` from v4.0 onwards — see migration guide.

### Changed — `<ActiveGrid>` API

`serverGroupAggregations?: GroupAggregation[]` prop **removed**.
Consumer migration: per-column `server.aggregate = aggregateScalar({ops: [...]})`
declarations. See migration guide §4.

### Changed — T3 correction (groupByRelation reserved, not active)

Plan-time recommendation T3 ("1:1 declaratief via `groupByRelation()`")
discovered at stap-B implementatie not to be runtime-implementable:
Prisma's `groupBy.by` accepts only scalar field-names on the model;
`GroupByRelationMapping`'s `{relation, path}`-shape carries nowhere
the FK-scalar Prisma needs. Three options considered, **optie I
chosen**: `GroupByRelationMapping` stays as a reserved type (C3-belofte
honoured; no schema-change), but is runtime fail-closed with a
`warnReserved` dev-warning. Consumer migration for 1:1 grouping:
`groupByField("<fkScalar>")` directly (the FK column on the host
model). For one-to-many: `rawGroupBy()` with explicit Prisma fragment.
The type stays reserved for a future phase with DMMF-FK-inference;
see migration guide §3.

### Removed — legacy query-builder functions

The three functions preserved since v3.0.0 "tot R-23 het opruimt":

- **`queryBuilder.toPrismaGroupBy`** — replaced by
  `createGridServerAction`'s internal flow + `toPrismaGroupByFromContract` +
  `toPrismaAggregateFromContract`.
- **`queryBuilder.toPrismaWhereFromFilters`** — replaced by
  `toPrismaWhereFromContract`.
- **`queryBuilder.toPrismaOrderBy`** — replaced by
  `toPrismaOrderByFromContract`.
- **`queryBuilder.toPrismaWhere`** — internal helper that only the three
  legacy functions used.
- **`handleSelectFilter`** — internal helper that only `toPrismaWhereFromFilters` used.

**Breaking** for external adopters that call any of the three directly.
Geneax-internal has zero callers (verified pre-removal). Migration recipe
in `docs/migration/v3-to-v4.md` §2.

### Added — `warnReserved` dev-warning

New companion to `warnStripped` in `server/resolve-column-contract.ts`.
Fires once-per-(model, column, capability, reservedKind) when a
contract mapping is declared but the current runtime phase doesn't
activate it (the canonical v4.0 case being `GroupByRelationMapping`).
Distinct from `warnStripped` ("no mapping declared at any layer")
because the consumer DID declare something — the warning needs to
explain reservation, not absence. `warnStripped`'s capability-union
widened from `"sort" | "filter"` to also include
`"global" | "groupBy" | "aggregate"`.

### Tests

- **+12** in `helpers/server-contract.test.ts` (groupBy + aggregate
  helper-builders).
- **+19** in `query-builder-contract.test.ts` (toPrismaGroupByFromContract

  - toPrismaAggregateFromContract).
- **+2** in `dmmf-defaults.test.ts` (groupBy defaults; aggregate S3 pin).
- **+8** in `server/create-grid-server-action.test.ts` (factory branches:
  top-level/leaf/intermediate groupBy, Q1 sort-within-group + filter-
  under-group, empty-groupBy fall-through, fail-closed group-mapping).
- **−54** removed for the deleted legacy functions
  (`query-builder.test.ts` and `filter-expression.test.ts`).
- **Net**: 1285 tests passing (was 1339 — the 8 net-additions are
  swamped by the 54 legacy-tests being removed; the contract-aware
  variants have richer per-mapping coverage than the legacy direct
  tests had).

### Fixed — bevinding 1 + 2 (in-fase tijdens visuele verificatie van fase 3)

Tijdens stop-moment-2 van fase 3 ontdekt: drag-to-group werkte niet
(blokker; bestaande chips wel uit te X'en), progress-bar bleef draaien
na un-group-all, en een nieuwe `Performance.measure` console-error
tijdens un-group. Diagnose-rapport bewees: latente bugs sinds v3.1.3
(ADR-014), niet v4.0-regressies. De stap-F-migratie raakte geen drag-
to-group-code; de bugs zaten in ADR-014's `isServerGroupingActive`-
predicate dat naar `props.serverGroupBy` keek i.p.v. de lokale single-
source-of-truth state die ADR-014 zelf introduceerde, plus een loading-
flag-leak in `useServerGrouping`'s dataset-key-effect.

Fixes binnen v4.0.0:

- **`isServerGroupingActive` gesplitst in twee predicates** in
  `components/grid.tsx`. `isServerGroupingMode` (prop-sticky;
  consumer-intent) drijft engine-wiring decisions die consistent
  moeten blijven (`enableGrouping: false`, intercept-setup,
  `useServerGrouping(enabled)`, one-way mirror). `isServerGroupingActive`
  (state-based; afgeleid van lokale `serverGroupBy`) drijft runtime
  decisions (loading-route, data-injection, exportConfig). Sluit de
  gap die ADR-014 liet toen het de lokale state introduceerde maar
  dit ene predicate niet meenam.
- **`<GroupingToolbar>`** nieuwe optionele
  `canAcceptDrop?: (columnId: string) => boolean` prop. Default
  fall-back op `column.getCanGroup()`. `<ActiveGrid>` wires automatisch
  een server-grouping-aware predicate in server-grouping mode dat
  `column.columnDef.server?.groupBy` checkt i.p.v. het engine-
  `enableGrouping` flag (dat in mode altijd `false` is).
- **B-2 in `use-server-grouping.ts`** dataset-key-effect: vlag
  `isLoading`/`isRefetching` niet wanneer `groupBy.length === 0`
  (geen fetch firet, geen resolve om te resetten). Bij transitie naar
  empty: expliciet beide flags op `false` als defensieve maatregel.
- **Bevinding 3 (Performance.measure error)** wordt na deze fix
  herverifieerd. Aanname: secundair aan de halfgare-transitie-
  setState-storm die nu opgelost is. Als hij blijft, aparte diagnose.

### Fixed — drag-to-group source-side (ADR-018, koers-correctie na bevinding-1 visuele verificatie)

Walts visuele verificatie van de bevinding-1+2-fix toonde aan dat de
drop-acceptatie-fix correct was maar onvolledig: de header was niet
eens grijpbaar (cursor veranderde niet, geen drag-icoon, geen drag-
initiatie). Diagnose: de drag-source-machinery (`useColumnReorder.getDragHandleProps`)
bestond, maar was gegated op `enableColumnReordering` (default `false`).
Test-grids gebruiken `enableGrouping` zonder `enableColumnReordering`,
dus geen draggable headers — en de drop-zone (`GroupingToolbar`) heeft
niets om te ontvangen. Latente design-fout sinds `useColumnReorder`
toegevoegd; onopgemerkt omdat alle test-grids hun grouping via de
`serverGroupBy`-prop instelden. Volledig vastgelegd in
ADR-018.

Vier wijzigingen binnen v4.0.0:

- **`useColumnReorder` API split** `enabled` → `enableSource` (drag-
  initiatie) + `enableTarget` (drop-op-andere-header voor reorder).
  Drop-zones zijn onafhankelijk; één bron, meerdere bestemmingen die
  elk eigen acceptatie bepalen. De hook is intern (geen barrel-export,
  één caller) — geen migratie voor externe consumers.
- **`header.tsx` rendert dragProps** wanneer reorder OR grouping aan
  staat. Per-header beslissing voor `isGrabbable`: reorder ON → alle
  leaf-headers grabbable; grouping-only ON → alleen kolommen met
  `column.server.groupBy` grabbable (symmetrisch met de drop-
  acceptatie-fix; geen silentieel-rejected slepen op niet-groepbare
  kolommen).
- **`header-cell.tsx` cursor-affordance gated op `isGrabbable`**
  (per-header), niet op `enableColumnReordering` (per-grid). Grouping-
  only grids tonen nu ook `cursor-grab` op groepbare kolommen.
- **Grip-icoon (`GripVertical`) op hover** voor draggable kolommen,
  `opacity-0` → `group-hover:opacity-100`, `text-muted-foreground/50`,
  `h-3.5 w-3.5`. Walts ontdekbaarheids-punt: AG-Grid toont een grip;
  Geneax veranderde alleen de cursor. Geen UI-ruis in rust-staat,
  duidelijke affordance bij hover.

**Toekomstbestendigheid bevestigd** in ADR-018: de huidige
`enableSource`/`enableTarget`-splitsing + per-header `isGrabbable`-
beslissing zijn voldoende structurele voorbereiding voor latere
destination-disambiguation bij gelijktijdige activering van reorder +
grouping (AG-Grid-stijl). Drop-zone-zijde feedback kan additief
toegevoegd worden zonder model-herziening. Uitgesteld als
ROADMAP R-24
— trigger: publieke release of een grid die beide tegelijk aanzet.

### Fixed — i18n-coverage-gat in `nl/grid.json` + `en/grid.json` (Walts visuele observatie op /test-server-grouping)

Walt zag Engelse strings ("Filter Builder", "Grouping:", "Remove all
grouping", screen-reader-announcements, batch-toolbar, lookup-dialog)
op een grid op een systeem dat op Nederlands stond. Oorzaak: het
`nl/grid.json`-bestand had **48 keys minder** dan `defaults.ts` in
active-grid. De `useGridTranslation`-hook valt bij ontbrekende keys
terug op de Engelse defaults; vandaar het visuele effect. `en/grid.json`
had identiek dezelfde 48 gaten, maar de fallback-naar-defaults.ts is
óók Engels — visueel correct voor EN, onzichtbaar tot het op NL werd
getest.

Per-key audit toonde dat **41 keys live runtime-callers hebben** en
**7 keys 0 callers**:

- **41 live keys vertaald** in beide locale-bestanden:
  `filterBuilder.*` (12), `grouping.*` (8), `announce.*` (5),
  `selection.*` (4 extra: pageSelected/selectAllRows/allSelected/
  clearSelection), `lookup.*` (4: searchPlaceholder/selectButton/
  cancelButton/doubleClickHint), `serverGrouping.*` (3: loading/
  loadMore/loadingMore), `batch.*` (3: saveAll/discardAll/changesCount),
  `skipLink`, `export.formatPdf`.
- **7 dode keys verwijderd uit `defaults.ts`** (geen runtime-callers
  gevonden via grep van `grid.<key>` in productie-code; geen dynamische
  template-aanroepen):

  - `serverGrouping.noGroups`
  - `serverGrouping.expandGroup`
  - `serverGrouping.collapseGroup`
  - `serverGrouping.exportProgress`
  - `batch.confirmDiscard`
  - `batch.saveFailed`
  - `lookup.noResults`

  Vermoedelijk overblijvers van eerder verwijderde features; consistent
  met de "absorberen, niet archiveren"-filosofie verwijderd in plaats van
  defensief mee-vertalen. Twee tests in `batch-translations.test.ts` die
  de dode keys bewaakten zijn meegenomen — vervangen door een korte
  comment-noot die de keuze documenteert.

### Closed without code-fix — TD-4 (`useDebounce` leeg→leeg ref-mutatie) — bron bewezen, kost gemeten verwaarloosbaar

TD-4 was de overgebleven render-budget-vraag uit de v3.1.4-diagnose-
ronde. Een eerste sluit-poging op redenering werd door Walt
teruggedraaid; nieuwe meet-instrumentatie (setState-source-tracer met
stack-trace + render-cadence + fetchBlock-identity-rotation-detector)
leverde één hard-refresh van `/test-server-grouping` waaruit zowel
bron als kost ontegenzeggelijk afleesbaar was.

**Bron bewezen** — een vierde, niet eerder vermoede kandidaat:
`NumberFilter.useEffect` (`number-filter.tsx:142`) doet
`column.setFilterValue(undefined)` in zijn passive-mount-effect. Twee
instances (salary + age, beide `filterType: "number"`) → React batcht
de twee setStates in één commit → één gecombineerde columnFilters
ref-mutatie op render #3. De write is content-SAME (filter verwijderd
uit een al-lege lijst → nieuwe lege lijst-ref), maar de Store heeft
geen deep-compare dus de ref wordt vervangen en subscribers krijgen
het mutatie-signaal. De drie eerder vermoede kandidaten (TD-3
hydration, columnFiltering init-effect, useColumnManager setup-effect)
waren allemaal mis.

**Kost gemeten verwaarloosbaar** — cascade-detector toonde
`render #21: debouncedColumnFilters muteert (output, 300ms debounce-
fire)` + `render #21: fetchBlock useCallback IDENTITY rotated (EENMAAL)`.
**Géén verdere mutaties of rotations na render #21.** Per ADR-015
Fix A zit fetchBlock in een ref; useCallback-rotation triggert geen
consumer re-render-cascade. Per-mount eindeffect: 1 useCallback-
recreatie, géén consumer re-render, géén triggered fetch. Niet
meetbaar in user-perceived performance.

**Geen fix.** NumberFilter's mount-time no-op `setFilterValue(undefined)`
zou kosteloos kunnen worden gefixt (skip wanneer de waarde al
undefined is in de store), maar de cascade-impact is verwaarloosbaar
en preventief fixen van een latente theoretische bug die al
geneutraliseerd is, is precies waar YAGNI tegen waarschuwt. Walt's
voorwaarde "Als de render-kost verwaarloosbaar blijkt: forceer geen
fix" is gerechtvaardigd door meting.

Instrumentatie weg uit `use-grid-state.ts` en `use-data-orchestrator.ts`.
Volledige conclusie, cascade-trace en lessons-learned in
DONE.md.

### Verification

- `pnpm --filter @workspace/active-grid test` — **1285/1285 passing**
  (+2 voor B-2 in `use-server-grouping.test.ts`; −2 voor de twee
  dode-key-tests in `batch-translations.test.ts` die meegenomen werden
  bij de i18n-coverage-fix).
- `pnpm typecheck` — **38/38 packages clean**.
- Visual (Walt te verifiëren on test-grids + production /users):

  - `/test-server-grouping`: groep-rijen + aggregaten zichtbaar (sum
    of salary, avg of age per group); **drag-to-group werkt vanaf
    de drag-bron** (grip-icoon op hover bij groepbare kolommen,
    cursor verandert, drag start, drop op toolbar groepeert); niet-
    groepbare kolommen (zonder `server.groupBy`) tonen géén grip-icoon
    en zijn niet grabbable; **un-group-all + re-group cycli stabiel**
    (meerdere keren); geen regressie op v3.1.5 stale-while-revalidate;
    geen regressie op v3.1.4 fetch-stability.
  - `/test-minimal-grouping?step=0..7`: alle stappen stabiel.
  - **Geneste grouping (2 levels) stack + unstack via drag** stabiel.
  - Verificatie of bevinding-3 (Performance.measure error) verdwenen
    is na deze fix.
  - Productie-grids (geen consumer-migratie verplicht): flat-mode
    grids onveranderd.

---

## \[3.1.5\] — 2026-05-22 — Rustig grid: progress-indicator + stale-while-revalidate (ADR-016)

Combineert twee UX-fixes die hetzelfde architecturale gat aanpakten:
de active-grid engine had geen *unified loading-indicator* die rustig
naast bestaande content werkt. Skeleton-overlay en empty-state-flash
verdwijnen; rijen blijven zichtbaar tijdens filter/sort/cross-block-
pagination met een 2-pixel progress-bar bovenaan. Verlaat bewust de
v2.2.0 "defensive option B"-cache-gate ten gunste van stale-while-
revalidate — die was correct onder het skeleton-overlay-model, niet
onder de nieuwe progress-indicator. Zie ADR-016.

### Changed — `OverlayManager` default = `"progress"`

`packages/active-grid/src/components/sections/overlay-manager.tsx` —
nieuwe default `loadingDisplay="progress"`:

- Eén 2-pixel indeterminate animated bar bovenaan het grid voor zowel
  `isLoading` (initial load) als `isRefetching` (refetch).
- Geen opaque overlay meer. Bestaande rijen blijven volledig zichtbaar
  en interactief tijdens loading.
- Skeleton en spinner blijven beschikbaar als opt-in
  (`loadingDisplay="skeleton"` of `="spinner"`) voor consumers die de
  oude look verkiezen.

`packages/active-ui/src/styles/grid-theme.css` — progress-bar-kleur
van `color-mix(--muted-foreground @ 20%)` naar
`color-mix(--primary @ 45%)`. Net zichtbaar genoeg dat een trage
productie-query (500 ms+) niet als "hangt het?" voelt; nog steeds
rustig genoeg om niet te storen.

### Changed — `useDataOrchestrator` = stale-while-revalidate

`packages/active-grid/src/hooks/use-data-orchestrator.ts` —
`!isDatasetStale`-gate op `cachedPage` verwijderd; `lastGoodPageRef`
toegevoegd als fallback voor het invalidate-window:

```ts
const livePage =
  mode === 'server'
    ? store.peekPage(pageIndex, pageSize, pagesPerBlock)
    : undefined;

const lastGoodPageRef = React.useRef<typeof livePage>(undefined);
if (livePage) lastGoodPageRef.current = livePage;

const cachedPage = livePage ?? lastGoodPageRef.current;
const isLiveCacheHit = livePage !== undefined;
const isCacheHit = isLiveCacheHit;
```

De fetch-trigger en prefetch effects blijven op `isLiveCacheHit`
gebaseerd — het ref-fallback is puur display, blokkeert geen
legitieme fetches. Token-gating (`setBlockIfToken`) blijft
stale-write-bescherming. Het return-pad surfaced loading-flags
fallback-aware: fresh cache-hit → `isLoading=false, isRefetching=
false`; stale-fallback → echte state surface → progress-bar runt
boven stale rijen.

### Changed — Body empty-state-guard

`packages/active-grid/src/components/sections/body.tsx` +
`body-virtual.tsx` — guard uitgebreid van `if (isLoading)` naar
`if (isLoading || isRefetching)`. Onder stale-while-revalidate
zelden noodzakelijk (cachedPage is via fallback zelden leeg) maar
sluit het first-load-window en dient als safety-net. De achterhaalde
comments ("stale data is kept visible with a dimming overlay") zijn
herschreven naar de nieuwe semantiek.

### Withdrawn — defensive option B (v2.2.0)

De [v2.2.0 R-14 CHANGELOG-entry](#220--2026-05-21--r-14--r-21-batch-adr-005-pattern-3-retired)
documenteerde defensive option B als:

> "Defensive option B: cache is gated by `isDatasetStale`. Filter/sort
> change forces a brief loading flash instead of stale-while-revalidate;
> intentional design to guarantee no stale frame can reach the DOM."

Dat ontwerp paste binnen het skeleton-overlay-model van v2.2.0: zonder
onmiskenbare loading-cue was stale-while-revalidate misleidend.
**v3.1.5's progress-indicator (deze release) maakt de loading-state
visueel onmiskenbaar**, waardoor het bestaansrecht van defensive option
B vervalt. Industry-precedent (React Query, SWR, TanStack Query,
Apollo) bevestigt stale-while-revalidate als standaard wanneer een
loading-indicator zichtbaar is. Defensive option B is daarmee
expliciet teruggetrokken; de R-14-CHANGELOG-entry hieronder bevat een
forward-reference naar deze v3.1.5-entry zodat het spoor in beide
richtingen klopt.

### v2.2.0-tests groen onder stale-while-revalidate

Beide v2.2.0-integratietests die ADR-016's diagnose markeerde als
"verifiëren, niet aannemen":

- **`R-14: page-navigation cache-hit invariant`**
  (`use-data-orchestrator.test.ts:520`). Test intra-block pagination:
  zelfde datasetKey, alleen pageIndex verandert. `isDatasetStale=false`
  in dit pad; voorstel B raakt deze test niet. Test-comment vermeldt
  zelfs expliciet: "Wat dit test NIET is: filter-change-flash".
  Resultaat: groen.
- **`R-14 S3: stale-prevention on groupBy change`**
  (`use-server-grouping.test.ts:719`). Test dat een mid-flight oude-
  groupBy-fetch wordt afgewezen via `setGroupIfToken`-token bij
  resolve, en dat expanded-state synchroon clearet bij groupBy-switch.
  Token-pad blijft ongewijzigd onder voorstel B; expanded-clearing
  gebeurt in `useServerGrouping`'s dataset-key-effect (los van
  `useDataOrchestrator`). Resultaat: groen.

Geen test-aanpassingen nodig. Geen design-conflict tussen
stale-while-revalidate en de stale-write-protection-tests.

### Verification

- `pnpm --filter @workspace/active-grid test` — **1308/1308 passing**.
- `pnpm typecheck` — alle 38 packages clean.
- Visueel (Walt te verifiëren): rijen blijven zichtbaar tijdens
  filtering, sortering, snelle cross-block-pagination op
  `/test-server-grid`, `/test-server-grouping`,
  `/test-minimal-grouping?step=0..7`, en `/users` (productie-grid met
  echte Prisma-query-latency).

### Follow-up

- **TD-4** (was over render-cascade van debouncedColumnFilters-ref-
  mutatie + skeleton-flash) — de skeleton-flash-helft is nu structureel
  opgelost via deel 1 + 2. Wat resteert is alleen de render-cascade
  efficiency-vraag. TD-4-instrumentatie blijft actief; herzien in
  separate ronde.
- **TD-5** is in v3.1.5 doorgevoerd: zie "Internal — TD-5 fix" hieronder.

### Internal — TD-5 fix (action-via-ref op prefetch-effect)

Tweede instantie van het ADR-015 action-in-deps anti-pattern,
opgelost in dezelfde release. `use-data-orchestrator.ts:352-358`'s
prefetch-effect bevatte `prefetchAdjacentBlock` in zijn deps. Bij de
useCallback-cascade die `fetchBlock` triggerde, zou dit dezelfde
abort-cleanup veroorzaken als ADR-015's bug op de fetch-trigger
effect. Niet user-visible (prefetches togglen geen `isLoading`),
maar het patroon was onveilig en wachtte op de eerstvolgende cascade-
trigger om opnieuw destructief te worden.

```ts
const prefetchAdjacentBlockRef = React.useRef(prefetchAdjacentBlock);
React.useEffect(() => {
  prefetchAdjacentBlockRef.current = prefetchAdjacentBlock;
});

React.useEffect(() => {
  if (mode !== 'server' || !isCacheHit || !cachedPage) return;
  prefetchAdjacentBlockRef.current(pageIndex, cachedPage.totalCount);
  return () => {
    prefetchControllerRef.current?.abort();
  };
}, [mode, isCacheHit, cachedPage, pageIndex]); // prefetchAdjacentBlock niet meer in deps
```

ADR-015's "Cross-cutting les" — actions horen niet in effect-dep-
arrays — toegepast op een tweede plek in dezelfde hook.

---

## \[3.1.4\] — 2026-05-21 — `fetchData` action-via-ref in useDataOrchestrator (ADR-015)

Fixes the `/test-server-grid` "no results" hang where in-flight fetches
were aborted by spurious effect-cleanups before they could resolve. The
race surfaced sporadically because the abort-cascade fires after the
useDebounce 150-300 ms timer, while the mock fetch has a random 200-500 ms
latency — the symptom appeared as "soms wel, soms niet werkt het". Same
latent-since-v1.0.x flank as ADR-014's grouping-sync: aggressive
re-render cadence introduced by the v1.0.71 R-14 `useSyncExternalStore`
rewrite widened the timing window enough to consistently expose it.

### Root cause (in one paragraph)

The fetch-trigger `useEffect` in `useDataOrchestrator` had `fetchData`
in its dep array. `fetchData` is a `useCallback` with 11 inner deps,
several of which (`fetchBlock`, `prefetchAdjacentBlock`,
`prefetchNextBlock`) are themselves `useCallback`s that depend on
`debouncedColumnFilters` / `debouncedGlobalFilter`. When one of those
`useDebounce` results ref-mutated (verified runtime: `arr[0]→arr[0]`
— same empty content, different reference), the cascade rotated
`fetchData`'s ref. The fetch-effect saw the new ref → fired cleanup →
aborted the in-flight controller → spuriously cancelled a fetch
mid-flight. The in-flight-token-guard (line 495) then blocked the
re-fire because the aborted fetch's async `finally` hadn't run yet
(`inFlightTokenRef.current = null` happens on a later microtask).
Deadlock: cache empty, guard blocks, no state-change route resets it.

### Fixed

Action-via-ref pattern. `fetchData` is now wrapped in a ref that's
synced via a no-deps effect on every render. The fetch-trigger
useEffect calls `fetchDataRef.current()` and no longer includes
`fetchData` in its deps:

```ts
const fetchDataRef = React.useRef(fetchData);
React.useEffect(() => {
  fetchDataRef.current = fetchData;
}); // no deps — keeps ref at latest version

React.useEffect(() => {
  if (mode !== 'server' || isCacheHit || isDatasetStale) return;
  if (inFlightTokenRef.current === store.getInvalidationToken()) return;
  fetchDataRef.current();
  return () => {
    controllerRef.current?.abort();
    prefetchControllerRef.current?.abort();
  };
}, [mode, store, isCacheHit, isDatasetStale, debouncedPageIndex]);
//                                            ^^^^^^^^^^^^^^^^^^^
//                                            primitive Object.is-stable
//                                            — preserves cache-miss
//                                            trigger on pagination.
```

`debouncedPageIndex` is added as a primitive trigger so pagination to a
non-cached block still fires the fetch after the 150 ms debounce
settles (previously this was implicit via `fetchData`'s inner-deps).
Other legitimate triggers preserved: `isCacheHit` flip (cache-miss),
`isDatasetStale` flip (sort/filter/globalFilter via datasetKey),
`fetchFn` swap (separate detection-effect bumps invalidation token).

### Considered, rejected

A simpler symptom-patch — sync-resetting `inFlightTokenRef.current = null`
inside the effect cleanup — would unblock the re-fetch path but break
the `refetch()` / `retry()` double-fire guard (imperative refetch sets
the token synchronously; a subsequent re-render must NOT fire again on
the same token). Root-fix over symptom-patch; see ADR-015.

### Cross-cutting les (shared with ADR-014)

Two distinct bugs (ADR-014 bidirectional grouping-sync, ADR-015
action-in-deps) share one wortel: identity-instability causing
false-positive effect-re-fires. Documented vuistregel for active-grid
engine code: **actions (one-shot initiators like `fetchData`,
`triggerInvalidate`, `emit*`) belong in a `useRef`-sync, not in an
effect-dep-array.** Effect-deps should hold only data fields where a
change is a legitimate re-fire signal.

### Removed

- Diagnostic instrumentation that proved the loop: `__diagPrev`,
  `__diagInnerPrev`, `__diagEffectFires` refs and `[DIAG …]` console
  logs in `use-data-orchestrator.ts`; `__fetchCounter` /
  `__activeFetchCounter` / `[orders fetch …]` logs in
  `apps/admin/.../test-server-grid/grid/grid-utils.ts`;
  `__orderGridMountCounter` / mount-tracker useEffect in
  `apps/admin/.../test-server-grid/grid/grid.tsx`; the
  `instrumented-fetch.ts` wrapper in
  `apps/admin/.../test-minimal-grouping/` (re-pointed to the regular
  `createGroupingFetchFn`).

### Follow-up TD

- **TD-4** (was TD-6 vóór de documentatie-audit op 2026-05-22, zie
  hernummerings-noot in `ROADMAP.md`): the `debouncedColumnFilters`
  ref-mutation (leeg-naar-leeg) that triggered the cascade is still
  latent. Fix A makes it irrelevant for the hang, but it produces
  unnecessary re-renders. Suspected origin: a Strict-Mode interaction
  with `useDebounce`'s `useState(value)` initialiser, or an upstream
  state-mutation in `state.columnFilters` that wasn't traced. Worth a
  focused follow-up to keep render-budget predictable for hot-path
  grids.
- **TD-5** (also surfaced by the 3.1.4 diagnose, registered during
  the documentatie-audit): the prefetch-effect at
  `use-data-orchestrator.ts:352-358` has the exact same action-in-deps
  anti-pattern that Fix A solved on the fetch-trigger effect.
  `prefetchAdjacentBlock` is in its deps; when the same cascade rotates
  its ref, the cleanup aborts in-flight prefetches. Not user-visible
  today (prefetches don't toggle isLoading) but a known defect pattern
  that should be fixed in the same round as TD-4 (shared diagnostic
  scaffolding).

### Verification

- `pnpm --filter @workspace/active-grid test` — 1305/1305 passing.
- `pnpm typecheck` — all packages clean.
- Visual: `/test-server-grid`, `/test-server-grouping`, and
  `/test-minimal-grouping?step=0..7` — to be confirmed by Walt.

---

## \[3.1.3\] — 2026-05-21 — Server-grouping single source of truth (ADR-014)

Fixes the server-grouping ping-pong remount loop that surfaced on
`/test-server-grouping` and was empirically proven via the
`/test-minimal-grouping?step=N` bisection page plus per-render dep-ref
instrumentation in `useServerGrouping`. The loop existed since the
initial server-grouping commit (`3fa1648`, v1.0.7) but stayed latent
until the v1.0.71 R-14 `useSyncExternalStore`-rewrite of the cache stores
made render-cadence aggressive enough to expose it.

### Root cause (in one paragraph)

`ActiveGrid` held the active server-grouping in **two states** —
`effectiveServerGroupBy` (local) and the engine's `grouping` slice
(store) — and synchronised them via **two `useEffect`s in opposite
directions** (effective → engine and engine → effective). On mount the
local state initialised from `props.serverGroupBy` (non-empty), the
engine slice initialised at `[]` (engine default), and both effects
fired in the same commit with stale closure-captured values of the
*other* state. Each effect undid the previous flip; `effectiveGroupBy`
oscillated every render → `useServerGrouping`'s `fetchTopLevel`
useCallback got a new ref each render → the
`useEffect([enabled, fetchTopLevel])` re-fired → `topControllerRef`
aborted the previous fetch → new fetch fired → infinite abort/refetch.

### Fixed

Single source of truth for the active server-grouping:

- **State** `serverGroupBy: GroupByColumn[]` in `ActiveGrid` is the sole
  bron. Initialised from `props.serverGroupBy ?? []`. Toolbar mutations
  call `setServerGroupBy` via an intercepted engine-`onGroupingChange`
  callback (`handleEngineGroupingChange`); they no longer write into
  the engine store directly. The previous engine→effective sync
  `useEffect` (`grid.tsx:257-277`) and its `useStore(grid.baseStore,
  selectGrouping)` subscription are deleted.
- **One-way mirror** `useEffect` writes
  `serverGroupBy.map(g => g.field)` into `grid.baseStore.state.grouping`
  via direct `store.setState` (not `grid.setGrouping(...)`, which would
  re-call the intercept). No-op-detection: returns `prev` when the
  engine grouping content already matches, so subscribers are not
  notified on inert renders.
- **Engine `onGroupingChange` override** wired via new
  `interceptEngineGroupingChange` prop on `useGridSetup` and
  `onGroupingChangeOverride` on `useGridOptions`. Only set when
  `isServerGroupingActive` is true — client-grouping consumers keep
  the engine's default `makeStateUpdater("grouping", grid)`.
- **Consumer-facing `props.onGroupingChange`** observation callback is
  unaffected. The `use-grid-setup.ts:393-401` store-subscriber sees
  the mirror-effect's engine update and fires the callback on every
  grouping change, exactly as before.

### Removed

- Diagnostic instrumentation in `use-server-grouping.ts` that pinpointed
  the loop (`[DIAG useServerGrouping] dep refs changed: …` logs) is
  removed in the same commit as the fix.

### Architecture note — see ADR-014

The bidirectional-sync pattern is an anti-pattern: it relies on closure-
captured staleness to converge, which fails on initial-state mismatches.
The fix replaces it with a single source of truth and a one-way mirror
to the engine — the engine state becomes a derivative, not a peer.
Future server-grouping refinements (fase 3, R-23 — group-by + aggregaten
runtime) build on this foundation.

### Decisions on the four prior v3.1.x defensive patches

Diagnose confirmed they are all innocent and defensive — no revert
needed. Retained as-is:

- `isSameServerGroupBy` value-equal comparator (3.1.2) — still guards
  the `props.serverGroupBy → serverGroupBy` mirror edge against inline
  array literals.
- `recordKeyRemountUnmount` cleanup (3.1.2) — still blocks the
  Next.js navigation false-positive in `detectKeyRemount`.
- Badge-preset `enableSorting: true` removal (3.1.1) — still correct.
- Bouwsteen-3 stale-state pruning in `loadInitialGridState` (3.1.2) —
  still closes the fase-1 hardening gap.

### Verification

- `pnpm --filter @workspace/active-grid test` — 1305 tests passing.
- `pnpm typecheck` — 38/38 packages clean.
- Visual: minimal-grouping repro stable through all 8 steps (0-7);
  `/test-server-grouping` stable (no flicker, no console abort/refire
  noise); `/test-server-grid` to be re-verified post-fix per the
  separate-diagnosis caveat in the briefing.

---

## \[3.1.2\] — 2026-05-21 — Fixes: serverGroupBy loop, remount-detector false-positive, bouwsteen-3 hardening

Three fixes surfaced by fase-2 visual testing. None changes the public API
or required consumer migration; together they tighten the engine against
pre-existing instability and close one loose end from fase-1.

### Fixed — `serverGroupBy` infinite remount loop

`ActiveGrid` previously used a **reference**-comparison to gate the
sync-effect that mirrors `props.serverGroupBy` into the
`effectiveServerGroupBy` state (`grid.tsx:170-176`). Consumers that pass
`serverGroupBy` as an inline array literal — the common case — get a
fresh array reference every render, defeating the ref-check: `setState`
fires every render → re-render → fresh inline array → `setState` →
**infinite remount loop**. Symptom: multi-second hang on
`/test-server-grouping` with a deep
`recursivelyTraversePassiveMountEffects` stack-trace.

Replaced with a value-equal comparator (`isSameServerGroupBy` in
`src/utils/is-same-server-group-by.ts`) that compares `field` and `sort`
per entry. Pre-existing bug (not fase-1/fase-2 regression); engine-side
fix protects every future consumer that passes the prop inline.

### Fixed — `detectKeyRemount` false-positive on Next.js navigation

The module-level `mountTracker` in `key-remount-detector.ts` persists
across Next.js navigations within the same JS environment. Navigating
away from a grid-page and back (legitimate routing flow) re-mounted
`ActiveGrid` with the same `gridId`; the detector fired the warning
designed for the `<ActiveGrid key={refreshKey}>` anti-pattern even
though the consumer did nothing wrong.

Added `recordKeyRemountUnmount` — a cleanup-pair called from
`ActiveGrid`'s mount-effect. The cleanup clears the tracker entry **only
when the user spent ≥ 2 seconds on the page** before unmounting (likely
legitimate). Rapid unmount/remount cycles (\< 2s, the actual anti-pattern
target) leave the entry intact so the next mount still fires the
warning. Trade-off: slow key-prop anti-patterns where the user mutates
every 5+ seconds become false-negatives — acceptable; their blast
radius is low compared to the false-positive's confusion cost.

### Added — Bouwsteen-3 stale-state pruning for `sorting` / `columnFilters`

`loadInitialGridState` already filtered out stale column-IDs from
`columnVisibility`, `columnSizing`, `columnOrder`, and `columnPinning`.
It did **not** filter `sorting` and `columnFilters` — entries
referencing column-IDs no longer present in the current grid sat in
state forever (TanStack does not auto-prune). After fase-1 introduced
bouwsteen 3, the same entries can also reference columns that exist
but have no `server.sort` / `server.filter` mapping; visual effect is
identical (state present, UI inert).

Added symmetric pruning for `sorting` and `columnFilters` based on the
`columnIds` set already passed for the other state slices. **Option 2a
from the diagnose-report** — based on column-id existence, not on
contract presence. Contract-aware pruning (option 2b) is deferred to
**TD-4** because it requires threading the resolved-contract into
hydration — a fase-3-territory refactor.

### Tests

Total 1305 (was 1286, +19):

- `is-same-server-group-by.test.ts` — 8 cases covering undefined/empty
  equivalence, inline-array-loop scenario, field/sort mismatches,
  order-matters.
- `key-remount-detector.test.ts` — 4 new cases: legitimate-unmount
  clears tracker, rapid-unmount preserves tracker, defensive no-op
  paths for unknown / undefined gridId.
- `load-initial-state.test.ts` — 5 new cases + 2 updated for the
  bouwsteen-3 pruning (filter on present column-id, drop key when all
  stale, respect missing columnIds, URL-hydration prune symmetry).

### Not in this patch

- Contract-aware stale-state pruning (TD-3 — was TD-4 vóór de
  documentatie-audit op 2026-05-22, zie hernummerings-noot in
  `ROADMAP.md`): drop sort/filter entries whose column exists but
  has no `server.sort`/`server.filter` mapping. Requires threading
  the resolver into hydration; scheduled for fase 3.
- Investigation of consumer-side remount cause in `test-server-grid`:
  with the detector fix, the warning Walt observed disappears on
  navigate-back. If a real consumer-side remount cause still presents
  itself it surfaces post-fix; until then it was the detector noise.

---

## \[3.1.1\] — 2026-05-21 — Fix: `badge` preset no longer forces `enableSorting: true`

Removed `enableSorting: true` from the `badge` type-preset in
`getDefaultColumnTypes`. The preset predated bouwsteen 3 (ADR-013) and was
spreading `enableSorting: true` onto every badge column via
`applyColumnTypes`. When a badge column declared `server.sort: false`
(e.g. the `userRoles`-column on the users-grid, where sort is semantically
empty because users have multiple roles), the result was an inconsistent
column-def with `enableSorting: true` AND `server.sort: false` — exactly
the configuration that bouwsteen 3's dev-warning catches.

**End-state behavior unchanged.** Sort UI remained off in both cases —
bouwsteen 3's "force off + warn" already handled it. The fix removes the
warning by removing the upstream source of the conflict.

- **Client-mode badge columns:** TanStack's own default (`enableSorting: true`)
  still applies, no behavioral change.
- **Server-mode badge columns with `server.sort`:** bouwsteen 3 derives
  the flag from the mapping; behavior unchanged.
- **Server-mode badge columns without `server.sort`:** bouwsteen 3 now
  derives `enableSorting: false` cleanly without the warn-and-force-off
  path. Same end-state, cleaner derivation.

The dev-warning itself stays in place — it still catches the case where
a consumer explicitly writes `enableSorting: true` on a column without
declaring `server.sort`. Just no longer fires for "innocent" badge
columns.

---

## \[3.1.0\] — 2026-05-21 — Server-side query-contract phase 2 (globalFilter)

Implements the `global` runtime of the column-contract. The toolbar's
single-search box now translates to a Prisma `OR`-clause across columns
that opt in via `column.server.global`, joined to column-filters and
`extraWhere` via top-level `AND`. Columns without a `global` mapping
fail-closed — typed queries silently drop their column contribution
instead of reaching Prisma as a schema-blind fragment, identical to
the phase-1 policy for sort/filter. Closes R-22; group-by + aggregations
(R-23) remain reserved.

This is a **minor bump** — purely additive. Zero production grids use
the search-box today; consumers opt in voluntarily by setting
`toolbar.search.enabled = true` and declaring `column.server.global`
mappings.

### Added

- **`@workspace/active-grid` main barrel** — global helpers:
  `globalField(path?, options?)`, `globalSomeByField({...})`,
  `globalEveryByField({...})`, `globalNoneByField({...})`. Same
  declarative pattern as the phase-1 sort/filter helpers; pure object
  builders without Prisma imports.
- **`@workspace/active-grid/server` subpath** — server-only
  `rawGlobal(build)` escape-hatch for closures that the wire-shape
  cannot transmit.
- **`queryBuilder.toPrismaGlobalFromContract(query, contractMap, options)`**
  — new contract-aware function. Joins per-column condition fragments
  under one `OR`-clause; returns `undefined` for empty queries or when
  no column contributes.
- **`useToolbarSearchWarning` hook** — internal. Fires a one-time-per-
  `gridId` dev-warning when a server-mode grid sets
  `toolbar.search.enabled = true` but no column declares
  `server.global`. Does not force-off the search-box; consumer choice
  is respected, the warning surfaces the development-fault. Matches the
  B-3 design decision from the phase-2 plan (not a phase-1 bouwsteen-3
  symmetry — the toolbar is grid-level, not per-column-interactive).

### Changed

- **`createGridServerAction`** folds globalFilter into the where-clause
  via top-level `AND`, not via spread. This preserves any `OR`-clause
  that a `FilterMultiFieldMapping` column-filter may have produced; the
  two OR-clauses stay semantically separate. When `globalFilter` is
  empty or no column contributes, behavior is identical to v3.0.0.

### Schema-loosening — `path?: string` on three field-mappings

`GlobalFieldMapping.path`, `GroupByFieldMapping.path`, and
`AggregateScalarMapping.path` are now `path?: string` (optional), matching
the existing `SortFieldMapping.path?` and `FilterFieldMapping.path?`.
v3.0.0 had `path: string` (required) on these three — an asymmetric design
gap that surfaced when implementing the phase-2 helpers. Loosening a type
constraint (required → optional) is non-breaking: existing
`path: "x"`-mappings continue to type. No consumer is forced to migrate;
the C3 schema-stability promise for the reserved sub-keys is not violated
(that promise is about wiring breaking changes through major bumps, not
about refinements that strictly broaden the set of valid consumer code).

The change is explicitly logged in
ADR-013 — Bekende technische schuld → Schema-loosening v3.1.0.
Future asymmetry-fixes will need the same explicit ADR note; treating
schema-loosening as a free-for-all "refinement" path would erode the
C3 discipline that makes the reserved keys trustworthy in the first
place.

### Migration

None required. Consumers that want server-side global search add
`server.global` to the relevant columns and set
`toolbar.search.enabled = true`. See
`migration/v2-to-v3.md` § "Step 6 — opt in to globalFilter (v3.1.0)" (removed in 2026-09).

---

## \[3.0.0\] — 2026-05-21 — Server-side query-contract (phase 1: sort + filter)

Introduces a per-column server-side query-contract that replaces the
schema-blind query-builder. Closes a structural Prisma-crash class
(`prismaModel.findMany` invocations that included a relation-name in
`orderBy` / `where`) and aligns active-grid's mental model with
enterprise patterns (AG Grid SSRM, TanStack Table): the grid sends a
neutral intent, the server translates it via explicit per-column
mappings, and unmapped fields are fail-closed instead of forwarded to
the ORM. See ADR-013 — full design rationale
including overwogen alternatieven (A1/B2/C1/C2) is in the ADR sub-
section "Overwogen alternatieven", geabsorbeerd uit het oorspronkelijke
RFC-werkdocument bij de v4.0 documentatie-opruiming.

This is phase 1 of three: `sort` and `filter` are active; `global`,
`groupBy`, and `aggregate` are reserved (their type-schemas are locked
so phase 2 and 3 cause no schema-change on consumers, but their runtime
behavior is a no-op with dev-warning if used).

### Breaking changes

- **`FilterFieldConfig` / `FilterFieldConfigMap` removed** from the
  public exports. Per-column filter mappings now live on
  `column.server.filter` via the declarative helpers
  (`scalarField`, `fieldFilter`, `multiField`, `someByField`,
  `everyByField`, `noneByField`).
- **`CreateServerFetchFnConfig.filterFieldConfig` removed**; replaced by
  a new `columns` field that lets `createServerFetchFn` extract each
  column's `server` contract into a serializable `columnContract` map
  transmitted per request.
- **`GridServerActionRequest.filterFieldConfig` removed**; replaced by
  `GridServerActionRequest.columnContract: Record<string, ServerColumnContract>`.
- **`column.enableSorting` / `column.enableColumnFilter` semantics
  hardened.** When the column has no `server.sort` / `server.filter`
  mapping (and no DMMF default exists for the column-id), the header
  no longer offers sort/filter UI even when these flags are
  unset/`true`. Explicit `enableSorting: true` without a mapping logs a
  dev-warning and is forced off to avoid silently-stripped instructions.
  Explicit `enableSorting: false` always wins (consumer override). The
  policy is applied only for `mode="server"` grids — client-mode
  defaults are untouched.

### Added

- **`@workspace/active-grid` main barrel** — declarative helpers:
  `scalarField`, `byField`, `byCount`, `fieldFilter`, `multiField`,
  `someByField`, `everyByField`, `noneByField`. Plus the contract-shape
  types: `ServerColumnContract`, `ServerColumnOverride`,
  `SerializableSortMapping`, `SerializableFilterMapping`,
  `SerializableGlobalMapping`, `SerializableGroupByMapping`,
  `SerializableAggregateMapping`, `ResolvedColumnContract`,
  `ResolvedColumnContractMap`, and each tagged-mapping interface.
- **`@workspace/active-grid/server` subpath** — server-only:
  `rawSort` / `rawFilter` (closure-based escape-hatches for the
  `serverColumnOverrides` factory parameter), `generateDmmfDefaults`,
  `resolveColumnContract`, `resolveAllColumnContracts`, `warnStripped`.
  Plus types: `DmmfModelMeta`, `DmmfFieldMeta`, and the four `Raw*`
  mapping interfaces.
- **`createGridServerAction({ serverColumnOverrides, dmmfModelProvider })`**
  — new opt-in factory parameters. `dmmfModelProvider(modelName)`
  returns DMMF metadata for the requested Prisma model; the factory
  generates scalar-only default mappings from it. `serverColumnOverrides`
  is a per-column override map that may contain `rawSort` / `rawFilter`
  closures and has the highest authority in conflict-resolution.
- **`queryBuilder.toPrismaOrderByFromContract` and `toPrismaWhereFromContract`**
  — contract-aware variants used by the factory. The legacy
  `toPrismaOrderBy` and `toPrismaWhereFromFilters` remain only for the
  yet-to-migrate group-by path (phase 3 will move group-by onto the
  contract too and remove them).
- **Three-layer conflict-resolver.** DMMF defaults (lowest) → wire
  contract (`column.server`) → server-side overrides (highest). Per
  column-id, the highest authority wins as a whole; explicit
  per-sub-key `false` overrules lower layers for that sub-key only.
  Unmapped sort/filter instructions are silently stripped with one
  dev-warning per `(model, column, capability)` tuple.

### Changed

- **`createServerFetchFn(config)`** accepts a new `columns` field
  (`GridColumnDef<TRow>[]`) used to extract per-request column-contract.
  Migration is mechanical: pass the same `columns` array you already
  give to `<ActiveGrid>`.
- **`useColumnManager` / `enhanceColumns`** thread the grid's `mode`
  through so server-mode-only capability resolution can run.
- **dbCore + dbArchives action wrappers** wired with a structural DMMF
  adapter that reads `_runtimeDataModel.models` on the Prisma client
  and adapts it to active-grid's `DmmfModelMeta` shape. Falls back
  gracefully (no defaults generated) if the runtime structure is
  unavailable.

### Migration

See `docs/migration/v2-to-v3.md` (removed in 2026-09) for the
mechanical recipe. Highlights:

- Replace `filterFieldConfig: {...}` on `createServerFetchFn` with a
  `columns` prop pointing at the column definitions.
- For each server-mode column, add a `server` field with the right
  helper. `scalarField()` covers ~80% (sort + filter on column-id);
  nested 1:1 paths use `scalarField("user.profile.displayName")`;
  one-to-many relations use `{sort: byCount(...), filter: someByField(...)}`.
- The `userRoles`-style v2.x demping (`enableSorting: false` +
  `enableColumnFilter: false` on a relation column) is replaced by a
  proper contract — `{sort: byCount("userRoles"), filter: someByField({...})}`.

---

## \[2.2.0\] — 2026-05-21 — R-14 + R-21 batch (ADR-005 Pattern 3 retired)

Retires ADR-005 Pattern 3 (render-time imperative cache) by rewriting
`BlockCache` + `GroupCache` around `useSyncExternalStore`. **5 inline
`react-hooks/refs` disables eliminated** (4 in `use-data-orchestrator.ts`,
1 in `use-server-grouping.ts`). Expands the benchmark suite from 4 to 9
scenarios with N=5 variance reporting and a frozen pre-rewrite baseline
(R-21). No public API changes — fully internal. Closes R-14 + R-21.

### Changed (internal)

- **`BlockCache` + `GroupCache` peek/touch split + setMaxSize**. Reads
  are now pure `peekBlock` / `peekPage` / `peekAllData` / `peekGroup`
  methods that don't touch LRU; freshness is signalled only by writes
  and explicit `touchBlock` / `touchGroup`. Each cache adds
  `setMaxSize(newSize): boolean` returning true on eviction so the
  store-wrapper can decide whether to notify subscribers. Effective
  policy shifts from "evict least-recently-read" to "evict
  least-recently-written" — acceptable for fetch-block caching since
  writes are the strongest freshness signal anyway.
- **`useDataOrchestrator` rewritten** around `BlockCacheStore` +
  `useSyncExternalStore`. Eliminates 4 inline `react-hooks/refs`
  disables in `use-data-orchestrator.ts` (former lines 104, 167, 610,
  640).

  - Defensive option B: cache is gated by `isDatasetStale`. Filter/sort
    change forces a brief loading flash instead of stale-while-revalidate;
    intentional design to guarantee no stale frame can reach the DOM.

    **WITHDRAWN per v3.1.5 / ADR-016 (2026-05-22).** The "no stale frame"
    rationale assumed a missing loading-indicator made stale rows
    misleading. v3.1.5's progress-indicator (2-pixel animated bar
    over the top edge) makes the loading state visually unambiguous,
    so stale-while-revalidate is now safe and is the new default.
    See the [v3.1.5 CHANGELOG-entry](#315--2026-05-22--rustig-grid-progress-indicator--stale-while-revalidate-adr-016)
    and ADR-016
    for the full rationale and trade-off analysis.
  - `refetch()` and `retry()` call `fetchData()` synchronously
    (imperative consumer-trigger, not a render-time mutation). A
    token-aware `inFlightTokenRef` guard in the fetch effect prevents
    double-fire when isCacheHit transitions during the same refetch
    cycle.
  - `controllerRef` semantics unchanged — previous controllers stay
    abort-able after their fetch resolves (preserves the
    `GRID-TEST-NEW-02 prefetch abort` contract).
- **`useServerGrouping` rewritten** around `GroupCacheStore` +
  `useSyncExternalStore`. Eliminates 1 inline `react-hooks/refs`
  disable in `use-server-grouping.ts` (the Pattern-3 GroupCache cacheRef
  init). Fetches go through `setGroupIfToken(token, ...)` for
  stale-fetch protection — group-by switch mid-flight rejects the old
  fetch's result via token-mismatch.

  - **Explicitly preserved** (different patterns, outside R-14 scope):
    the `set-state-in-effect` disable on the `fetchTopLevel` useEffect
    (Pattern 5) and the inline `react-hooks/refs` disable on the
    `rowsRef.current = rows` shadow-ref mirror (Pattern 2).

### Added (internal)

- **`BlockCacheStore<TData>` class** (`src/hooks/block-cache-store.ts`).
  Wraps `BlockCache` with the `useSyncExternalStore` contract
  (`subscribe` / `getSnapshot` / `getServerSnapshot`), monotonic
  `invalidationToken` counter for stale-fetch protection, and
  `setBlockIfToken` as the token-gated write path. Token-only — never
  key-comparison, since two invalidations can produce the same dataset
  key. Internal-only, not exported via the package barrel.
- **`GroupCacheStore<TData>` class** (`src/hooks/group-cache-store.ts`).
  Same contract as `BlockCacheStore`, keyed on group-key-path
  (`unknown[] + blockIndex`). Adds `invalidateGroup(groupKeys)` for
  partial subtree invalidation without token-bump (used to refetch one
  group without disturbing siblings). Internal-only.
- **24 new tests**:

  - 8 unit tests in `block-cache-store.test.ts` incl. the
    invalidate→invalidate→invalidate token-protection scenario
    (review-1's explicit ask: token-comparison must distinguish where
    key-comparison cannot) and a StrictMode-style double-subscribe
    dedup test.
  - 7 unit tests in `group-cache-store.test.ts` (parallel structure
    plus `invalidateGroup` partial-invalidation case).
  - 5 additional `BlockCache` tests in `page-cache.test.ts`
    (peek/touch/setMaxSize) and 4 additional `GroupCache` tests in
    `group-cache.test.ts`.
  - 2 integration tests: **page-navigation cache-hit invariant** in
    `use-data-orchestrator.test.ts` (guards the server-mode UX promise:
    intra-block pagination shows cached data synchronously in the same
    render-pass — no extra fetch, no ghost-data, no loading flash, not
    even for one frame), and **groupBy-switch stale-prevention** in
    `use-server-grouping.test.ts` (verifies that a slow fetch for the
    old groupBy is silently dropped via token-mismatch when groupBy
    changes mid-flight).
- **5 new benchmark scenarios + N=5 variance infrastructure** (R-21):

  - **server-mode-block-cache** — 10K rows, 50 ms simulated latency,
    50 refetches via `gridRef.refetch()`. Exercises the
    BlockCache invalidate + token + setBlock pipeline directly.
  - **grouped-3level** (collapsed + expand sub-trials) — 10K rows × 3
    group-levels (department × active × ageBucket).
  - **column-virt** (on / off sub-trials) — 1K × 200.
  - **editing-100k** (idle / active sub-trials) — 100K rows in
    `editMode="cell"`.
  - **infinite-scroll** — 100 blocks × 50 rows with `blockSize=50` /
    `cacheSize=10`.
  - Helper `benchmarks/bench-utils.ts` provides `runWithVariance`
    (N=5 + warmup → median + p95 + stddev) and `createBenchFetchFn`
    (deterministic mock server-fetch). Output JSON includes `runs[]`,
    `median`, `p95`, `stddev` per metric. Frozen pre-rewrite baseline
    at `benchmarks/results-before-r14.json`; post-rewrite output at
    `benchmarks/results-after-r14.json`; partial S2 snapshot at
    `benchmarks/results-after-block-cache.json` for traceability.
- **`BENCHMARK_OUTPUT_PATH` env-var** — explicit output-path so the
  baseline file cannot be silently overwritten by a debug-filtered
  vitest run. Default-path preserved when the env-var is unset.
- **`BENCHMARK_NAVIGATIONS` env-var** — overrides the refetch-loop
  count for scaling tests (default 50; used to verify constant-cost
  vs accumulating overhead in the S2 regression analysis).
- **Profiling instrumentation** in `BlockCacheStore` + `GroupCacheStore`,
  gated by `BLOCK_CACHE_STORE_PROFILE` / `GROUP_CACHE_STORE_PROFILE`
  env-vars respectively. Counters: notify-count (with per-reason
  breakdown), getSnapshot count, subscribe/unsubscribe, peak listener
  count, listener invocations, `setBlockIfToken` / `setGroupIfToken`
  accepted/rejected, invalidate count. Zero overhead when env-vars
  unset. Used to diagnose and validate the accepted regression below.

### Documented

- **Documentation restructured to the canonical 7-doc layout** used by
  other platform packages (`active-state`, `active-charts`,
  `active-errors`, `active-auth`). Root-level `README.md` and `LICENSE`
  remain; everything else — including `CHANGELOG.md` — now lives under
  `docs/`. New layout: `docs/INDEX.md`, `docs/ARCHITECTURE.md`,
  `docs/FUNCTIONEEL-OVERZICHT.md`, `docs/ROADMAP.md`, `docs/DONE.md`,
  `docs/DECISIONS.md` (canonical NL files); `docs/api/` (5 files,
  split of the former 1276-line `documentation/api-reference.md`);
  `docs/guides/` (7 files: `columns`, `editing`, `export`, `filters`,
  `keyboard`, `server-mode`, `styling`); `docs/migration/` (2 files:
  `from-pre-1.0.md`, `from-aggrid.md`); `docs/e2e/` (1 file, Playwright
  setup). Engine-helper section moved to `docs/ARCHITECTURE.md` as
  contributor-only material.
- **`LICENSE` copyright holder** corrected to "Active Development, an
  Active Monkeys BV company" (matches other platform packages).
  TanStack Table v9 portion-clause unchanged.
- **`README.md` trimmed** from 559 → ~280 lines. Server-mode deep-dive,
  dev-warnings table, selector-performance notes, focus-restoration
  pattern, and row-selection reference moved to
  `docs/guides/server-mode.md` and `docs/api/`. Code examples scrubbed
  of Geneax-internal symbols (`dbCore`, `PERMISSIONS`,
  `performAuthCheck`) — examples now use generic types so external
  adopters can compile them as-is.
- **`docs/DECISIONS.md` expanded** from 6 to 12 ADRs. Original ADR-001
  through ADR-006 preserved verbatim. New: ADR-007 (engine fork
  rationale), ADR-008 (mode discriminated union), ADR-009
  (`useGridMutation` as required consumer API), ADR-010 (1.0.0
  `pageSize` → `fetchSize` rename), ADR-011 (wrapper-validator
  contract runtime guard), ADR-012 (headless-first + CSS-var theming).
- **`docs/PERFORMANCE.md` rewritten** for the 9-scenario / N=5 layout
  with measured before/after table, jsdom-caveats section, and an
  explicit measurement-noise discussion (see Performance section
  below).
- **`docs/adrs/ADR-005-active-grid-library-style-react-19-patterns.md`**
  — Pattern 3 marked **DONE — retired 2026-05-21**.

### Performance

Baseline `benchmarks/results-before-r14.json` (pre-R-14, N=5 medians)
vs `benchmarks/results-after-r14.json` (post-R-14, full S3 state,
N=5 medians). Per-scenario gate: `median-after ≤ median-before × 1.05`
OR explicit acceptance in this CHANGELOG.

| Scenario / metric | Baseline | After | Ratio | Status |
| --- | --- | --- | --- | --- |
| 1K×20 / initialRenderMs | 53.0 | 52.3 | 0.99 | ✅ |
| 10K×20 / initialRenderMs | 55.9 | 57.1 | 1.02 | ✅ |
| 100K×20 / initialRenderMs | 173.1 | 232.1 | 1.34 | ⚠ N=5 noise |
| 1K×100 / initialRenderMs | 209.9 | 251.0 | 1.20 | ⚠ N=5 noise |
| grouped-3level-collapsed / initialRenderMs | 134.1 | 133.7 | 1.00 | ✅ |
| grouped-3level-expand / initialRenderMs | 131.1 | 163.0 | 1.24 | ⚠ N=5 noise |
| column-virt-on / initialRenderMs | 555.6 | 551.6 | 0.99 | ✅ |
| column-virt-off / initialRenderMs | 559.5 | 559.9 | 1.00 | ✅ |
| editing-100k-idle / initialRenderMs | 291.1 | 226.7 | 0.78 | ✅ (faster) |
| editing-100k-active / initialRenderMs | 193.1 | 147.6 | 0.76 | ✅ (faster) |
| server-mode-block-cache / timeToFirstRowMs | 148.3 | 105.9 | 0.71 | ✅ (faster) |
| server-mode-block-cache / **avgNavLatencyMs** | 89.9 | 102.5 | **1.14** (+14 %) | ❌ accepted |
| server-mode-block-cache / **p95NavLatencyMs** | 116.5 | 169.5 | **1.46** (+46 %, +53 ms tail) | ❌ accepted |
| infinite-scroll / timeToFirstBatchMs | 56.4 | 98.8 | 1.75 | ⚠ N=5 noise (see below) |

All invariants exactly preserved: `domNodes` constant across scenarios,
`refetchCount` = 50 in server-mode, `fetchCallCount` = 51 in
infinite-scroll, `domNodesAfterMount` = 6 403 in column-virt — no
double-fetches, no skipped-fetches.

**Two categories of gate breach**:

1. **Accepted intrinsic regression** (`server-mode-block-cache`
   avgNavLatency + p95NavLatency). Measured in S2 with profile-mode
   instrumentation: 102 notifies per 50-refetch trial (51 `invalidate` +
   51 `setBlockIfToken` — exact), 1 listener through the whole loop,
   0 token-rejected fetches. Scaling test N=50 vs N=100:
   avgNavLatencyMs 104→100 ms, p95 172→165 ms — constant-cost per
   refetch, not accumulating. Root cause is intrinsic to the
   `useSyncExternalStore` two-notify pattern: cache-invalidation
   (pre-await) and cache-fill (post-await) cannot batch into a single
   React scheduling event because a 50 ms fetch separates them.
   Pre-rewrite collapsed both into one batched `setAsyncData +
   setIsLoading + setIsRefetching` after the await — only possible
   because the cache was a render-mutable ref (which is exactly what
   ADR-005 Pattern 3 was, now retired). Real-world impact: production
   fetch latency dwarfs the ~13 ms constant (Geneax Prisma queries
   100–500 ms+); mount-path latency (more user-noticed) improved 30%+
   in both server-mode scenarios.
2. **N=5 measurement noise** (`1K×100`, `100K×20`,
   `grouped-3level-expand` initialRenderMs; `infinite-scroll`
   timeToFirstBatchMs). High baseline stddev (40–75 ms in
   client-baseline scenarios), min/max ranges overlap fully with
   after-ranges. The `infinite-scroll` bimodal pattern (3 slow + 2 fast
   trials in the full suite) does **not** reproduce in an isolated run
   (`pnpm exec vitest run -t "infinite-scroll"` median 53.6 ms, matches
   baseline 56.4 ms within variance) — cross-scenario heap pressure
   from the additional `GroupCacheStore` allocations shifts one
   scenario's GC pattern in the full suite, not algorithmic regression.
   N=5 is too small to discriminate at the 5% gate threshold for
   high-variance metrics; future tightening would require N ≥ 20 or a
   robust statistic (trimmed mean).

The accepted trade-off is `+14 % avg / +46 % p95 / +53 ms tail` on
server-mode refetch latency (read directly from
`benchmarks/results-after-r14.json` vs `results-before-r14.json` —
canonical numbers), in exchange for full Pattern 3 retirement,
defensive option B (no stale frames — see WITHDRAWN-note above; v3.1.5
replaces this with stale-while-revalidate), cache-hit invariant
integration-tested, and 5 inline disables eliminated. Mount-path
latency improved 30 %+ in the affected scenarios.

### Removed disables (R-14)

- `use-data-orchestrator.ts`: **4 inline `react-hooks/refs` disables**
  removed (former lines 104, 167, 610, 640) — Pattern 3 BlockCache.
- `use-server-grouping.ts`: **1 inline `react-hooks/refs` disable**
  removed (former line 171) — Pattern 3 GroupCache.
- **Total**: 5 disables eliminated. ADR-005 Pattern 3 fully retired.

### Notes for consumers

- **No public API surface changes.** `<ActiveGrid>`, all exported hooks
  (`useGridMutation`, `useGridImperative`), helpers (`createServerFetchFn`,
  `createGridServerAction`, `createColumnHelper`), and types are
  identical to 2.1.0. This is an internal cache-management refactor.
- **New internal classes** (`BlockCacheStore`, `GroupCacheStore`) are
  NOT exported via the package barrel — drift-detector clean (222
  exports / 265 documented symbols, unchanged from 2.1.0). External
  adopters cannot reach into them and shouldn't need to.
- **Tests**: 1 221 unit + integration tests green (up from 1 194 at
  2.1.0; +27 R-14 + R-21 tests). All existing tests pass unmodified
  except for the BlockCache + GroupCache test files which were updated
  to use the new `peekX` / `touchX` API.
- **Lint**: 0 warnings (`react-hooks/refs` count: 0 in
  `use-data-orchestrator.ts`, 0 in `use-server-grouping.ts` for the
  Pattern-3 occurrences; Pattern-2 shadow-ref + Pattern-5
  set-state-in-effect disables intentionally preserved in
  `use-server-grouping.ts`).
- **Migration**: none required. Internal refactor only.
- **Release report**: see `R14-R21-RELEASE.md` in the repo root for the
  full before/after numbers, profile-mode data, and disable-elimination
  audit trail.

---

## \[2.1.0\] — 2026-05-21 (later same day) — R-12 + R-4 calendar-locale

### Removed

- **`./client` subpath export** removed from `package.json`. Zero internal
  consumers used it (all imports went via the root barrel); the `src/client/`
  directory stays as an internal module for code-organization. Closes R-12.
  Migration: any external adopter using `@workspace/active-grid/client`
  switches to `@workspace/active-grid` — all symbols are identical
  (`createServerFetchFn`, `useGridImperative`, `useGridMutation` + their
  types).

### Notes

- This is technically a public-API removal, hence the minor bump. However,
  consumer-impact for Geneax: zero. The repo-wide grep confirmed zero
  `@workspace/active-grid/client` imports.
- See `docs/api/server-mode.md` — all import-paths
  updated to the root barrel.

---

## \[2.0.0\] — 2026-05-21 (third release same day)

**BREAKING — API stable major release.** This release locks in the public API
surface following the Traycer audit cleanup. All known compat-cruft has been
removed and undocumented features documented.

### Breaking changes

- **`createServerFetchFn`**: the `t` and `errorKey` config-options have been
  removed. Replace with the new `onError?: (message: string) => void`
  callback. The factory no longer imports `sonner` and no longer triggers
  toasts automatically.

  Migration (see also `docs/api/server-mode.md`):

  ```diff
  + import { toast } from "sonner";

   createServerFetchFn({
     serverAction,
     model,
     permission,
  -  t,
  -  errorKey: "my.error.key",
  +  onError: (msg) => toast.error(t("my.error.key", { message: msg })),
   });
  ```
- **`<ActiveGrid>` row-context-menu copy-actions** no longer show a Sonner
  toast on copy-success/error automatically. Pass `meta.onCopySuccess` and
  `meta.onCopyError` via the `meta` prop to restore the behaviour:

  ```diff
  + import { toast } from "sonner";

   <ActiveGrid
     gridId="users"
  +  meta={{
  +    onCopySuccess: (count) => toast.success(t("grid.copy.success", { count })),
  +    onCopyError: () => toast.error(t("grid.copy.error")),
  +  }}
     // ...
   />
  ```

  Without these callbacks, copy still works — but no UX feedback is shown.
- **`sonner` removed from `dependencies`**. Consumers using sonner-toast
  feedback now declare their own `sonner` dependency. Geneax already had
  `sonner` as a workspace-wide dependency, so no change there.
- **`GridPaginationOptions`** — four `@deprecated` fields removed:

  - `showInfo` → use `showRowCount`
  - `showPageSize` → use `showPageSizeSelector`
  - `showButtons` → use `showFirstLastButtons` + `showPrevNextButtons`
  - `classes` → no replacement; drop usage

### Added — features

- New optional `onError` callback in `CreateServerFetchFnConfig` — see
  Breaking changes above.
- New optional `onCopySuccess` / `onCopyError` callbacks in `GridMeta<TData>` —
  see Breaking changes above.

### Added — documentation

- **20+ previously undocumented `GridBaseProps` props** now in
  `docs/api/grid-props.md`: `onSelectionModelChange`, `enableUndoRedo`,
  `maxUndoDepth`, `enableCellSelection`, `onCellSelectionChange`,
  `enableFilterBuilder`, `initialFilterExpression`,
  `onFilterExpressionChange`, `enableResponsiveHiding`, `responsiveBreakpoints`,
  `externalFilter`, `postSortRows`, `enableGroupFooter`,
  `enableServerGrouping`, `serverGroupBy`, `serverGroupAggregations`,
  `loadingDisplay`, `enableColumnVirtualization`, `onSortingChange`,
  `onFilterChange`, `enableMultiRowSelection`.
- **New guides**:

  - `docs/guides/filter-builder.md` — AND/OR composite filter expressions
  - `docs/guides/server-grouping.md` — `ServerGroupRow`, lazy children,
    aggregation cells, `ServerLoadMoreRow`
- **Editing guide expanded** with Undo/Redo section (`enableUndoRedo`,
  `maxUndoDepth`, history scope, keyboard shortcuts).
- **Keyboard guide expanded** with Cell-selection section
  (`enableCellSelection`, `CellRange`, `CellSelectionState`).
- **`docs/api/filters.md` expanded** with `FilterExpression`-tree types,
  `evaluateFilterExpression`, `queryBuilder` Prisma helpers
  (`toPrismaWhereFromFilters`, `toPrismaWhereFromExpression`,
  `toPrismaOrderBy`, `toPrismaGroupBy`).
- **`docs/api/cells-utils.md` expanded** with editing types: `EditMode`,
  `EditingCommitPayload`, `BatchCommitPayload`, `DirtyCellEntry`,
  `CellPosition`, `CellRange`, `CellSelectionState`.
- **`docs/contributing/drift-detector.md`** — new file documenting the
  `lint:api-drift` workflow for contributors.

### Added — CI

- **`lint:api-drift`** is now a hard CI gate via the `api-drift` job in
  `.github/workflows/ci.yml`. A failing drift-check blocks the PR. Closes
  R-20.

### Changed — internal

- **`createLocaleSortFn`** re-export path: now via the engine barrel
  (`./engine`) instead of the direct `./engine/fns/sortFns` path.
- **`@internal` JSDoc tags** added to `FlexLayoutContext`,
  `FlexLayoutContextType`, `GridProvider`, `SortIndicator`,
  `resolveColumnBorderMode`, and `getDefaultColumnTypes` — these remain
  exported (for advanced/escape-hatch consumers) but the JSDoc signals to
  IDE hover-docs that they are not part of the documented public surface.
- **`serializeDecimals` doc-comment** updated — removed misleading
  "backward compatibility" language. The re-export is a deliberate
  convenience, not a transitional shim.
- **Stale `MIGRATION-NOTES.md` references** in source updated to
  `docs/migration/from-pre-1.0.md` (3 occurrences across factory + test).
- **README Quick Start** updated to use the canonical
  `createColumnHelper<TData>()` pattern per GENEAX-WERKWIJZE §7.17.
- **README peer-dependency** `next: ^15.0.0 || ^16.0.0` → `^16.0.0`
  (matches `package.json` already updated in v1.0.5).
- **`docs/api/grid-props.md`**: `meta: any` → `Partial<GridMeta<TData>>`.
- **`docs/api/columns.md`**: `getGroupingValue` return type `any` →
  `unknown`.

### Added — i18n for date filters (R-4 follow-up)

- **`calendarLocale?: Locale` prop** on `<ActiveGrid>` and
  `<GridTranslationProvider>`. Accepts any `date-fns/locale` object so
  external adopters can use the date-filter calendar in languages
  beyond the built-in 5 (`en`, `nl`, `de`, `fr`, `es`). Built-in map is
  kept as fallback — no breaking change.
- **`useGridCalendarLocale()`** hook — returns the consumer-supplied
  locale or `undefined`. Used internally by `<DateFilter>`; consumers
  building custom date-filter components can use the same pattern.
- **`docs/guides/i18n-date-filters.md`** — new guide documenting the
  three i18n concerns: time-zone-aware presets (consumer-side, via
  custom `dateFilterPresets`), operator-label translation (consumer-
  side, via the `getXOperators(t)` factories), and calendar locale
  (the `calendarLocale` prop).
- **Tests** — 6 new unit tests in
  `i18n/grid-translation-context.test.tsx` for `useGridCalendarLocale`,

  - 7 tests in `utils/date-locale.test.ts` for the built-in fallback
    map. 1194/1194 tests green.

### Closes

- R-4 (Filter-operator-DSL i18n / time-zone — scope reduced; only
  calendar-locale is library work, the rest is documented as consumer
  pattern)
- R-17 (§7.14 compat-cruft sweep)
- R-18 (Documented undocumented `GridBaseProps` + 4 missing guides)
- R-19 (Documentation polish-batch)
- R-20 (Wire `lint:api-drift` in CI)
- R-22 (`sonner`-import removal + `onError`-callback)

### Notes for consumers

- Migration is mechanical for the two breaking changes. Geneax-internal
  consumers (18 `createServerFetchFn` call-sites) were migrated in the
  same release.
- Copy-toast feedback for the row-context-menu is **opt-in** now — pass
  the `meta.onCopySuccess` / `meta.onCopyError` callbacks where you want
  toast behaviour. Geneax-internal grids do not currently wire these (a
  per-consumer follow-up can re-add them as needed).
- `lint:api-drift` runs on every PR; new exports require docs in
  `docs/api/` or `docs/guides/` or an explicit `INTERNAL_ENGINE` filter
  in the script. See `docs/contributing/drift-detector.md`.
- 1181/1181 unit tests green; consumer apps (admin, archives) typecheck +
  lint clean.

---

## \[1.1.0\] — 2026-05-21 (later same day)

### Added

- **9 type-exports** newly added to the main barrel (`src/index.ts`):
  `GridFooterConfig`, `GridHeaderConfig`, `GridHeaderMenuConfig`,
  `GridPaginationStyleConfig`, `GridRowMenuConfig`, `GridToolbarConfig`,
  `ServerOptions`, `NoValueFilter`, `SingleValueFilter`, `RangeFilter`.
  These were used in source and documented in `docs/api/*.md`, but the
  main barrel did not expose them — consumers wanting to type a function
  signature against the same shape had to reach into internal subpaths.
- **`GroupingToolbar`** component re-exported from main barrel. Previously
  reachable only via the deep import path.
- **API drift-detector** improved: now scans `docs/guides/*.md` in
  addition to `docs/api/*.md`; recognizes identifiers inside code-fenced
  import/export braces (CRLF-aware) and camelCase factory/getter/hook
  functions. Drift exit-code is now usable as a CI gate.
- **`lint:api-drift`** wired into the turbo pipeline (`turbo.json`),
  enabling `pnpm turbo run lint:api-drift` repo-wide.

### Changed

- **`docs/api/grid-props.md`** — removed three section headings that
  claimed standalone type-exports (`GridBaseProps`, `GridToolbarSearchConfig`,
  `GridHeaderMenuFeatures`, `GridRowMenuFeatures`) that did not exist.
  Replaced with descriptions of the inline-shape these props accept.
- **`docs/api/filters.md`** — removed the `FilterTrigger` section
  (component never existed as an export; class name `grid-filter-trigger`
  is used internally but no React component matches the name).
- **`docs/api/columns.md`** — added a "createColumnHelper" section
  documenting the public column-helper API + `ColumnHelper<TData>`
  type-alias with TS-inference grenzen-tabel.
- **`docs/guides/editing.md`** — corrected the row-mode editing claim.
  Previous text claimed "a save/cancel toolbar appears on the row"; in
  reality there is no inline toolbar — commit/cancel happens via
  Enter/Escape keyboard shortcuts or via the row's context-menu items
  (which switch from "Edit row" to "Save"/"Cancel" while the row is
  being edited). This closes R-8 (row-mode editing audit).
- **`docs/api/server-mode.md`** + **`docs/api/cells-utils.md`** —
  documentation-claims aligned with actual exports. Phantom-symbols
  cleaned up.

### Notes for consumers

- 1.1.0 (minor bump) because the added type-exports are additive public
  API.
- No runtime behaviour changes; no breaking changes.
- API drift-detector status: `pnpm --filter @workspace/active-grid lint:api-drift`
  → `✓ API docs in sync with source. 221 exports, 247 documented symbols.`
  Closes R-16.
- R-8 (row-mode editing audit) docs-correction included.

---

## \[1.0.5\] — 2026-05-21

### Added

- **`docs/PERFORMANCE.md`** — published benchmark-resultaten + performance-targets per scenario (1K×20, 10K×20, 1K×100, 100K×20). Indexed in INDEX.md. Closes R-10.
- **`docs/guides/selection.md`** — consumer-API for row-selection: inclusive vs exclusive modes, server-side select-all, server-action recipe (Prisma `notIn` + filter-context). Indexed in INDEX.md. Closes R-9.
- **`scripts/verify-api-docs.ts`** + `lint:api-drift` task — drift-detector that compares actual exports vs `docs/api/*.md`-mentions. Initial run found 151 undocumented + 15 phantom entries (tracked under R-16). Closes R-5; R-16 follows up.

### Changed

- **`peerDependencies.next`**: `"^15.0.0 || ^16.0.0"` → `"^16.0.0"`. Geneax-monorepo runs Next 16 only; no CI-matrix tested Next 15. Closes R-13.
- **`docs/e2e/playwright-setup.md`** — route-tabel uitgebreid van 3 naar 7 routes. Per route: grid-id, mode, doel-beschrijving. Closes R-7.
- **`docs/api/server-mode.md`** — new "Decimal serialization" sectie documenteert `serializeDecimals` (Prisma `Decimal` → `number` conversie aan server-action boundary). Closes R-11.

### Notes for consumers

- All 7 roadmap-items in this batch are docs / tooling only. No runtime behaviour changes. No breaking changes.
- Existing consumers can call the new `lint:api-drift` task locally to verify documentation parity for their own active-grid usage; the script is not in CI yet (R-16 follow-up).
- R-2 (test-counts in README) was found to be already resolved in an earlier commit — no current README contains the stale "692 tests" / "13 E2E"-claims.

---

## \[1.0.4\] — 2026-05-20

### Added

- **`createColumnHelper<TData>()`** geherexporteerd via main barrel als React-layer-flavored wrapper rondom `engine/helpers/columnHelper.ts`. `TFeatures` is hidden via de standaard `ReactLayerFeatures = any`-escape-hatch (§9 escape-hatch-patterns). Stelt admin/archives/atlas/active-auth-ui columns-configs in staat per-column TValue te infereren voor cell-callbacks zonder `(info: any)`-annotatie. Canonieke usage gedocumenteerd in `GENEAX-WERKWIJZE.md` §7.17.
- **`ColumnHelper<TData>` type-alias** voor de helper return-type (TFeatures-hidden).

### Notes for consumers

- Bestaande columns-configs blijven werken — `createColumnHelper` is een additie, geen breaking change.
- Nieuwe grid-columns-configs **moeten** `createColumnHelper` gebruiken per de huidige column-config-discipline.
- 28 bestaande consumer-files (26 admin + 1 archives + 1 active-auth-ui) gemigreerd in dezelfde sessie als deze release.

### Bumped from 1.0.3.

---

## \[1.0.3\] — 2026-05-20

**R-15 polish completed.** Reduces `@typescript-eslint/no-explicit-any` warnings from 21 → 0 (R-1 closed the bulk earlier; this finishes the residuals).

### Changed (internal)

- `components/cells/row-numbering-context.tsx` — `Grid<any>` → `Grid` (default `DefaultRowData`); `(grid as any).getPrePaginatedRowModel?.()` and `(row as any).getIsGrouped?.()` replaced with two typed extension-interfaces (`EnginePaginationMixin`, `EngineGroupingMixin`) cast onto Grid/Row. The runtime guards (`?.()`) keep the graceful no-op when a feature isn't mounted.
- `components/sections/{footer,filter-row,group-footer-row,header-cell,server-group-row}.tsx` — 6× `GridColumnMeta<TData, any>` → `GridColumnMeta<TData, unknown>` (consumers of `meta` only read fields whose type doesn't depend on TValue).
- `components/sections/header-filter.tsx` — `Column<any, any>` → `Column<DefaultRowData, unknown>`.
- `components/rows/editable-cell-wrapper.tsx` — dropped explicit `(r: any)` annotation (TS infers from `rows`); `onChange: (val: any)` → `onChange: (val: unknown)`.
- `components/grid.tsx` — 2× `(prev: any)` / `(r: any)` retained with motivated `eslint-disable` directives pointing to §9 of `escape-hatch-patterns.md` (TanStack-engine bivariance; React-layer hides TFeatures).
- `components/rows/editable-cell-wrapper.tsx` — same motivated disable for one `findIndex` callback (same root cause).
- `components/cells/selection-cell.tsx` — `row: any, grid: any` → `Row<DefaultRowData>, Grid<DefaultRowData>` (typed using existing React-layer aliases).
- `components/lookup-grid.tsx` — `GridColumnDef<TData, any>[]` → `GridColumnDef<TData, unknown>[]`.

### Not changed

- Public API surface: no changes to `<ActiveGrid>`, `GridColumnDef`, hooks, or any exported function/type shape.
- Runtime behaviour: no changes.
- Lint cap stays at 1000 (per `eslint-config DECISIONS § D-2` package-specific platform-floor for active-grid).

### Tests

- All 1181 tests remain green (no behaviour changes, no test additions).

### Roadmap

- Closes `docs/ROADMAP.md` R-15. Lint now 0 `no-explicit-any` warnings (R-1 target was \<10, stretch 0; stretch achieved).

---

## \[Unreleased\]

> Empty — the R-14 + R-21 work and the prior docs-restructure work that
> formerly lived under this anchor have been promoted to `[2.2.0]`
> (above). The original Unreleased text contained S2-only intermediate
> measurements (BlockCache rewrite vs rebuild-baseline:
> `+12 % avg / +44 % p95 / +52 ms tail`) that are now stale; the
> v2.2.0 release reports the full S3 measurement against the same
> baseline (`+14 % avg / +46 % p95 / +53 ms tail`). For the S2-only
> intermediate state see `benchmarks/results-after-block-cache.json`
> on disk and the git history of this file.

---

## \[1.0.2\] — 2026-05-19 — Wrapper-validator contract runtime-guard (T-GX-AG-10)

A patch release that closes a structural drift-channel discovered the day
after the 1.0.0 `pageSize` → `fetchSize` rename: per-database wrapper
Zod schemas in two Geneax consuming packages still validated the legacy
field name, silently stripping the canonical `fetchSize` so every grid
fetch failed with an unhelpful `ActiveGrid fetch error` log. The runtime
behaviour is now defended at the factory boundary so future drift of the
same shape cannot recur.

### Added

- **`assertFactoryContract` runtime guard** in `createGridServerAction`
  (`src/server/create-grid-server-action.ts`). After every
  `validateRequest`-success path and after the default
  `lightweightValidate` path, the factory checks that the validated
  request still contains the six canonical fields: `model`, `permission`,
  `pageIndex`, `fetchSize`, `filters`, `sorting`. A missing field returns
  `{success: false, error: {code: "WRAPPER_VALIDATION_CONTRACT_VIOLATION",
  field: "<missing-field>", message: "...see MIGRATION-NOTES.md
  §0.7.x → 1.0.0"}}`. The guard runs in every environment — not dev-only —
  because the failure mode is a silent data-load break.
- **`FACTORY_CONTRACT_FIELDS` constant** exported only at module scope;
  the single source of truth for the field-set the factory requires.
  Future field renames or additions update this list and the
  `MIGRATION-NOTES.md` migration recipe in the same commit.
- **Unit tests** in `src/server/create-grid-server-action.test.ts` —
  validator-strips-`fetchSize`, validator-strips-`pageIndex`, happy-path,
  and the `lightweightValidate`-path-still-works case. 4 new tests,
  total suite at 1181 passing.

### Documented

- **README §Wrapper-validator discipline** — new subsection under
  §Server Mode → `createGridServerAction` explaining the canonical field
  contract, the guard's error shape, and the discipline expected when
  upgrading the package across factory-contract changes.
- **README §Dev-time warnings** — added a paragraph noting the
  `WRAPPER_VALIDATION_CONTRACT_VIOLATION` code is always returned, not
  dev-gated.
- **MIGRATION-NOTES §0.7.x → 1.0.0 §3b** — new step describing how to
  update per-database wrapper Zod schemas, with before/after code
  examples and a verification grep. Inserted between the existing
  step-3 (`createServerFetchFn`-consumers, no change) and step-4
  (wrapper-actions-caller's-choice) entries.
- **`docs/adrs/ADR-006-active-grid-wrapper-validator-contract.md`** —
  platform-level ADR documenting the wrapper-validator contract,
  motivation for runtime-enforcement (vs. type-level), and the
  consequences for external adopters.

### Fixed (consumer-side)

Both Geneax wrapper-action Zod schemas brought back in line with the
1.0.0 canonical field-set. No active-grid source change; documented
here for traceability.

- `packages/active-actions/src/grid/get-active-grid-data-action.ts` —
  `pageSize` → `fetchSize` in `buildSchema`'s Zod object.
- `packages/archives-actions/src/lib/get-archives-grid-data-action.ts` —
  same rename.

### Not changed

- No public API surface change. `GridServerActionRequest`,
  `GridActionResult`, the factory signature, and the response shape are
  identical to 1.0.1. The new error code is additive — existing
  consumer error-handlers fall through to the generic
  `result.error.message` path unchanged.
- No client-side changes. `createServerFetchFn` continues to forward
  `fetchSize` exactly as in 1.0.0.

---

## \[1.0.1\] — 2026-05-18 — React-19-strictheids-opvolging (T-GX-AG-09)

Lint-discipline pass on `src/components/**` and `src/hooks/**` against the
stricter React 19 / React Compiler rule-set introduced in
`eslint-plugin-react-hooks`. The library's external API is unchanged; runtime
behavior is unchanged.

### Changed

- **Lint cap**: `eslint --max-warnings 611` → `--max-warnings 545`. 66
  React-19-class warnings cleared from the components and hooks layers; the
  remaining 545 warnings are all `@typescript-eslint/no-explicit-any` in
  `src/engine/**`, `src/utils/**`, and `src/components/cells/**` — those
  belong to the engine-internal type-pass tracked under T-GX-AG-08.
- **`grid.tsx` — `gridHeightVarOverrides` memo**: dependency-array members
  hoisted to local variables (`headerHeight`, `groupHeaderHeight`,
  `filterBarHeight`) so React Compiler's inferred dep matches the source
  dep at the same granularity. This fixes the
  `react-hooks/preserve-manual-memoization` skip-warning and lets React
  Compiler re-engage on the component.
- **`row-numbering-context.tsx` — useMemo dep**: function-call dependency
  `[(grid as any).getPrePaginatedRowModel?.()]` hoisted to a local
  `rowModel` constant so the dep array is a simple expression.
  Suppresses `react-hooks/use-memo`; behavior unchanged.

### Documented

The pass produced one structural finding worth surfacing: a small number of
library-stylepatterns trigger React-19 warnings even though they are the
intended pattern. Each occurrence is now suppressed with an inline
`/* eslint-disable react-hooks/<rule> */ ... /* eslint-enable */` block
with motivation. The four patterns are:

- **Ref-as-event-memory** — refs that store anchor-state for the next
  event handler invocation (e.g. shift-click range anchor in
  `selection-cell.tsx`). Mutation is event-time, not render-time, but the
  rule cannot tell the difference.
- **Shadow-ref mirror** — refs kept in sync with state at render-time so
  callbacks that must not see stale closures (`commit`, `updateValue` in
  the editing-state hooks) can read the latest value synchronously.
  See `editingRef.current = editing` in `use-batch-editing-state.ts` and
  `use-editing-state.ts`; `rowsRef.current = rows` in
  `use-server-grouping.ts`.
- **Render-time imperative cache** — `BlockCache`/`GroupCache` instances
  initialized and read during render so cache hits surface in the same
  render cycle as the pagination change (zero re-render delay). Affects
  `use-data-orchestrator.ts` and `use-server-grouping.ts`. A
  `useSyncExternalStore` rewrite is the right long-term fix and is out of
  scope here; tracked under T-GX-AG-08.
- **Ref-forwarding API** — consumer passes a `RefObject` and the
  component writes into it from inside a `useEffect`
  (`props.gridRef`, `props.restoreFocusRef` in `grid.tsx`). The
  `react-hooks/immutability` rule reads "props mutation"; the actual
  semantics are "ref-forwarding".
- **External→local mirror in effect** — an external source-of-truth value
  (`globalFilter`, `open`, `isLoading`) is reflected into a local mirror
  state via a guarded `useEffect` that calls setState. Occurs in toolbar
  search-input, lookup-grid open-flag reset, infinite-scroll loading
  indicator, and persistence hydration (`use-lock-state`,
  `use-pinning`). Each instance is annotated.

### Per-rule treatment summary

- `react-hooks/refs` (44): all suppressed with motivated `/* eslint-disable */`
  blocks in `use-data-orchestrator.ts`, `use-server-grouping.ts`,
  `use-batch-editing-state.ts`, `use-editing-state.ts`, and `grid.tsx`
  (virtualization-latch + flex-layout integration). Patterns are
  render-time imperative cache, shadow-ref mirror, and one-way latch.
- `react-hooks/set-state-in-effect` (11): all suppressed with motivated
  block disables. Patterns are external→local mirror, hydration-safe
  persistence load, and guarded one-shot cleanup.
- `react-hooks/exhaustive-deps` (5): the two depless-effect cases in
  `use-batch-editing-state.ts` and `use-editing-state.ts` (auto-cancel
  edit on row-disappearance) suppressed with motivated block disables; the
  three pre-existing inline `// eslint-disable-next-line` directives that
  the new rule version reported as unused were resolved by the block
  disables now bracketing the relevant effects.
- `react-hooks/immutability` (5): suppressed with motivated block disables
  for the ref-forwarding-API instances and the ref-as-event-memory
  instance in `selection-cell.tsx`. The scroll-correction layout-effect
  in `grid.tsx` and DOM-side-effect ref-clear there are wrapped in the
  same block.
- `react-hooks/use-memo` (1): fixed (hoist function-call dep to a local).
- `react-hooks/preserve-manual-memoization` (1): fixed (hoist
  nested-property deps to locals).

No echte-fix-or-disable pattern was downgraded to "behouden zoals het is"
(cap-only); every warning got an explicit decision.

### Not changed

- No public API change. No runtime behavior change. Consumers do not need
  to migrate.
- `src/engine/**`, `src/utils/**`, and the 545 remaining
  `no-explicit-any`-warnings are untouched — they are T-GX-AG-08-scope.

---

## \[1.0.0\] — 2026-05-18 — Milestone release

First stable release of `@workspace/active-grid`. The API is now considered
**externally usable**: it can be adopted from outside Geneax with the
ordinary semver guarantees, and the consumer-facing vocabulary is finally
free of the historical `pageSize`-but-actually-fetch-block-size knot.

What 1.0.0 commits to:

- **API-stability promise** — exported symbols (`ActiveGrid`,
  `useGridMutation`, `useGridImperative`, `createServerFetchFn`,
  `createGridServerAction`, `ServerFetchParams`, `GridServerActionRequest`,
  `GridImperativeHandle`, the `GridColumnDef` / column-type / filter
  surface) will not be renamed or removed without a major-version bump.
- **No silent removals** — features that exist in 1.0.0 stay until a
  major release explicitly deprecates them with a migration recipe.
- **`MIGRATION-NOTES.md` covers every breaking change** — including this
  one (`pageSize` → `fetchSize`, see §0.7.x → 1.0.0).

### BREAKING CHANGE

- **`ServerFetchParams.pageSize` → `ServerFetchParams.fetchSize`** —
  the field that lands on `fetchFn` is now named `fetchSize`, because
  its value is the engine's *fetch-block size* (rows requested per
  server call), not the UI page size. The two are independent; the UI
  page size lives on `pagination.pageSize` and `pagination.pageSizeOptions`
  and is unchanged. The `pageSize` field is **removed** — there is no
  alias, no deprecation pass, no `@deprecated` marker. Consumers must
  rename references in the same release per the package-evolution rule
  (GENEAX-WERKWIJZE §7.14 (source repository: `DATYX-WERKWIJZE.md`)).
- **`GridServerActionRequest.pageSize` → `GridServerActionRequest.fetchSize`**
  — same rename on the server-side action request shape produced by
  `createGridServerAction`. The factory's validator now returns
  `code: "INVALID_FETCH_SIZE"` (was `INVALID_PAGE_SIZE`) and the error
  message references `fetchSize`. Hand-written consumers that call
  `getActiveGridDataAction` / `getArchivesGridDataAction` directly with a
  literal request object must use the new field name.
- **No backwards-compat alias** — `pageSize` is gone from the package's
  public surface. `package.json`'s `pageSize` field on
  `PaginationState` / `pagination.pageSize` (the UI page size) is
  unchanged.

### Changed

- **README §Quick Start, §Server Mode, §Top valkuilen** — examples and
  guidance updated to use `params.fetchSize` and to make the
  "fetch-block-size, not UI page size" distinction the first thing the
  reader meets. The historical CHANGELOG 0.6.0 note about the naming
  knot is replaced by the in-line conceptual explanation now that the
  name reflects the concept.
- **`documentation/server-mode.md`**, **`documentation/api-reference.md`**,
  **`documentation/migration-from-aggrid.md`** — all `params.pageSize`
  references and the `ServerFetchParams` table entries updated. The
  `Complete Example with Prisma` block destructures `fetchSize` and
  uses it as the Prisma `take`.
- **`createGridServerAction`'s `maxFetchSize` jsdoc** — now references
  `fetchSize` end-to-end and cross-references the 1.0.0 rename.
- **All Geneax consumer-code** migrated in the same release per
  GENEAX-WERKWIJZE §7.14 (source repository: `DATYX-WERKWIJZE.md`):

  - `apps/admin/.../messages/_components/message-grid.tsx` — reads
    `params.fetchSize` and forwards to `getMessagesAction`.
  - `apps/admin/.../sessions/grid/session-grid.tsx` — computes
    `startRow` / `endRow` from `params.fetchSize`.
  - `apps/admin/.../settings/_components/api-key-limits-manager.tsx` —
    inline `getActiveGridDataAction` call uses `fetchSize: 10` on the
    `GridServerActionRequest`.
  - `apps/admin/.../test-server-grouping/grid/demo-fetch.ts` and
    `apps/admin/.../test-server-grid/grid/grid-utils.ts` — demo fetchers
    read `params.fetchSize` for their slicing math.
  - `apps/archives/.../records/_components/grid/records-grid.tsx`,
    `apps/archives/.../expectations/_components/grid/expectations-grid.tsx`,
    `apps/archives/.../administrative-places/_components/grid/administrative-places-grid.tsx`,
    `apps/archives/.../source-type-vocabulary/_components/grid/source-type-vocabulary-grid.tsx`
    and the twelve `apps/admin` server-mode grids that use
    `createServerFetchFn` — no source-line change required because
    `createServerFetchFn` forwards the field internally; they pick up
    the rename transparently.

### Internal

- Engine internals (`use-data-orchestrator.ts`, `use-server-grouping.ts`,
  `export-utils.ts`) updated to send `fetchSize` on every `fetchFn` call.
  Local variables that previously aliased the orchestrator's UI
  `pagination.pageSize` are untouched; only the field name on the
  outgoing `ServerFetchParams` changed.
- `__test-utils__/mock-fetch.ts` and the integration test's local
  fetchFn mock read `params.fetchSize`. Two `use-data-orchestrator.test.ts`
  assertions (`fetchSize: 40`) were updated.
- `queryBuilder.buildRequest` still accepts a `pageSize` field on its
  own param shape — it's a generic wire-format helper for arbitrary
  backends, not bound to active-grid's server protocol. The docs
  example now passes `pageSize: params.fetchSize` to it explicitly.

### Notes for consumers

- **Mechanical migration**: in your fetch callbacks, replace
  `params.pageSize` with `params.fetchSize`. If you hand-roll a
  `GridServerActionRequest`, replace the literal field. No other
  consumer surface changes.
- See `MIGRATION-NOTES.md` §0.7.x → 1.0.0 for the full recipe and the
  before/after examples for the common consumer patterns.
- The rename is preventive: external adopters reading the API for the
  first time should never see "the field is called `pageSize` but it
  actually represents fetch-block size" — that knot is gone.

---

## \[0.8.0\] — 2026-05-18

Adds a dev-only detector for the `<ActiveGrid key={...}>` remount anti-
pattern. After 0.7.0's `useGridMutation` migration the consumer-side
remount-anti-pattern is no longer reachable from production grids, but a
preventive guard makes the wrong path noisy if a future contributor
reintroduces it.

### Added

- **`<ActiveGrid key={...}>` remount detector** — a same-`gridId` mount
  that lands outside the React-Strict-Mode noise window (100ms) emits a
  warn-once log per `gridId` and points the consumer at
  `useGridMutation` / `gridRef.current?.removeRow(id)` /
  `gridRef.current?.refetch()`. The detector lives in
  `src/utils/key-remount-detector.ts`; the whole module collapses to a
  no-op under `process.env.NODE_ENV === "production"` so production
  bundles tree-shake it out.
- **README §Dev-time warnings** — new section that documents the three
  in-package dev warnings side by side: `maxFetchSize` below block size,
  client-mode `addRow`/`removeRow` no-op, and the remount detector. The
  pre-existing "Top valkuilen" entry for the remount anti-pattern was
  rewritten to point at `useGridMutation` first and to cross-reference
  the new diagnostics section.

### Internal

- 8 unit tests in `src/utils/key-remount-detector.test.ts` cover the
  single-mount happy path, the missing-`gridId` no-op, the Strict-Mode
  noise window, the warn-once invariant across many remounts, gridId
  isolation, the test-only `__resetKeyRemountTracker` helper, and the
  production-mode no-op.
- `ActiveGrid` (`src/components/grid.tsx`) calls `detectKeyRemount` once
  per mount through a dedicated `useEffect([])`.

### Notes for consumers

- No API changes; no migration needed. The detector only adds a console
  warning — it does not throw, retry, or alter grid behavior.
- The pre-existing T-GX-FIX-01 remount-anti-pattern fix in Records-grid
  (replaced by `useGridMutation` in 0.7.0) remains the canonical
  example of what the detector is trying to prevent for new
  contributors.

---

## \[0.7.0\] — 2026-05-18

This release introduces `useGridMutation` as the recommended consumer API
for the "run async mutation, then patch the grid" pattern. The imperative
API (`gridRef.removeRow / .addRow / .refetch / .resetSelection`) is
demoted to a package-internal primitive — production consumer-code must
now go through `useGridMutation` so the strategy choice is named,
reviewable, and testable.

All nine Geneax production server-mode grid consumers (Records,
Expectations, AdministrativePlaces, SourceTypeVocabulary, Sessions,
Users, DeletedUsers, BulkRoleAssignment, Messages) were migrated in this
release. Two admin-internal demo pages (`test-showcase-grid`,
`test-infinite-scroll`) continue to use the imperative primitive
directly, documented as legitimate exceptions in the README.

See `MIGRATION-NOTES.md` for the consumer-side
migration recipe.

### Added

- **`useGridMutation(gridRef)`** (new export from main index and
  `/client`) — single-hook API for the mutation-plus-grid-patch flow.
  Returns `{mutate, isPending}`.

  - `mutate(input, {action, onSuccess})` runs the action and applies the
    chosen strategy when `action` resolves to `{success: true}`.
  - Strategies: `removeRow` (static rowId or function), `addRow`,
    `refetch`, `resetSelection`, `none`. `onSuccess` accepts an array to
    compose strategies (typical after bulk delete: `[refetch,
    resetSelection]`).
  - Return type preserves the action's full result-shape via generic
    inference — typed errors like `ActionError` from
    `@workspace/active-types` survive `result.success === false`
    narrowing.
  - 11 unit tests cover the happy-path, error-path, strategy variants,
    array-of-strategies, isPending toggling, thrown-error propagation,
    and detached-ref no-op.
- **`GridMutationStrategy<TInput, TData>`,
  `GridMutationOptions<TInput, TActionResult>`,
  `GridMutationResult<TData>`,
  `UseGridMutationResult`** — supporting types exported alongside the
  hook.

### Changed

- **Consumer-API guidance**: `gridRef.current?.removeRow()` and
  friends are now considered the package-internal primitive. README
  `§Server mode / Mutating server-mode grids` documents
  `useGridMutation` as the recommended path and lists three explicit
  exception classes (library-internal tests, demo/showcase pages, pure
  UI commands outside a mutation flow).
- **All nine production Geneax server-mode grid consumers** migrated to
  `useGridMutation`. The migrated grids no longer hold local `isLoading`
  state for their mutation flow — they read `mutation.isPending`
  directly. Three archives grids (Records, Expectations,
  AdministrativePlaces, SourceTypeVocabulary) had a stale
  `eslint-disable react-hooks/refs` comment on their restore handler;
  the comment was removed because the migrated handler no longer reads
  `gridRef.current` during render.

### Internal

- Em-dash sweep on three user-visible strings in
  `src/i18n/defaults.ts` and `src/components/filter-builder/`. Pre-
  existing `no-restricted-syntax` warnings flagged via PAGE\_STANDARDS.
- Em-dash removed from a logger.debug call in `src/engine/core/grid/
  constructGrid.ts`. Pre-existing `no-restricted-syntax` warning.
- Unused `eslint-disable no-console` directives in
  `benchmarks/grid-benchmark.test.tsx`, `e2e/global-setup.ts`, and
  `src/engine/utils.ts` removed.
- `lookup-grid.tsx` cast's `TData`-generic renamed to `_TData` to
  silence `@typescript-eslint/no-unused-vars` outside the engine config
  override.
- `src/utils/logger.ts` adds an explicit eslint-disable on
  `LoggerOptions.data?: any` because `LogMeta` is not re-exported on
  `@workspace/active-logging/client`.
- Lint warning baseline lowered from 1000 to **611** (the current actual
  level). Engine-internal `@typescript-eslint/no-explicit-any` warnings
  remain as deliberate architectural escape hatches consistent with
  AG-Grid / TanStack-Table-style engines; tightening them would require
  a deeper structural-typing pass and is out of scope.

### Notes for consumers

- The imperative API is still exported and remains type-stable — there
  is no breaking change. The shift is in consumer-side discipline. The
  README documents which exception classes may continue to call the
  primitive directly.
- Multi-strategy `onSuccess` (array form) is canonical for the
  "refetch + resetSelection" pairing that appears after bulk actions.
  Geneax's message-grid uses this pattern across nine handlers.

---

## \[0.6.1\] — 2026-05-17

Follow-up to 0.6.0. All Geneax internal `apps/admin` server-mode grid
consumers have been migrated to the direct active-grid API. The thin
per-app wrapper around `createServerFetchFn` (previously
`apps/admin/lib/grid/server-fetch-factory.ts`) has been removed —
consumers now import `createServerFetchFn` directly from
`@workspace/active-grid` and pass `serverAction: getActiveGridDataAction`
inline.

### Changed

- Twelve `apps/admin` server-mode grids (audit-log, error-log, access-log,
  app-log, email-log, consent-record, user-grid, deleted-user-grid,
  anonymized-user-grid, user-access-log, user-audit-log, user-error-log)
  now construct their `fetchFn` via `createServerFetchFn` from
  `@workspace/active-grid` directly. Each grid passes the `t` translation
  function to `createServerFetchFn` so the standard error-toast path
  picks up the consumer's locale.
- `apps/admin/app/(main)/users/grid/user-grid.tsx` had an inline custom
  fetchFn calling `getActiveGridDataAction` directly with a manual
  `transform` step (added `hasLocalPassword`); it now uses
  `createServerFetchFn`'s `transform` callback for consistency with the
  rest of admin and with the canonical archives Records-grid.

### Notes for consumers

- The wrapper-removal is internal — external consumer-side API surface
  is unchanged. `createServerFetchFn`'s signature is identical to what
  was already documented in 0.6.0.
- With this release, all production Geneax server-mode grids (admin +
  archives Records) use the direct active-grid API. The 0.6.0 factory +
  dynamic `extraWhere` + `gridRef` patterns are now exercised across 12+
  consumers, providing a meaningful showcase for the package's
  long-term AG-Grid-parity goal.

---

## \[0.6.0\] — 2026-05-17

This release lifts the server-mode action factory into active-grid itself
and adds first-class support for dynamic scope-filtering, eliminating
copy-paste between database-specific grid actions.

See `MIGRATION-NOTES.md` for the consumer
migration recipe.

### Added

- **`createGridServerAction`** (new `/server` subpath export) — generic
  factory that produces a `{success,data}`-shaped server-action for any
  Prisma client. Each consuming database (`dbCore`, `dbArchives`, etc.)
  creates one instance; the factory is Prisma- and platform-agnostic
  (structural Prisma-delegate typing, injected `performAuthCheck`,
  injected `handleError`). The `maxFetchSize` parameter replaces the
  previous hardcoded `z.number().max(500)` cap. Validation can be
  delegated to a consumer-supplied `validateRequest` callback so existing
  Zod + `formatZodError` translation paths stay intact.
- **`createServerFetchFn`** (new client helper, exported from main index
  and from `/client`) — builds a `ServerFetchFunction<T>` on top of a
  server-action with support for:

  - **Dynamic `extraWhere`** — pass a function
    `(ServerFetchParams) => Record<string, unknown>` to compute scope
    filters per fetch. Closures over React state (view tabs, scope ids)
    eliminate the need for per-domain wrapper actions.
  - **Row `transform`** — optional `(row) => TRow` callback for mapping
    raw Prisma rows to a flat grid-row shape inline.
- **`useGridImperative<TData>(gridRef)`** — typed wrapper returning
  stable `{refetch, removeRow, addRow, resetSelection}` callbacks. Avoids
  the `react-hooks/refs` lint warning that fires when a ref-reading
  function is passed to a context-menu builder during render.
- **Dev-time warning for low `maxFetchSize`** — `createGridServerAction`
  warns when `maxFetchSize` is below the engine's default block size
  (200). Surfaces the conceptual mismatch between fetch-block size and
  UI page size that previously produced silent "Too big" Zod errors.
- **Dev-time warning on client-mode `addRow`/`removeRow`** — the
  imperative API now emits a `[active-grid] gridRef.X() is a no-op in
  mode="client"` warning instead of silently doing nothing. The fix:
  mutate the `data` prop yourself (e.g. `setData(prev => prev.filter(...))`).

### Internal

- Factory + client helper live in `packages/active-grid/src/server/` and
  `packages/active-grid/src/client/`, exported under new `/server` and
  `/client` subpaths in `package.json` `exports`.

### Notes for consumers

- The `pageSize` field on `ServerFetchParams` is the engine's **block
  size** (rows fetched per server call, default 200), not the UI page
  size. The two values are independent — the UI's
  `pagination.pageSizeOptions` controls visible-row chunks, while the
  engine groups multiple UI pages into a single fetch block for cache
  efficiency. This release keeps the name `pageSize` for backward
  compatibility (Option B in the T-GX-AG-01 design) but documents the
  distinction in README §Server-mode and surfaces the conceptual gap
  via the factory's `maxFetchSize` warning.

---

## \[0.5.0\] — 2026-05-17

### Added

- **Column-meta `emptyValueKey`** — per-column i18n-key for null/undefined
  values, resolved via `t()` at render time. Defaults to
  `common.placeholder.notAvailable` when not specified. Use this for
  column-specific empty-state semantics (e.g.
  `"archives.route.columns.lastVerifiedAtEmpty"` → "Nog niet geverifieerd").
  The column's `valueFormatter` is **not** invoked for null/undefined
  values — body-cell handles them directly.
- **Auto-label for badge cells** — when a column uses `type: "badge"`
  (`filterType: "select"`) and provides
  `meta.filterOptions: [{label, value}]`, the cell renderer automatically
  derives a `labelMap` from those options and shows the human-readable
  label. Removes the need to duplicate the value→label mapping in a
  hand-written `cell` renderer. Single source of truth for enum-style
  columns.

### Changed

- **`getRowId` becomes required at the type level** when `TData` lacks an
  `id` field. The new conditional type
  `RequireGetRowIdIfMissingId<U, TData>` in `GridProps<TData>` flips
  `getRowId` from optional to required when `keyof TData` is a concrete
  key-set that does not include `"id"`. Generic / `Record<string, any>`
  positions remain permissive (bail-outs for `string extends keyof TData`
  and `"id" extends keyof TData`). Fixes a runtime-only crash that used
  to surface as `ValidationError: [ActiveGrid] No "id" field found in row
  data. Provide a custom getRowId prop.` Previously only caught at
  runtime; now caught by `tsc --noEmit`.

  **Migration**: any consumer that passes data with an alternate key
  (e.g. `Provider.code`) must now supply `getRowId={(row) => row.code}`
  explicitly. Consumers using entities with `id: string` are unaffected.
- **Null-value rendering moved upstream of `valueFormatter`** in
  `body-cell.tsx`. Before this release, a `valueFormatter` that returned
  an i18n-key string (the date-formatter pattern) leaked raw keys into
  the DOM because nobody resolved them via `t()`. Body-cell now catches
  null/undefined explicitly and renders the resolved `emptyValueKey` —
  the formatter never sees null.

  **Migration**: none for consumers. Authors of custom `valueFormatter`s
  that intentionally handled null themselves should switch to
  `meta.emptyValueKey` for that column.

### Internal

- `lookup-grid.tsx` gets a local cast around `ActiveGrid` to bypass the
  conditional `getRowId` requirement at a generic-passthrough boundary.
  LookupGrid already exposes its own `getRowId?` prop to callers, so user-
  facing safety remains intact.

---

## \[0.4.22\] and earlier

See `git log` and `DECISIONS.md` (ADR-001 through ADR-N)
for prior changes and the broader architectural arc — engine store
single-source-of-truth, useFlexLayout re-anchoring to the engine store,
column virtualization design, server-mode block-cache, and the
filtering / sorting / grouping subsystems.

This CHANGELOG starts at 0.5.0; earlier versions are not retroactively
itemized — the DECISIONS document covers the conceptual evolution and
git is authoritative for the line-by-line history.
