laif-ds 1.0.0 → 1.0.2-app-data-grid.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/CHANGELOG.md +282 -0
  2. package/dist/_virtual/index7.js +5 -2
  3. package/dist/_virtual/index8.js +2 -5
  4. package/dist/agent-docs/adoption-report.json +164 -83
  5. package/dist/agent-docs/components/AppBottomNav.md +180 -49
  6. package/dist/agent-docs/components/AppCard.md +25 -21
  7. package/dist/agent-docs/components/AppDataGrid.md +2602 -0
  8. package/dist/agent-docs/components/AppEditor.md +18 -13
  9. package/dist/agent-docs/components/AppSelect.md +50 -0
  10. package/dist/agent-docs/components/AppSidebar.md +504 -11
  11. package/dist/agent-docs/components/DataTable.md +34 -3
  12. package/dist/agent-docs/components/DatePicker.md +7 -7
  13. package/dist/agent-docs/components/FileUploader.md +12 -12
  14. package/dist/agent-docs/components/GridFilterPanel.md +112 -0
  15. package/dist/agent-docs/components/Sidebar.md +72 -1
  16. package/dist/agent-docs/components/SidebarMenuBadge.md +87 -0
  17. package/dist/agent-docs/components-list.md +47 -2
  18. package/dist/agent-docs/manifest.json +310 -36
  19. package/dist/components/design-tokens.js +138 -66
  20. package/dist/components/nav-tokens.js +41 -0
  21. package/dist/components/ui/app-bottom-nav.js +125 -130
  22. package/dist/components/ui/app-data-grid/app-data-grid.js +874 -0
  23. package/dist/components/ui/app-data-grid/engine/use-grid-column-resize.js +110 -0
  24. package/dist/components/ui/app-data-grid/engine/use-grid-engine.js +598 -0
  25. package/dist/components/ui/app-data-grid/engine/use-grid-infinite-loader.js +50 -0
  26. package/dist/components/ui/app-data-grid/engine/use-grid-virtualizer.js +90 -0
  27. package/dist/components/ui/app-data-grid/grid-aggregate.js +5 -0
  28. package/dist/components/ui/app-data-grid/grid-column-registry.js +28 -0
  29. package/dist/components/ui/app-data-grid/grid-columns.js +204 -0
  30. package/dist/components/ui/app-data-grid/grid-filter-compile.js +33 -0
  31. package/dist/components/ui/app-data-grid/grid-filter-operators.js +139 -0
  32. package/dist/components/ui/app-data-grid/grid-grouping.js +104 -0
  33. package/dist/components/ui/app-data-grid/grid-header-matrix.js +112 -0
  34. package/dist/components/ui/app-data-grid/grid-i18n.js +207 -0
  35. package/dist/components/ui/app-data-grid/grid-mount-count.js +8 -0
  36. package/dist/components/ui/app-data-grid/grid-reducer.js +200 -0
  37. package/dist/components/ui/app-data-grid/grid-result-scope.js +35 -0
  38. package/dist/components/ui/app-data-grid/grid-tokens.js +169 -0
  39. package/dist/components/ui/app-data-grid/grid-view-preset.js +97 -0
  40. package/dist/components/ui/app-data-grid/parts/grid-actions-row.js +86 -0
  41. package/dist/components/ui/app-data-grid/parts/grid-cell-text.js +67 -0
  42. package/dist/components/ui/app-data-grid/parts/grid-column-registry-panel.js +320 -0
  43. package/dist/components/ui/app-data-grid/parts/grid-density-control.js +88 -0
  44. package/dist/components/ui/app-data-grid/parts/grid-filter-chips.js +224 -0
  45. package/dist/components/ui/app-data-grid/parts/grid-filter-editor.js +197 -0
  46. package/dist/components/ui/app-data-grid/parts/grid-filter-panel.js +386 -0
  47. package/dist/components/ui/app-data-grid/parts/grid-filter-row.js +273 -0
  48. package/dist/components/ui/app-data-grid/parts/grid-fullscreen-toggle.js +29 -0
  49. package/dist/components/ui/app-data-grid/parts/grid-group-row.js +135 -0
  50. package/dist/components/ui/app-data-grid/parts/grid-header.js +338 -0
  51. package/dist/components/ui/app-data-grid/parts/grid-pagination-control.js +218 -0
  52. package/dist/components/ui/app-data-grid/parts/grid-row.js +181 -0
  53. package/dist/components/ui/app-data-grid/parts/grid-scroll-area.js +186 -0
  54. package/dist/components/ui/app-data-grid/parts/grid-selection-bar.js +195 -0
  55. package/dist/components/ui/app-data-grid/parts/grid-status-surface.js +66 -0
  56. package/dist/components/ui/app-data-grid/parts/grid-table.js +482 -0
  57. package/dist/components/ui/app-data-grid/parts/grid-toolbar-slots.js +192 -0
  58. package/dist/components/ui/app-data-grid/use-grid-filters.js +173 -0
  59. package/dist/components/ui/app-select.js +332 -222
  60. package/dist/components/ui/app-sidebar-mobile.js +119 -104
  61. package/dist/components/ui/app-sidebar.js +516 -313
  62. package/dist/components/ui/button.js +2 -1
  63. package/dist/components/ui/gantt/components/Chart/Chart.js +1 -1
  64. package/dist/components/ui/nav-badge.js +66 -0
  65. package/dist/components/ui/sidebar.js +272 -207
  66. package/dist/components/ui/tables/data-cross-table/data-cross-table.js +6 -6
  67. package/dist/components/ui/tables/data-table/data-table-constants.js +36 -16
  68. package/dist/components/ui/tables/data-table/data-table-i18n.js +135 -1
  69. package/dist/css-for-template.css +27 -0
  70. package/dist/index.d.ts +5872 -144
  71. package/dist/index.js +473 -406
  72. package/dist/node_modules/eventemitter3/index2.js +1 -1
  73. package/dist/styles.css +2 -1
  74. package/dist/styles.v3.css +1 -1
  75. package/package.json +25 -7
  76. /package/dist/agent-docs/components/{truncated-cell.md → TruncatedCell.md} +0 -0
@@ -0,0 +1,2602 @@
1
+ # AppDataGrid
2
+
3
+ ## Overview
4
+
5
+ A new data grid, built to be **independent of `DataTable`** — it imports nothing from it, does not break it, and can be mounted on the same page alongside it.
6
+
7
+ > **Status: both plans are closed — the first one's phases (P1-P10) and the ten phases of the COMPLETION plan (Fase 1-10).** `DataTable` is now deprecated in favour of
8
+ > this component (see `components-list.md`), but the two coexist: nothing is removed, every existing
9
+ > call-site keeps compiling, and migration is call-site by call-site.
10
+ > What works today: real rows, real sorting (mouse + keyboard, with `aria-sort`), a real client-side search filter (on the columns that declare `searchable: true` — **opt-in since 2026-09-10**, as in the frozen table), the frozen public contracts, a measured emitter contract, paginated mode, opt-in **virtualization** (P3), **column widths, resizing and left-pinning with horizontal overflow** (P4), and **multi-level headers with the derived `id`/`headers` accessibility chain** (P5).
11
+ > What P6-P10 added, by phase: the designed **density and full-screen** experience, with the provisional `showDensityControl` switch gone (P6); the **lifted filter panel and chips**, whose state lives above the grid in `useGridFilters` and works with no grid in the tree, plus the **filter atom** — the control is derived from `filterType`, so a list is filtered from a list and a date from the picker — and the **288px overlay inventory** the panel became (P7); **infinite scroll and skeleton rows**, which coexist with the data (P8); client-side **row grouping** on one or more columns, with `aggregatedCell` filling the group rows (P9); and **saved views**, applied in one tick through `preset`/`onPresetApplied` (P10). **Riga Zero (C9)** then added the second home for the filters: `filterMode="row"` renders a FOURTH `<tr>` of `<thead>` with one control per column, reusing the Fase 7 atom and the one badge constructor, so the row and the inventory panel produce the same state; `"panel"` stays the default.
12
+ > What the completion plan has added since, by phase: **left AND right pinning with the measured sticky band** and the single `align` derivation (Fase 2); the **multi-level header matrix** hardened against the group that covers zero leaves (Fase 3); **`gridResultScope`, the single source of every scope figure** — paginator, chips and filter trigger read the same object, and on the client branch the paginator is a read-out (Fase 4); and, from **Fase 5**, the three **outcomes without data rendered INSIDE the table** (a `<td>` covering every layout column, ghost column included), an **error distinguishable from an empty result** with its own surface, its own i18n strings and a «Retry» control (`error` + `onRetry`), the **`<tfoot>` of column totals** (`aggregate` per column, sticky at the useful bottom, aligned by the same single derivation), and the rule that **above a body with no data the frame is silent**: no totals, no paginator, no scope figure; the **page-scope selection bar** — count in page, a monetary aggregate as a render-prop, bulk actions that carry the number in their own name («Delete 10 rows»), a destructive variant and a way out — which writes its clause into the one permanent live region instead of opening a second one, together with the group tri-state (Fase 6); the **filter atom**, where both the control and the operator list are derived from `filterType` instead of a closed `switch`, and the panel it lives in became the 288px overlay inventory (Fase 7); **`GridFilterControl`**, the string-keyed registry that makes an application-declared filter first class through the obligatory `toFragment` + `describe` pair, so a filter with no column lands in the toolbar instead of being pruned (Fase 8, R15); and **Riga Zero** (Fase 9), described in the paragraph above. **Fase 10 adds no behaviour**: it is the delivery — the `AppDataGrid/Completa` scene, remounted on the mockup's own columns and rendered with the real engine (**fourteen leaf columns**, measured as `aria-colcount="14"` on the rendered table: twelve of data plus the checkbox column and the row menu; three header levels; five cell renderers declared through `cell` — status badge, initials avatar, progress bar, `it-IT` amounts, `gg/mm/aaaa` dates; group aggregates through `aggregatedCell` and the `<tfoot>` count and average through `aggregate`), next to the frozen `AppDataGrid/Mockup`; the list of deviations from that mockup, **thirty-four rows** each carrying `accettato`, `aperto` or `chiuso`; and the single measured registry of the fifteen requirements, all in `docs/analysis/datatable-v2/PIANO-COMPLETAMENTO.md`. ⚠️ **Fase 10 closes nothing on the component, and the remount closed nothing either**: **nine** of those thirty-four rows are still open — **seven of the component** (rows 13, 14, 19, 20, 22, 27, 34), one of governance (row 10, the chip row of §11.3-A, which no signed decision covers) and one of the story (row 28, the missing initial sort, open because nobody has written why). The seven: the group label falls in the grouping column rather than the pinned one (row 13) and reads «Edilizia 78 righe» rather than «Edilizia (78)» (row 14) — ⚠️ **row 14 is listed above, and in the COMPONENT it is closed**: `adg-m5` made the parentheses unconditional in `parts/grid-group-row.tsx`, and no prop governs them; it keeps its `aperto` only because the count mirrors the plan's table. **Row 13 stays genuinely open**: the prop exists (`groupLabelPlacement="first-column"`) but the signature scene does not pass it; the status line is `sr-only` (row 20); the selection bar's count phrase is a fixed template (row 22); and the toolbar order is **half closed** (row 27) — `adg-m3` gave it the mockup's two groups and «Esporta» before «Schermo intero», so what is left is ONE swap away: WHERE the filters slot goes, which is a decision and not a change (see «The toolbar order, and the one decision still open»); ⚠️ **Rows 19 and 34 are listed above, and in the COMPONENT they are closed — `adg-m2` closed BOTH in one edit, because they were one root**: a single undefined `scopeTotal` switched off the visible figure (19) and made `aria-rowcount` fall back to the rendered rows (34); the paginated scene now reads «Righe 1-10 di 128» with `aria-rowcount="128"` over ten rendered rows, measured live. They keep their `aperto` in the count above only because that count mirrors the plan's table, whose reclassification is pending — the two numbers are not a disagreement about the component. The remedy is NOT a new prop — `resultTotal` exists only on `GridClientProps` (`app-data-grid.types.ts:1088`), the branch declaring `paginated?: never` (line 982), so passing it beside `paginated` is `error TS2322`, measured with `tsc --noEmit`. The scope is **DERIVED** instead: a grid that paginates itself already holds the figure. See «The scope on a self-paginating grid». ⚠️ The filter trigger carrying no count is **not** among them: that is row 18, and it is `accettato`. ⚠️ Nor are **the trailing skeleton rows**, and the reason is the one that took them off the list (row 24, reclassified `accettato` with the measure in hand): they are not missing — **they are the R4/P8 invariant on the `infinite` branch**, where an appending page keeps `replacingRows` false, so `dataRowCount` stays full while `skeletonRowCount` is positive and the skeletons ACCOMPANY the data instead of replacing it. They replace it only outside that branch. The signature scene is paginated, so the trailing pair is unreachable **by construction** there, not absent — and it is proved by two stories, `app-data-grid-infinite.stories.tsx :: GliScheletriStannoDOPOIDati` and `:: NessunVuotoNeiTreStati`. ⚠️ Nor is **the viewport 64px shorter than its content with three header levels** — that is row 25, **closed by `adg-m1`**, and both halves of the old sentence are now false: the input exists (`headerLevels` on `GridViewportHeightInput`, with `headerHeight * headerLevels` in `gridViewportHeight`, and `gridFrameHeight` no longer dropping inherited fields in silence) and the measured delta is **0** — `scrollHeight` 502 against `clientHeight` 502, where it was 64, with zero rows covered by the `<tfoot>`. ⚠️ But the remedy is a PACT WITH THE CALLER, not a behaviour of the component: see «Two height formulas» for why the full signature is written out there. ⚠️ **`chiuso` is a THIRD outcome value, not an `accettato` in disguise**, and rows 15, 17 and 25 carry it: they left the OPEN rows without joining the accepted ones. The difference is the whole point — «accepted» means the divergence from the mockup is still there and we are keeping it; «closed» means it is GONE and is nobody's work any more. The thirty-four therefore split **22 `accettato` + 9 `aperto` + 3 `chiuso`**, and this blockquote enumerates only the nine. Which task closed what is written in the table's own `chiuso da <task>`, and is deliberately NOT repeated here. The count above is the plan's own tally at the moment it was measured, and `docs/analysis/datatable-v2/PIANO-COMPLETAMENTO.md` is the file that re-derives it: this paragraph does not, so that two documents cannot disagree about the same arithmetic.
13
+ >
14
+ > **Reach for `AppDataGrid` in new work.** Migrating a call site is mostly a rename — `scripts/codemod/rename-map.json` records name by name which old export maps to which new one; see "Coexistence with `DataTable`" below.
15
+
16
+ Three contracts are **copied from `DataTable`, never imported**, so that migrating a call site is a rename rather than a rewrite:
17
+
18
+ 1. **The `cell:` signature**, arity inconsistency included.
19
+ 2. **The server payload shape** (`GridServerState`), field-for-field identical to `ServerState`.
20
+ 3. **The i18n type**, superset-assignable: anything satisfying `DataTableI18n` satisfies `GridI18n`.
21
+
22
+ ---
23
+
24
+ ## Props
25
+
26
+ ### AppDataGridProps&lt;TData&gt;
27
+
28
+ | Prop | Type | Default | Description |
29
+ | -------------------------- | --------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
30
+ | `columns` | `GridColumnNode<TData>[]` | — | **Required.** Built with the `createGrid*Column` builders — never hand-written. From P5 the array may also contain **groups** (`createGridColumnGroup`), which add header rows without touching anything else. `GridColumnDef<TData>[]` stays assignable, so pre-P5 call sites need no change. |
31
+ | `data` | `TData[]` | — | **Required.** The rows. Client-side; every row is rendered unless `virtualized` is on. |
32
+ | `i18n` | `GridI18n` | `defaultGridI18n` | Labels. A `DataTableI18n` value is assignable here as-is — which is why every section added after P1 (`columnResize`, `selectColumnHeader`, `toolbar`) is **optional**, with the fallback from `defaultGridI18n`. |
33
+ | `debounceTime` | `number` | `200` | Milliseconds to wait before **confirming** a search or filter keystroke; `0` confirms immediately. Confirming — not merely emitting: while the window is open the table stays on the last state it reached instead of re-filtering on every key, and when it closes it filters **and** emits in one event. The typed character stays instant either way: what is deferred is the state that FILTERS, not the state the inputs show. Works **in both modes** — in client mode it buys the local re-filtering rather than requests, which is why it lives here and not inside `serverConfig`, which the client variant does not have. Only the continuous actions: sort, page, load-more and preset confirm at once and cancel a pending confirmation. Does **not** cover lifted badges — there the consumer reduces the rows, and `useGridFilters` publishes `committedBadges` for it. Wins over `serverConfig.serverDebounceTime`, the same delay under the name inherited from the frozen twin. |
34
+ | `sortMode` | `"single" \| "multi"` | `"multi"` | `"multi"` matches today's `DataTable`. Shift-click accumulates. |
35
+ | `maxSortedColumns` | `number` | `2` | Ceiling on simultaneous sorts, matching `DataTable`. The oldest is dropped past the cap. |
36
+ | `density` | `"compact" \| "default" \| "comfortable"` | `"default"` | Row height: 32 / 36 / 48 px. ⚠️ **BREAKING from `adg-c12`: this is the ONLY handle on density, and the grid renders no density control.** The `toolbar.density` key is gone from `GridToolbarSlots` and passing it is a compiler error. Row height is a decision of whoever mounts the table. Formally the prop is still "controlled when passed, uncontrolled when omitted", but with the control removed nothing inside the grid writes that reducer slice except `applyPreset` — so **omitting the prop freezes density at `"default"` for good**. To offer the switch to the end user, mount the public `GridDensityControl` in `toolbar.actions` or outside the table (see `AppDataGrid/Completa`, which puts it outside). |
37
+ | `onDensityChange` | `(density: GridDensity) => void` | `undefined` | **Not an emission** — density is view configuration, so it cannot refetch. ⚠️ **From `adg-c12` the toolbar no longer fires this**, because there is no toolbar density control any more: the remaining caller is **preset application**, which announces `preset.view.density`. Without that caller the prop would be public, documented and never invoked. A `GridDensityControl` you mount yourself reports through its own `onChange`, not through this prop. ⚠️ **Declared limit:** on a grid with `density` passed, a preset's own density cannot win — the render picks the prop — so the preset *announces* its density without imposing it. Who should win is an open product decision (`adg-c12`). |
38
+ | `onFullscreenChange` | `(fullscreen: boolean) => void` | `undefined` | Fires on entering and leaving full screen. Notification only, not a controlled mode: persisting it across sessions belongs to P10. |
39
+ | `filters` | `readonly GridBadgeFilter[]` | `undefined` | The active filter badges, **lifted above the grid** (P7). **Controlled when passed**: the grid stops keeping its own filter slice and reads these, which is what makes the chips and the panel work with no `<AppDataGrid>` in the tree. Build them with `useGridFilters` and pass the same value to the chips and to the grid. Absent = the grid uses its own slice, exactly as before P7. |
40
+ | `columnVisibility` | `Readonly<Record<string, boolean>>` | `undefined` | Column id → visible (P7). **An absent column is visible**, because absence means nobody said anything. Controlled when passed, like `filters`, and **the registry is one place**: this map is the only truth about which columns exist. |
41
+ | `paginated` | `boolean` | `false` | Paginated mode. Off by default. **Not available alongside `groupBy`** — it lives on the server variant of the union only. Pagination slices the rows _before_ grouping sees them, so a group would count, and `aggregatedCell` would sum, only the current page: measured at 6 rows out of 340, with another "Italy" reappearing on the next page. That is the same number-that-looks-like-a-total the union forbids for server sources. |
42
+ | `pageSize` | `number` | `10` | Rows per page when `paginated`. ⚠️ **Seeds the live page size and is inert afterwards** (the «rows per page» selector owns it from Fase 4 on). ⚠️ **The SKELETON count is capped at `1_000`** — `Math.min(livePageSize, 1_000)` — because that number feeds `Array.from({length})`: measured, `Infinity` raised `RangeError: Invalid array length` and `1e9` killed the process with `heap out of memory`. Data rows and the paginator are NOT capped, so above 1000 the loading page shows fewer placeholders than the page that is arriving. ⚠️ **`0` and non-integers are not validated here**, and `readGridViewPreset` refuses them: a grid mounted with `pageSize={0}` captures a preset that can never be restored (declared limit 6). |
43
+ | `onQueryChange` | `(state: GridServerState) => void` | `undefined` | Fires **only** on a query action (search, sort, filter, page). A view action can never reach it. **Mutually exclusive with `groupBy`**: the props type is a union, so the two together do not compile — see the `groupBy` row. |
44
+ | `virtualized` | `boolean` | `false` | Renders only the visible window of rows. Opt-in, so existing call-sites are unaffected. Needs a bounded viewport — see `viewportHeight`. |
45
+ | `viewportHeight` | `number` | `480` | Height in px of the scroll container. Applied **whenever it is passed explicitly**, and in any case when `virtualized`, `infinite` **or `groupBy`** is on (`viewportIsBounded`, `app-data-grid.tsx`). **Fase 6 added `groupBy` to that list**: a grouped grid without a bounded height grew with its content, so nothing could be recomposed when the selection bar appeared — and the scroll compensation of P9 could never fire either. Must be a number: a percentage against an auto-height parent resolves to zero. ⚠️ **From `adg-m3`, NOT passing it no longer means «content height» inside a stretched frame.** The scroll-area root now carries `min-h-0 grow` whenever the viewport is UNBOUNDED — exactly the case with no `viewportHeight`, no `virtualized`, no `infinite` and no `groupBy` — so it takes whatever vertical space its parent leaves. Measured defect on the template: the grid root stretched correctly to 617px (the consumer passes `className="flex min-h-0 flex-1"`) while the scroll area stopped ~84px short, and the residue stayed empty INSIDE the grid's own frame; the frozen table stretched by construction (`flex max-h-full min-h-0 flex-1` around an `h-full` `ScrollArea`), so this was a regression of the lane and not a choice. **`grow` and not `flex-1`, and that is the load-bearing half:** `flex: 1 1 auto` keeps the box at CONTENT height when the parent has no definite height (every Storybook scene that omits this prop), where `flex: 1 1 0%` would size it on a zero basis and collapse the frame. When this prop IS passed — or under `virtualized`/`infinite`/`groupBy` — the class is not emitted at all and the inline height stays the only source: `[data-grid-viewport].clientHeight === viewportHeight` holds word for word, and so do the budgets `gridViewportHeight` derives from it. |
46
+ | `infinite` | `GridInfiniteOptions` | `undefined` | Loads rows page by page as you scroll (P8). Absent = off. `{ hasMore }` — the consumer declares whether another page exists; the grid never infers it from a short page. **Wants `virtualized`**: without it every arriving row is attached to the DOM, and an infinite source is exactly the case with no ceiling. The grid asks for a page by emitting `onQueryChange` with `pagination.pageIndex` advanced by one — **the consumer appends**, the grid keeps no copy of the data. **⚠️ Ignored when `paginated` is on** — the two write the same `pageIndex` and together produce an empty table; see "Declared limits" under Infinite source (P8). |
47
+ | `preset` | `GridViewPreset` | `undefined` | The saved view to **apply** (P10). Applied whenever the object's **identity** changes — not its content — so re-passing a field-for-field equal preset applies it again, on purpose: «re-apply the view I am already looking at» is a legitimate gesture. One application raises `data-emissions` by exactly 1. ⚠️ **The grid now runs it through `readGridViewPreset` itself**, and until this change it ran it through nothing: the type is a COMPILE-TIME contract and `JSON.parse(localStorage.getItem(k))` is `any`, so a truncated payload used to reach `preset.view.density` inside an effect — a `TypeError` with the tree unmounted, i.e. the blank screen the declared error exists to replace. A payload the door refuses applies **nothing** (not the view half, not the query half), does not call `onDensityChange`, does not call `onPresetApplied`, and logs the `GridViewPresetVersionError` once with `console.error`. The grid stays on the view it already has — a RIGHT view, just not restored. ⚠️ **Declared limit — a refusal has no dedicated channel:** `onPresetApplied` NOT firing is the machine-readable signal, so a consumer who did not wire it only sees the console. An `onPresetError` prop is a feature nobody asked for. ⚠️ **The door is STRICTER than the type**, on five families of fields — see «Saved views» below. |
48
+ | `onPresetApplied` | `(preset: GridViewPreset) => void` | `undefined` | Fires **after** a preset has been applied, with the preset itself. It exists because half the view state lives outside the grid — P7 lifted filters and column visibility above it — and the grid cannot write what is not its own: it applies the preset to its own state to build the emitted payload, then asks the consumer to realign. `useGridFilters().restore()` does that without emitting again. |
49
+ | `groupBy` | `readonly string[]` | `undefined` | Groups rows by one or more columns, in order (P9). **Client only, and the compiler enforces it**: `AppDataGridProps` is a union, so `groupBy` alongside `onQueryChange` does not compile. Each entry consumes a level — `["country", "city"]` nests cities inside countries. A group's key is its **path**, so two "Nice" under different countries stay two groups. Expansion is **view** state: it never emits. |
50
+ | `initialOpenGroups` | `"all" \| "first" \| readonly string[]` | `"all"` | Which groups start open (C1). **Client variant only**, like `groupBy` — on the server variant it is `never`. Three forms: `"all"` (default, nothing is closed), `"first"` (only the **first first-level** group stays open; deeper levels are untouched, so inside a group that opens the subgroups are open), and a list of **paths** (exactly those stay open, plus the ancestors needed to reach them — asking for the "Roma" nested under "Italia" while "Italia" is closed would make the request invisible). A fourth form does not compile: the type is a closed union, not a `string`. **It is a seed of `groupExpansion`, not a mode** — that map records only the exception (`false` = closed, absent = open). **Re-application rule, in two halves.** _When:_ it re-seeds only when the **set of group paths changes** — so a user's toggle never triggers it (the group tree is built from rows + `groupBy` and does not depend on expansion), and neither does a re-sort (the path set is compared as a set, not a sequence). _What it does when it fires:_ **it never changes the state of a group that still exists.** Every group somebody has already spoken about — the user by toggling it, or an earlier seed — keeps what it had; `initialOpenGroups` is applied only to paths that are **new**; groups that vanished lose their entry. Both halves are needed: the set changes because of **one** group, so a re-seed that redid everything would charge every group for it. Measured before the fix — with `"first"` and four categories, opening 2 and 3 by hand and then typing a search term that removed every row of category 4 slammed 2 and 3 shut. The flip side of the same guarantee: `"first"` is **not** re-applied to a group that already has a state, so if the first group disappears the second does not open by itself. Changing the prop's value on a mounted grid does nothing on its own — it is "initial" in the sense of `initialState`: declared, not applied. |
51
+ | `initialState` | `GridInitialState<TData>` | `undefined` | Initial filters, sorting, pagination, search term and column visibility, in the shape corpus call-sites already wrote (the twin of `InitialTableState`/`DataTableState`) — P10. **Read once at mount**: changing it afterwards does nothing, like in the frozen twin. To restore a view on a mounted grid use `preset`, which is a different prop because it is a different thing — one is declared, the other is applied. **Server variant only**, so not available alongside `groupBy` — see «Cosa i tipi rifiutano». |
52
+ | `className` | `string` | `undefined` | Extra classes on the grid container. |
53
+ | `id` | `string` | `undefined` | DOM id. |
54
+ | `data-testid` | `string` | `undefined` | Test hook on the container. |
55
+ | `loading` | `boolean` | `false` | A request is in flight. Renders skeleton rows at the same height as data rows, and suppresses **both** outcomes without data — the empty one and, from Fase 5, the error one: saying «no results» to someone who is waiting is the quickest way to make them close a page that would have shown their data a second later, and an error from the previous attempt above the skeletons of the current one describes the past. From Fase 5 loading is one of the **three** outcomes rendered inside the table, and above it the frame is silent too: no `<tfoot>`, no paginator. **From P8 skeletons and data coexist**: when the in-flight request is page N&gt;0 of an infinite source the skeletons are **appended** after the data; otherwise (a new sort, filter, search, or the first load) they **replace** it, because the rows on screen belong to a query that is no longer the current one. |
56
+ | `emptyComponent` | `ReactNode` | `undefined` | Replaces the default "no results" message **on screen only**, and only for the EMPTY outcome — the error outcome has a surface of its own with its own i18n strings, and `emptyComponent` does not reach it (from Fase 5 «the state without data» is no longer one thing). From Fase 5 it renders as a **descendant of the `[data-grid-empty]` cell inside the `<tbody>`**, not in a `<div>` beside the table. The live region keeps announcing `i18n.notFoundMessage`, so with a custom node the screen and the voice say different things — see "Declared limits" below for why, and for what to do about it. |
57
+ | `actions` | `GridActions[]` | `[]` | The buttons the grid dresses itself, `{label, icon, onClick, data-testid}` — rendered **INSIDE `[data-grid-toolbar]`**, in its `.ml-auto` right group, between «columns» and the `toolbar.actions` slot. ⚠️ **MOVED by `adg-m3`, and this row used to say the opposite**: until then they were their own `role="toolbar"` row, the FIRST child of `[data-app-data-grid]`, ABOVE the toolbar. The move is not cosmetics — this prop is the compat RENAME of `DataTableProps.actions`, aimed at the corpus the codemod covers (the 297 files this document counts elsewhere), and the frozen table rendered those buttons inside its OWN bar (`tables/data-table/components/data-table-actions.tsx`), so a consumer who migrates by renaming has to find the same bar. That `AppDataGrid` is ABSENT from the last published bundle is what made the move free: no layout already in production moves. ⚠️ **TWO OR MORE COLLAPSE INTO ONE trigger** — `[data-grid-actions-trigger]`, named `i18n.tableActions.actions`, with the commands inside `[data-grid-actions-panel]`; **ONE action renders inline**. Also the frozen shape, and the reason is measured: that right group is 24px tall and already holds «columns» and the full-screen toggle, while the real corpus (`UserTable` in the template) passes THREE — three more buttons there wrap a bar that is `flex-wrap`, collapsed it grows by ONE trigger. ⚠️ **The dress is the bar's own `gridToolbarTriggerClass`** (`variant="ghost"`, h-6, text-xs), NOT the frozen `variant="default"`: the two neighbours are ghost triggers, and the lane already carries a written decision against the primary variant (its hover sheen uses `rounded-[inherit]`, an arbitrary value `check-grid-css.mjs` refuses). **Declared cost:** an «Add» action loses the emphasis it had in the frozen bar. `hideActionsRow` still wins over a populated list. |
58
+ | `hideActionsRow` | `boolean` | `false` | Hides the `actions` buttons even when `actions` is populated. ⚠️ **SCOPE CHANGED from `DataTable`, where it hid the WHOLE toolbar** (sorting, filters, reset, search, column visibility, actions). Here it hides only the `actions` buttons: `<div data-grid-toolbar>` renders unconditionally and keeps the search field, its `×` and the full-screen trigger. A call-site that used it to hide the entire bar gets a bar. Nailed by `app-data-grid-fix-guscio.stories.tsx :: LaPortataDiHideActionsRow`. ⚠️ **From `adg-m3` there is no ROW left to hide.** The buttons live inside the toolbar's right group, so what this prop suppresses is `[data-grid-actions-row]` and its contents — not a band of the frame, and not the toolbar, which renders either way. The semantics did not change (it still wins over a populated `actions`, and the story above still nails it); only the node it removes has moved. It keeps its `Row` name for the same reason the hook does — see `[data-grid-actions-row]` in the DOM contract. |
59
+ | `checkable` | `boolean` | `false` | Adds the selection checkbox column (to `colgroup`, `th` and `td` alike). |
60
+ | `rowSelection` | `Record<string, boolean>` | `undefined` | Controlled selection; internal when omitted. |
61
+ | `getRowId` | `(row, index) => string` | `undefined` | The identity of a row, declared by the consumer, forwarded to TanStack. **Absent = the identity is the position**, which is TanStack's default and shows up in the selection: in infinite mode a `restart` (a re-sort, a filter change) rebuilds the list, and a selection pinned to indices stays pinned to the same _indices_ — i.e. to different records than the user picked. The ticks are still there, still look right, and point at something else. Opt-in: passing it is how you get record identity. Return a key from the data (`(row) => row.code`), never `String(index)`. |
62
+ | `onRowSelectionChange` | `(selection) => void` | `undefined` | Fires on every selection change. |
63
+ | `onCheckedRowsChange` | `(rows: TData[]) => void` | `undefined` | The selected rows, already resolved to data. |
64
+ | `rowClassName` | `string \| ((row: GridRow<TData>) => string)` | `undefined` | Per-row classes. The callback form is resolved internally. |
65
+ | `toolbar` | `GridToolbarSlots` | `undefined` | **Fase 3, live.** The shell, opt-in **per slot**: `search`, `fullscreen`, `filters`, `columns`, `actions`. ⚠️ **`density` is NOT in that list from `adg-c12`** — the key was deleted, not moved to the opt-in family, and `toolbar={{ density: {} }}` is a `TS2353`. The "always rendered" family is therefore **two** keys, not three. Each key is a **configuration object** whose PRESENCE turns the slot on and whose optional `render` replaces the node the grid would draw. `search`/`density`/`fullscreen` are rendered **always** — declaring them only lets you substitute the node, and omitting them does not switch them off (the P6 rule is not retracted behind a prop). `filters`/`columns`/`actions` are **absent by default**, and the three use three **different** conditions: `columns` needs the key **and** a writable visibility map (see `onColumnVisibilityChange`); `filters` needs the key to carry `render` or `onOpen`; `actions` renders only its `render`, with no default node. ⚠️ **Not to be confused with the top-level `actions` PROP**, which from `adg-m3` lands in the SAME `.ml-auto` group, immediately BEFORE this slot: that one is the compat rename of `DataTableProps.actions` and the grid dresses its buttons itself; this one is the consumer's own node and the grid draws nothing for it. A slot is deliberately not a `ReactNode`: `ReactNode` admits `boolean` and `string`, so `toolbar={{ search: false }}` would compile and switch search off. `search: false`, `search: "si"` and `search: { render: () => null }` are all compiler errors, proven by the **three** probes in `type-probes/shell-slots.probe.tsx` rather than by this sentence — the first two guard the **property type**, the third the **return type** (`search`/`density`/`fullscreen` use `GridToolbarAlwaysSlot`, whose `render` returns `ReactElement`, not `ReactElement | null`). ⚠️ **The type closes the form, not the intention, and that is a declared limit:** `() => null`does not compile, but`() => <></>` does and paints nothing, so a substitute node that renders nothing stays writable — excluding it would mean inspecting what a function returns, which a type does not do. |
66
+ | `selection` | `GridSelectionSlots` | `undefined` | **Live from Fase 6.** Configures the **page-selection bar** — the band that appears **above** the scroll area (right after the filter band, the mockup's order) as soon as at least one row is ticked, and that does not exist at all while none is. ⚠️ **It used to appear UNDER it; it moved on 2026-09-03**, and the move retires a named promise — see "Page selection" below. `mode` is the one-member union `"page"` because selection is page-only (no promotion to the whole result set), which makes that decision a compile error instead of a note. `actions` is `readonly GridSelectionAction[]` — the bulk actions rendered inside the bar (`label` **without** a number, optional `icon`, `destructive?: boolean` mapped to the shared `Button`'s `destructive` variant, and `onSelect(rowIds)` receiving the **page** ids). `summary` replaces the bar's **totals band** — not the bar, and not the count: the count is the bar's accessible name via `aria-labelledby`, so a slot that replaced it would leave the group unnamed. **`summary` still passes no arguments, and that is now a decision rather than an open question**: the two things it could pass — the selected rows and their aggregates — the consumer already has from `onCheckedRowsChange` (or the `rowSelection` it controls) and from its own `aggregate` functions, so passing them here would open a second channel for the same two numbers. |
67
+ | `pagination` | `GridPaginationSlots` | `undefined` | **LIVE from Fase 4.** `pagination.pageSize` turns the paginator ON **and** seeds the live page size. **The precedence is decided** and resolved in ONE place (at the `useGridEngine` call): `initialState.pagination.pageSize` > `pagination.pageSize` > the top-level `pageSize` prop. **On the CLIENT branch the paginator is a read-out, not a control**: `paginated` is `never` there and the types carry no page channel, so it prints the range and the rows clause from the declared `resultTotal` and renders NO page numbers, NO first/prev/next/last and NO size select — those commands could change nothing but the wording. **It does not coexist with `groupBy`**: the render path excludes it, because grouping × pagination is the pair declared unrepresentable by `paginated?: never` (line 982) on the branch that admits `groupBy`. |
68
+ | `initialFullscreen` | `boolean` | `false` | **Fase 3, live.** Seeds the fullscreen state at the **first** render, so a call site that mounts with it finds `role="dialog"` immediately rather than after an effect. It is an `initial*`, not a controlled `fullscreen`: it seeds and then stops governing, so no consumer has to resynchronise a state it does not own. |
69
+ | `onColumnVisibilityChange` | `(columnVisibility: Readonly<Record<string, boolean>>) => void` | `undefined` | The missing half of `columnVisibility`, and the twin of `onDensityChange`. Until Fase 3 nothing **inside** the grid wrote that map — the panel was consumer-mounted and wrote it itself — so the controlled half existed without a callback. From Fase 3 the `toolbar.columns` slot lives inside the grid and dispatches `view/column-visibility` (the column menu's hide item did too, until the `⋮` menu was removed); but when `columnVisibility` arrives as a prop the engine **overrides** that reducer slice with the prop, so the dispatch would be invisible. With this callback the grid **asks** the owner of the map for the change instead of writing where nobody reads. Receives the already-applied map, in the slice's convention: an absent key means visible. ⚠️ **Omitting it while `columnVisibility` is passed switches the control off.** In that combination nobody can write the map — the dispatch is suppressed and the request has no recipient — so the «columns» trigger is **not rendered** at all, rather than accepting a click and snapping back in the same commit. Same rule the phase already applies to `toolbar.filters` without `onOpen`: a button that does nothing is worse than a button that is not there. Measured: with the prop and no callback `[data-grid-toolbar-columns]` is not in the DOM; adding the callback brings it back. (It used to switch off TWO controls, the second being the `⋮` menu's hide item, removed with the menu.) |
70
+ | `onColumnWidthsChange` | `(columnWidths: Readonly<Record<string, number>>) => void` | `undefined` | **The capture half of `columnWidths`, and until this prop there was none.** A width was always restorable through `preset.view.columnWidths`, and never readable: `view/column-width` lives in the reducer and no prop carried it back out, so a persistence consumer saved the widths frozen at their first value forever — which is written down as the literals `columnWidths: {}` / `columnOrder: []` in the phase's own reference harness. **Not an emission** — a width is view configuration, so it cannot refetch: `view/column-width` is a `GridViewAction`, `onQueryChange` does not appear in `applyView`, and `data-emissions` does not move. Receives the **already-applied, whole** map, in the slice's convention, exactly like `onColumnVisibilityChange`. ⚠️ **The map is PARTIAL, and the partiality is the value:** a column never dragged is **not a key**, and that absence is what `parts/grid-table.tsx` reads to decide which columns are elastic and take part in slack distribution. Completing it would switch slack distribution off at the first save AND emit a value different from what `preset.view.columnWidths` stores — the round trip would not come back. ⚠️ **One call per commit, not one per pixel:** between `pointerdown` and `pointerup` the width is written straight to the `<col>` and consolidated into state once, on release; `pointercancel` does not consolidate, so it does not notify. ⚠️ **Declared limit — the keyboard commits once per keypress**, so a held arrow key yields one notification per OS key-repeat. The grid does not smooth it: `serverDebounceTime` is declared for query _emission_, and a view action has no emitter to protect. Debounce on your side if you POST per notification. ⚠️ **Declared limit — a zero-delta gesture notifies too, and the notification is faithful.** `pointerup` commits the seeded start width whatever it is, so a still click on the handle (a mis-click, or the click that focuses the separator before using the arrows) dispatches the rendered width and the column leaves the elastic set from then on. This prop does not filter it: the dispatch is pre-existing P4 behavior, so suppressing only the notification would leave the consumer's copy diverging from the reducer slice — the one thing worse than an extra notification. ⚠️ **Declared limit — the width handed over is the RENDERED one**, i.e. post-`distributeGridColumnSlack`, so the first notification for a formerly elastic column carries a number nobody typed and nobody dragged to: well-defined but **viewport-dependent**, which means a view captured on a 1600px window and restored on a 1200px one pins a column at a width that window would never have given it. Restore itself is safe — `columnWidths[id] ?? header.width ?? gridColumnWidth.default`, then clamped. ⚠️ **Declared limit — it does NOT fire when a `preset` is applied**, deliberately: `applyPreset` dispatches `view/preset` straight to the reducer and never reaches this handler. Firing there would close a write-back loop (load the saved view → `preset` → callback → the consumer POSTs what it just GET-ed). Restoration is announced once and wholesale through `onPresetApplied`. The asymmetry with `onDensityChange`, which _does_ fire on a preset, is not a precedent — that one was rescued there because `adg-c12` removed its last caller. ⚠⚠ **And therefore the consumer MUST re-seed its captured copy from `onPresetApplied`.** `view/preset` replaces `columnWidths` wholesale and no capture callback announces it: drag `city` to 400, then restore an older view whose `columnWidths` is `{}` — the grid goes back to all-elastic while the consumer's copy still says `{city:400}`, and the next save, with nobody having touched anything, writes it back and undoes the restore in silence. One line in `onPresetApplied` fixes it. ⚠️ **No controlled half:** there is no `columnWidths` prop, because `preset.view` already imposes widths in one tick with one emission. Consequence: `preset` is guarded by object **identity**, so restoring the same saved view twice needs a new object. |
71
+ | `onColumnOrderChange` | `(columnOrder: string[]) => void` | `undefined` | **The capture half of the column order, and the twin of `onColumnWidthsChange`.** `view/column-order` has a single dispatch in the whole grid and had no way out; a `GridColumnRegistryPanel` you mount yourself has always had its own `onColumnOrderChange`, but an order decided there does not reach the grid — there is no `columnOrder` prop. **Not an emission** — column order changes nothing the server needs to know, and `view/column-order` is not a `GridQueryAction`. Receives the **new, whole** order, not the move. `string[]` and not `readonly string[]` on purpose: `GridViewPresetView.columnOrder` is `string[]`, so a readonly array is `TS4104` on assignment and would force `[...columnOrder]` in every consumer; the widths map stays `Readonly<Record<…>>` because TypeScript ignores the modifier there — the asymmetry is the language's, and it is the same one the panel already has. ⚠️ **Never fires on a non-event**, and not because this point guards: the panel already rejects a move past the end of the list, one that crosses a pinned block, and an identical order. ⚠⚠ **Declared limit — without the IN-GRID registry this prop can never be invoked, and passing `toolbar.columns` is NOT enough.** The node that wires it renders only if `toolbar.columns` is present, does **not** carry its own `render` (a consumer's panel wins the precondition, deliberately), **and** `canWriteVisibility` is true — i.e. `columnVisibility` is absent, or arrives together with `onColumnVisibilityChange`. The combination «controlled visibility without its callback» switches the trigger off entirely, and with it the reorder. Pass the three together, or this is a prop that cannot fire. ⚠️ **Declared limit — it does NOT fire when a `preset` is applied**, same reason, same loop, same restore channel as `onColumnWidthsChange`, and the same **obligation** to re-seed the captured copy from `onPresetApplied`, because `view/preset` replaces `columnOrder` wholesale too. ⚠️ **Declared limit — the order is a FLAT list that does not say whose an id is.** The engine sorts **siblings** inside their group, so a saved order whose move crosses a group boundary is a visible non-event. Pre-existing P7 behavior, true of the captured value as of the applied one. ⚠️ **No controlled half:** the order lives only in the reducer, and `preset.view.columnOrder` is the way to impose it. The panel tolerates a **partial** order — it appends the ids the saved order does not name — so a view saved before a column existed does not lose it. |
72
+ | `resultTotal` | `number` | `undefined` | **CLIENT BRANCH ONLY — Fase 4.** How many rows exist IN TOTAL, i.e. the figure the paginator prints after «of» («Rows 1-10 of 128») and the chip row's clause reads. NOT the number of rows rendered: in client mode `data` may already be a page, and `data.length` would say 10 where the scope is 128. The server-branch twin is `serverConfig.totalItems` — the two never merge, and which one you pass is decided by the branch. Omitted, the scope equals the rows actually rendered — **except on a grid that paginates itself**, where from `adg-m2` it is derived: see «The scope on a self-paginating grid». Passing it (or `pagination.pageSize`) is also what turns the paginator on on the client branch, where `paginated` is `never`. |
73
+ | `groupLabelPlacement` | `"grouping-column" \| "first-column"` | `"grouping-column"` | **CLIENT BRANCH ONLY — M5.** Where a group row's label, chevron and count land. They are ONE node and move together, so this prop picks the cell all three fall into. `"grouping-column"` (default) puts them in the cell of the column that groups **at that level**; `"first-column"` puts them in the **first rendered column**, which is the mockup's shape. ⚠️ **It places, it does not pin**: the identity sits in a frozen left band that does not scroll away ONLY if that first column declares `pinned: "left"`. On an unpinned first column the label scrolls away with the body — the play passes because the fixture's first column happens to be pinned. **The default reproduces today's behaviour on purpose:** flipping it would move the label in every consumer's grouped grid without anyone asking, so a grid that does not pass this prop renders exactly what it rendered before. **A closed union of two forms, never a column id** — a free id would be an unverifiable `string`, and pointing it at a column that does not exist (or is hidden) would do nothing and say nothing, leaving the grid on the default while the author believes they moved it. **It hides no column, and that is why the prop exists:** moving the label left used to require *hiding* the grouping column — a group row falls back to the first visible column when its own is gone — which drops the rendered leaves by one and makes the «fourteen columns» clause of the complete scene false. `"first-column"` unhooks PLACEMENT from VISIBILITY: every column stays, and the grouping column's cell goes back to the common rule — its own aggregate if it declares one, otherwise empty (R11: the grouping value appears once, and it is the label that says it). **Server variant: `never`**, next to `groupBy` and `initialOpenGroups` — a grid that does not group has no group label to place, and without the twin this prop would be a silent no-op instead of a type error. |
74
+ | `error` | `boolean` | `false` | **Fase 5.** The request FELL: the data exists and did not arrive. It is the PREDICATE, and `onRetry` is not one — without it a failed fetch and a filter that catches nothing are the same state for the grid (`loading={false}`, `data={[]}`), and the reader is told «no results» where they should be told to retry, so they change the filters instead. Lives on **BOTH branches** of the union. It **wins over the empty outcome** (a failed request has zero rows, so both predicates would be true) and, like the empty one, does **not** show while `loading`: an error from the previous attempt above the skeletons of the current one describes the past. |
75
+ | `onRetry` | `() => void` | `undefined` | **Fase 5.** What to do when the reader asks to retry after a failed load. Governs the «Retry» control **only** — whether the error outcome renders is `error`'s job: absent, the error outcome is a message without a button, which is the honest shape when the caller has nothing to relaunch. Lives on **BOTH branches** of the union — a request fails on the server branch too. One click invokes it **exactly once**; the grid keeps no attempt counter, because it holds no state. |
76
+ | `filterControls` | `GridFilterControlRegistry` | `undefined` | **Fase 8 (R15).** The registry of the filters the APPLICATION declares — `Readonly<Record<string, GridFilterControl>>`, keyed by `GridFilterControl["type"]`. The grid does **not** render a registry entry's `Control`: the control that writes a custom filter's value is rendered by the application, where the filter is born — `createGridCustomFilterBadge` is the only place `describe` and `toFragment` are ever called, and from there the badge carries its own text and fragment. The grid uses the registry for ONE measurable thing: recognising a `kind: "custom"` badge whose `customKey` is **not** a key of the registry, and marking its chip with `data-grid-toolbar-custom-filter-unknown` instead of showing it like the others — a badge that did not come from the registry is a defect, and it is made noisy rather than plausible. Absent, no chip is marked. Lives on **BOTH branches** of the union. |
77
+ | `onRemoveFilter` | `(id: string) => void` | `undefined` | **Fase 8 (R15).** Removes ONE filter without a column, by badge `id` — not by `customKey`, since two custom filters of the same type share the registry key and only the `id` tells them apart. **It is the door that turns the dashed toolbar group on:** without a handler the group is not rendered at all, because each custom filter's «×» is a real, named `<button>` and a button without a handler is a dead command. It is deliberately **not** on the `toolbar.filters` slot: filters without a column exist independently of opening the panel, so the group renders next to the «Filters» trigger and OUTSIDE its ternary — a consumer who does not declare the slot, or who replaces the trigger with their own `render()`, still gets the group. **Executable precondition: it also needs `filters` lifted — and since R28 that holds for ONE of its two readers, not both.** ⚠️ This row used to say «the grid has no way to remove a badge from its own internal slice — the `query/filters` action exists in the reducer and nothing dispatches it». That is FALSE since R28: `clearFilterRowBadge` (`app-data-grid.tsx`) dispatches exactly `query/filters` with the badge filtered out, and it is the door behind the Zero Row's clear «×». The sentence is corrected, not kept. The two readers now split: for the **dashed group of column-less filters** the precondition still holds, because that group is a pure passthrough with no branch that writes the internal slice, so `onRemoveFilter` without `filters` would give a «×» that announces itself, takes focus, fires the handler and changes nothing on screen — which is why it renders only when BOTH are present. For the **Zero Row's «×»** it does not: `clearFilterRowBadge` branches on where the data lives, so with filters NOT lifted it removes the badge from the internal slice itself, and the condition governing that «×» is «internal slice OR `onRemoveFilter` wired», which is strictly weaker. ⚠️ **And with `filters` NOT lifted, `onRemoveFilter` is never called even if you wired it** — the Zero Row's «×» writes the internal slice directly and does not route through your handler. So a caller that wires it expecting to observe every removal will miss those: the handler is the door for the LIFTED case only. (The two paths branch on different conditions — `clearFilterRowBadge` on where the data lives, `applyFilterRowPatch` on which callback is wired — which is a known open item, not a contract.) Whoever lifts the filters passes `filters.badges` to `filters` and `filters.removeBadge` here — the same `useGridFilters` instance. Lives on **BOTH branches** of the union. |
78
+ | `filterMode` | `"row" \| "panel"` | `"panel"` | **Riga Zero.** WHERE the filters live. `"panel"` (the default) is the inventory panel: an overlay anchored to the toolbar's «Filters» trigger. `"row"` is the ZERO ROW — a FOURTH `<tr>` of `<thead>`, one control per column, inside the columns. **The default is `"panel"` and it is a decision, not convenience:** the zero row is the new mode and the panel the one already delivered and already measured, so a default on the other one would turn red every existing scene that counts `<thead>` rows without anyone having touched it. Whoever wants the row asks for it. **It is not a switch.** The library renders no control that moves between the two modes: that choice belongs to whoever MOUNTS the component (a story, an application setting), because a switch inside the grid would mean the grid owns a state the caller already declares as a prop — two sources for one thing. ⚠️ **In `"row"` the row exists only while the toggle is OPEN — changed on 2026-09-04 (R13).** Until then the row was rendered ALWAYS and the «Hide filter row» button only took its CONTROLS out of sight, because unmounting the `<tr>` drops every body row by `gridFilterRowHeight` px while the frame stays put — the same pixel under the pointer changing command. That price was PAID on purpose: the residue (a 36px row with nothing in it, plus a chip band put there to stand in for it) cost more than the jump, and the button's name promises to remove the row. Reserve the space with `gridViewportHeight({ …, hasFilterRow: filterMode === "row" && open })`, reading `open` from `onFilterRowOpenChange`; deriving it from the MODE alone now promises 36px that a closed row does not occupy. Lives on **BOTH branches** of the union. |
79
+ | `onAddFilter` | `(badge: GridBadgeFilter) => void` | `undefined` | **Riga Zero.** A cell of the zero row creates a filter for a column that had none. The badge arrives **already built** by `createGridFilterBadge` — the one and only constructor, the same the inventory panel uses — so wire it straight to `useGridFilters().addBadge`, the very function you also hand to `GridFilterPanel.onAdd`: the two surfaces then share one store and «both roads produce the same state» is a fact of the wiring, not a coincidence between two implementations. **Required whenever `filters` is lifted**, and the grid enforces it by omission: with lifted filters the grid has no way to write them itself (the reducer would be overwritten by the prop on every render), so without this callback the cells of the zero row stay EMPTY — the row is still there, the height still reserved, but no control is offered rather than a named, focusable target that fires a handler and changes nothing. With filters NOT lifted it is unnecessary: the grid writes its own slice. Lives on **BOTH branches** of the union. |
80
+ | `onUpdateFilter` | `(id: string, patch: Partial<GridBadgeFilter>) => void` | `undefined` | **Riga Zero.** A cell of the zero row changes the operator or the value of a filter that already exists. Same pairing as `onAddFilter` — wire it to `useGridFilters().updateBadge`, the same function `GridFilterPanel.onUpdate` gets — and the same rule: required whenever `filters` is lifted, or the cells stay empty. Lives on **BOTH branches** of the union. |
81
+ | `statusRowVisible` | `boolean` | `false` | **M4.** Renders the status line ON SCREEN as well. `[data-grid-live-region]` has existed since P8 and carries `sr-only`: it says «128 rows. 0 filters active. 0 rows selected» and only a screen reader receives it. Turned on, the SAME string — no second sentence, no second text node — is rendered on screen inside a frame carrying a megaphone icon, so **a reviewer can read the text a screen reader would receive instead of approving the a11y on trust**. **Off by default, and that is not caution:** `sr-only` is `position: absolute`, so today's node is NOT a flex item of the root — it contributes neither height nor the frame gap — while turning it on by default would add an in-flow child to every existing scene. **It does NOT open a second live region**, and that invariant is what the prop must not touch: the frame carries no `role` and no `aria-live`, the ICON sits OUTSIDE the region, and the region keeps one text node and zero element children. Copying the mockup's announcement literally — a `role="status"` container holding the icon plus a `<span>` — would take the region count to TWO and have the sentence announced in pieces. **Declared limit:** `gridFrameHeight` has no addend for this line, so on a bounded frame the root gains an in-flow child the formula does not reserve; whoever budgets the height of the whole component adds it themselves. Lives on **BOTH branches** of the union. |
82
+ | `onFilterRowOpenChange` | `(open: boolean) => void` | `undefined` | **R13.** Fires when the «Hide filter row» / «Show filter row» toolbar button is pressed, with the NEW state: `true` = the zero row is NOW mounted. ⚠️ **The toggle starts OPEN** — the grid holds it as `useState(true)` — so a caller mirroring it must seed its own state to `true`; seeding `false` gives a viewport short by `gridFilterRowHeight` px from mount that nothing ever corrects, because the first announcement only arrives on the first click. The toggle's state stays LOCAL to the grid — it is a reading preference of this instance, like an open column menu — but since R13 it has a GEOMETRIC consequence the caller can no longer derive from `filterMode` alone: **hiding the row UNMOUNTS the `<tr>`**, so `<thead>` is `gridFilterRowHeight` px shorter. Whoever budgets the height passes `hasFilterRow: filterMode === "row" && open` to `gridViewportHeight`, and this callback is where `open` comes from. Without it the budget stays the one for a row that is present — 36px of viewport promised and not rendered. **It is NOT a controlled prop, and the limit is declared:** there is no incoming `filterRowOpen`; the grid does not accept having its own toggle imposed on it, which would be two sources for one thing. This is the read-only channel. **Omitted by default, and omitting it moves nothing.** Lives on **BOTH branches** of the union. |
83
+ | `onSelectionScopeChange` | `(scope: GridSelectionScope) => void` | `undefined` | **M10.** Announces that the SCOPE of the selection changed, with the new value: `{ scope: "page" | "all", rowIds, count }`. ⚠️ **It exists because `onCheckedRowsChange` cannot say it, and without this channel the grid would publish TWO diverging numbers.** With the selection promoted the bar reads «128 rows selected» while `onCheckedRowsChange` keeps emitting the TEN rows of the page — which is correct, those are the rows the grid owns — so a consumer rendering its own count outside the grid would print 10 next to a 128. The scope arrives from ONE place, and it is the same object the bulk actions receive. ⚠️ **`rowIds` are ALWAYS the page's**, even at scope `"all"`: promoting does not conjure 118 ids out of nothing. Whoever reads `rowIds` at scope `"all"` is reading a SUBSET, and `scope` is the field that says so BEFORE they delete it. `count` is the only field that changes with the scope, and it is the figure every bar button shows. ⚠️ **It is NOT a control:** the grid owns the scope and this callback ANNOUNCES it — there is no prop to impose it from outside, because promoting is a gesture of whoever is looking. Same shape as `onFilterRowOpenChange`. ⚠️ **It reports GESTURES, not derivations — and the case that diverges is «offered, promoted, then withdrawn».** It is called only by the gesture that changes the scope. If the caller stops offering the promotion while the scope is `"all"` — dropping `resultTotal`, or setting `selection.mode` back to `"page"` — the grid goes back to composing `"page"` and the page count, and **nothing announces it**: the last notification said `all:128`, and a consumer that stopped there would act on 128 while the grid knows ten. This is **not** repaired with an effect, deliberately: an effect watching the derived scope would fire at mount and on every re-render that changes the total, announcing changes nobody asked for. The limit is DECLARED — whoever reads this callback must treat the offer's preconditions as part of the contract, not just the last value received. Withdrawing the offer is a change the consumer made itself. ⚠️ **It does not fire if the promotion is not OFFERED.** Without `selection.mode: "all"` the scope cannot change, so a page-scoped grid never calls it: receiving it once at mount with `scope: "page"` would be an event describing no change. It also needs the result total to be KNOWN (`resultTotal`, `serverConfig.totalItems`, or a non-server `paginated` grid): a «Select all undefined rows» button is worse than no button, so with an unknown total the control is not rendered and the scope stays `"page"`. ⚠️ **The announcement is INSIDE the gesture, never in an effect that watches state** — an effect would fire at mount and on every re-render that changes the total, announcing a change nobody asked for. Lives on **BOTH branches** of the union: promotion makes sense in server mode, where the page is a slice, and in client mode when `data` is already a slice loaded by the caller. |
84
+
85
+ **Selection never refetches.** It is held outside both state slices — it is not `query` (no refetch) and not `view` (not configuration) — so `data-emissions` is unchanged by selecting rows. There is a spec for it.
86
+
87
+ > These nine props were added in Fase 2 after the codemod measured that they blocked most of the corpus (`loading` alone: 246 of 297 files) while appearing in **no** phase of the migration plan. They are copied name-for-name and signature-for-signature from `DataTableProps`, which is what keeps the codemod a rename.
88
+
89
+ ### Counting props — the paginator, and where each figure comes from (Fase 4)
90
+
91
+ Every figure of SCOPE — the paginator's range, the chip row's clause, the count on the «Filters» trigger — is derived ONCE by `gridResultScope` (`grid-result-scope.ts`, one definition in the whole lane) and read from the SAME object by the three surfaces. Nothing recomputes it; a `Math.ceil(total / pageSize)` written in a surface is the second derivation the phase exists to forbid.
92
+
93
+ | Prop | Type | Default | Description |
94
+ | ---------------------------- | ------------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
95
+ | `pagination.pageSize` | `number` | `undefined` | Turns the paginator ON **and seeds the live page size** — precedence `initialState.pagination.pageSize` > `pagination.pageSize` > `pageSize`, resolved in ONE place at the `useGridEngine` call. (It used to be a switch that set nothing: `pagination={{ pageSize: 25 }}` rendered «Rows 1-10 of 128» and «10 per page», so the 25 did nothing and said nothing. `TagliaDalloSlot` is the spec.) Needed on the CLIENT branch, where `paginated` is `never`: there the paginator renders and DECLARES the scope but does not slice (`getPaginationRowModel` is mounted only by `paginated`) and does not navigate — see `navigable` below. Never rendered together with `groupBy`. |
96
+ | `pagination.pageSizeOptions` | `readonly (10 \| 25 \| 50 \| 100)[]` | `[10, 25, 50, 100]` | The sizes the «rows per page» select offers (decision 17). A closed union, not `number[]`: a size the select cannot render fails to compile instead of doing nothing. |
97
+ | paginator `navigable` | derived, not a prop | — | Whether changing page is OBSERVABLE: `paginated \|\| serverConfig !== undefined \|\| onQueryChange !== undefined`. False on the client branch, where all three are `never`: the paginator then renders the range and the rows clause only. Its `[data-grid-page-indicator]`, page numbers, first/prev/next/last and size select are ABSENT, not disabled — a control that dispatches into a void whose only visible effect is rewriting the label is worse than no control. A real client-side pagination needs a page channel on `GridClientProps`, i.e. a contract widening: **that decision belongs to a person, not to this phase.** |
98
+ | `resultTotal` | `number` | `undefined` | See the table above — client branch. The figure after «of». **The scope is POST-PRUNING on one condition:** it is built from `engine.state.query.filters`, which is the pruned list only if whoever lifts `filters` ALSO lifts `columnVisibility` — `case "view/column-visibility"` does not touch `query.filters`, so the grid cannot guarantee it alone. (Fase 8 follow-up: the pruning itself filters on `columnId` without consulting `kind`, so a `kind: "custom"` badge would be dropped and all three readers would agree on a wrong figure.) |
99
+ | `serverConfig.totalItems` | `number` | — | The server-branch twin of `resultTotal`. Also what TanStack uses for `getPageCount()`. |
100
+ | grid scope `scopeTotal` | derived, not a prop | — | The one figure three readers share: `resultTotal ?? serverConfig.totalItems ?? (paginated && !serverMode && !infinite.hasMore ? engine.filteredRowCount : undefined)`. **The third term is `adg-m2`** and needs no prop, because a grid that slices its own rows already knows the total. `engine.filteredRowCount` is `table.getPrePaginationRowModel().rows.length` — **the same model `getPageCount()` divides**, so `scope.pageCount` and `engine.pageCount` cannot separate. **It does not switch on where the scope is unknown:** under `serverMode` `data` is the server's slice (it would say 10 of 128), and while `infinite.hasMore` the total does not exist yet — the same term that makes `aria-rowcount` `-1`. |
101
+ | `GridBadgeFilter.kind` | `"column" \| "custom"` | `"column"` when absent | **Declared in Fase 4, and READ since Fase 8.** It is the discriminant of the badge: absent means `"column"`. `"custom"` marks a filter the application declared through `GridFilterControl` — it cites no column, the pruning never drops it (`isBadgeActive` reads `kind` first), the compiler ships its `customFragment` instead of rebuilding a condition from `columnAccessorKey`, and the four text surfaces (chip, panel row, dashed toolbar group, status line) read `customLabel` instead of composing «column + operator + value». Three companion fields travel with it: `customKey` (the registry key), `customLabel` (the text `describe` returned) and `customFragment` (the payload piece `toFragment` returned). |
102
+
103
+ **The scope is on the PUBLIC surface**, and it has to be: `GridFilterChips` is a public export and its new `scope` prop is typed `GridResultScope`, so a consumer mounting the chip row outside laif-ds had no exported way to BUILD that scope nor to NAME the type — the `[data-grid-filter-scope]` clause was dead code outside this repo. Exported from `laif-ds`: `gridResultScope`, `GRID_PAGE_SIZE_OPTIONS`, `GRID_IT_LOCALE`, and the types `GridResultScope`, `GridResultScopeInput`, `GridPageSizeOption`, `GridPaginationSlots`, `GridBadgeFilterKind`. `GridPageSizeOption` matters in practice: `pagination.pageSizeOptions` is `readonly GridPageSizeOption[]`, and an array declared in a variable without `as const` is `number[]`, so tsc names a type the consumer must be able to import.
104
+
105
+ **`aria-rowcount` is the declared total, not the rendered rows.** Per ARIA it is the count of ALL rows (`-1` when unknown), so on a paginated grid counting the RENDERED rows announced «row 1 of 10» next to a visible «Rows 1-10 of 128» — a false total, which is the very argument written next to the `-1` used for an infinite source. It is now `resultTotal ?? serverConfig.totalItems ?? (derived when the grid paginates itself) ?? rendered rows`, and `-1` while an infinite source still has pages. See «The scope on a self-paginating grid» for the derived term.
106
+
107
+ ### The scope on a self-paginating grid
108
+
109
+ **A grid that slices its own rows does not have to be told how many there are.** With `paginated` on
110
+ and no server behind it, `data` is the WHOLE corpus and the grid does the cutting, so the total is
111
+ already in the engine. `scopeTotal` therefore carries a third term after the two declared ones:
112
+
113
+ ```ts
114
+ resultTotal ??
115
+ serverConfig?.totalItems ??
116
+ (paginated && !serverMode && !(infinite !== undefined && infinite.hasMore)
117
+ ? engine.filteredRowCount
118
+ : undefined);
119
+ ```
120
+
121
+ **Why it is not a prop.** `resultTotal` lives only on `GridClientProps`, the branch that declares
122
+ `paginated?: never` (`app-data-grid.types.ts:1081`, cited by line number from a gate), and
123
+ `GridServerProps` does not carry it at all. `<AppDataGrid {...props} paginated resultTotal={128} />`
124
+ is `error TS2322`, measured with `tsc --noEmit` on the real file. Deriving the figure closes the gap
125
+ without adding a byte to the frozen contract.
126
+
127
+ **`filteredRowCount` is the model the paginator itself divides.** It is
128
+ `table.getPrePaginationRowModel().rows.length` — not `getFilteredRowModel()`, not the row model, not
129
+ the rendered rows. `engine.pageCount` is `Math.ceil` of that same number over the page size, so
130
+ `scope.pageCount` and `engine.pageCount` cannot separate: two derivations of one figure inside one
131
+ control is the defect the single-source rule exists to forbid.
132
+
133
+ **The declared limit.** On a `data` the CALLER has already sliced, `filteredRowCount` describes THE
134
+ SLICE and not the total — which is why the derivation is switched off under `serverMode`, where the
135
+ grid does not re-filter (`manualFiltering`) and the page arrives from outside. There the scope is
136
+ `serverConfig.totalItems`, and only that.
137
+
138
+ **And it stays unknown while the source is still open.** The third term carries the same
139
+ `infinite.hasMore` clause that makes `aria-rowcount` `-1`: with pages still to come, what the grid has
140
+ received is not a total, and announcing it as one is worse than announcing nothing. Both surfaces —
141
+ the visible figure and the attribute — go quiet together, which is what
142
+ `app-data-grid-infinite.stories.tsx :: InfiniteConPaginatedNonChiedeNulla` keeps green.
143
+
144
+ **What it closed, measured.** On `AppDataGrid/Completa :: ModalitaFiltri` (128 rows, 10 per page)
145
+ `[data-grid-page-range]` did not exist and `aria-rowcount` was `10`; it now reads «Righe 1-10 di 128»
146
+ and `aria-rowcount="128"`, with ten rows still rendered.
147
+
148
+ The selector dispatches `query/page-size` through the engine's `applyAndEmit` — ONE emission per gesture. `applyAndEmit` and not `applyView` is decided by the type (`query/page-size` is a member of `GridQueryAction`) and by the meaning: `querySignature` includes `pageSize`, so a new size is a new list the server has to know about. The reducer's `case` already restarts on `pageIndex: 0`, so no extra `query/page` is dispatched.
149
+
150
+ ### What arrives in `onQueryChange`
151
+
152
+ `GridServerState` is field-for-field the old `ServerState`, `computedFilter` included — that field is built from the search term across the searchable columns using the same algorithm as `DataTable` (one column → the bare condition, several → an `_or`), so a migrated handler that forwards it keeps filtering server-side. **Searchable is OPT-IN, as in `DataTable`**: only the columns that declare `searchable: true` are compiled into it, so a term is never compiled against a date, a boolean or an id array nobody asked to search — see the `searchable` row of the accessor-builder table for the default that changed on 2026-09-10 and the two backend answers that changed it. The term itself always travels in `filters.searchbarFilter`, searchable columns or not, so a handler that reads THAT field never sees a difference. Column-filter badges **are** populated: `filters.filterBadges` carries the active badges — the lifted `filters` prop, or `initialState.filters.filterBadges` at mount — into every payload, through `toGridServerState` (`grid-reducer.ts:576`). `advancedFilterBadge` is in the type and still **not** populated: nothing in the lane writes it, and the emitter story pins it to `undefined` while `filterBadges` carries the fixture.
153
+
154
+ Column-filter badges compile through the **same algorithm** as the frozen table, quirks included (valueless operators emit `value: true`; `list_single_select` values are array-wrapped; the empty string is dropped for simple badges but not for advanced conditions). A story asserts the two payloads are byte-identical across all twenty operators — see `data-wire-equal` below. ⚠️ **From 2026-09-10 that criterion also compares the GLOBAL-SEARCH fan-out**, which the old harness never crossed: it built its two payloads with an EMPTY term, so the branch that compiles the searchbar was dead on both sides and the comparison was green while the two sides disagreed. `app-data-grid-emitter.stories.tsx :: RicercaGlobaleUgualeAllaTabellaCongelata` builds the same six-column mixed fixture (string, number, date, boolean, two multi-selects) TWICE from ONE set of configs — once with the grid builders, once with the frozen ones — derives the searchable list on each side the way the live code does (`c.searchable` against `c.meta?.searchable`) and reads the field names back OUT of the payload. Measured: with the opt-in default both sides put `name,score,tags` on the wire and the payloads are identical; with the old `?? true` the grid side put `name,score,tags,created_at,flg_valid,roles_ids` and the comparison was `false`.
155
+
156
+ Unpaginated (the default), the emitted `pagination` is `{pageIndex: 0, pageSize}` and every row is rendered client-side; with `paginated`, page changes are query actions and emit exactly once each.
157
+
158
+ ### Why `onQueryChange` cannot fire on a view change
159
+
160
+ State is split into two slices held by **two separate reducers** with disjoint action types: `query` (what the server needs) and `view` (what the user configured — density, widths, visibility, order, fullscreen). Each has its own dispatcher, and only `applyAndEmit` calls `onQueryChange`. "Changing density must not cause a refetch" is therefore not a review note — it is unrepresentable. The emitter is also called **from the action, never from a `useEffect`**, which is what prevents the double initial load.
161
+
162
+ ⚠️ **Since 2026-09-09 the two continuous actions are COALESCED, and this paragraph used to say the emitter was called *synchronously*.** `query/search` and `query/filters` now wait `serverConfig.serverDebounceTime ?? 200` ms and emit **once**, with the final payload; every other query action — sort, page, page size, load-more, preset — still emits immediately, and **cancels a pending coalescence** rather than queueing behind it, so one gesture never puts two requests in flight. The reason is measured: the emission used to be synchronous inside the action, so a six-letter word in the searchbar was six `onQueryChange` calls, i.e. six HTTP requests, and a value typed in the filter **row** was one per keystroke. (The filter *panel*'s range editor never had the problem: «Apply» is the only gesture that calls `onApply`.)
163
+
164
+ ⚠️ **And the second pass, the same day, made the FILTERING wait too — this paragraph used to say "only the emission is deferred, never the state".** That was the whole defect that remained: the request went out once, but the table still re-filtered on every keystroke, and in **client** mode — where there is no request — the delay bought nothing at all. Measured on a client grid: typing in a filter cell took the rows from 10 to 0 within 60ms; on the searchbar, six keystrokes were six re-filterings.
165
+
166
+ **Two slices, and which reader gets which.** The **live** slice is what the *inputs* show — the searchbar is controlled by `state.query.search` and the filter-row editors read `state.query.filters`, so deferring that would make typing laggy, which is not what the delay is for. The **committed** slice is what *filters the rows* and what gets *emitted*; it advances immediately for discrete actions and after `debounceTime` for continuous ones. Committing and emitting are **one event**, so the table starts filtering with the same query snapshot the consumer receives. TanStack gets the whole committed snapshot — sorting, global filter and pagination together — because `query/search` and `query/filters` reset `pageIndex`, and mixing a live pagination with a committed filter would show page 0 of the *unfiltered* set for one frame. Measured after the change: six keystrokes leave the row count at a single distinct value, and the rows move once.
167
+
168
+ **The knob works in both modes**: `debounceTime` on the base props (`0` disables), with `serverConfig.serverDebounceTime` as the server-branch name it inherits from the frozen twin — `debounceTime` wins when both are present. The delay is no longer a property of the emitter: a client grid has no `onQueryChange` and still has something to defer, namely its own filtering.
169
+
170
+ ⚠️ **Lifted filters are the consumer's, and the grid does not defer state it does not own.** With `useGridFilters`, the rows are reduced by the consumer. The hook therefore publishes **`committedBadges`** next to `badges`: pass `badges` to the grid (the editors must stay live) and filter with `committedBadges`. It is **additive on purpose** — a consumer that keeps filtering with `badges` is not broken, it simply does not get the deferral, which is the failure mode we chose: making `badges` itself the committed value would have given everyone the deferral for free and made *typing lag in production* for anyone who forgot to feed the editors the draft.
171
+
172
+ ⚠️ **Inside the hook the split is by GESTURE, not by mutator name, and the first attempt got it wrong.** `updateBadge` — a value being typed or dragged — is continuous. `addBadge` is **discrete**: it is also how a badge is born from a switch, an `AppSelect` or a chip, and deferring those felt like a slow table. Measured: with `addBadge` classified as continuous, the switch in `FiltriSoloFuori` no longer announced its filter within the frame. The declared price is that a **new text filter** confirms on its first character and coalesces the rest — two confirmations per word instead of one; the global search, which does not go through the hook, stays at one.
173
+
174
+ ⚠️ **Two limits, declared.** Coalescing reduces requests, it does not **sequence** them: the landing order of the responses is still the consumer's problem. And it covers the **grid's** channel only — with filters **lifted**, `onFiltersChange` is the consumer's own callback, called synchronously inside the action (see the lifted-filters section), so that path stays one call per keystroke.
175
+
176
+ ⚠️ **Not the same behaviour as the frozen twin, despite the identical prop name**: in `DataTable`, `serverDebounceTime` (default 750) only delays the appearance of the loading overlay and coalesces nothing.
177
+
178
+ **The grid counts its own emissions.** The root carries `data-emissions`, and it is the contract in observable form: `0` at mount (even under `StrictMode`), `+1` per sort, `+1` per page change — and **unchanged** by any density switch. Read it from the DOM rather than instrumenting the callback. ⚠️ **It counts emissions, not gestures**, which since the coalescing above is a visible difference: a burst of keystrokes in the searchbar or the filter row raises it by **one**, not by one per key, and it rises when the window closes rather than on the keystroke — so read it inside a `waitFor`, not right after typing.
179
+
180
+ ---
181
+
182
+ ### Cosa i tipi rifiutano (what the types reject)
183
+
184
+ `AppDataGridProps` is a **union of two variants** — a client grid and a server grid — and what
185
+ follows is not a list of props to pass: it is the list of shapes that **do not compile**. They are
186
+ documented here and deliberately kept **out of the prop table**, because a table row teaches you to
187
+ pass the prop, and passing these is precisely what the union forbids. The compiler is the
188
+ enforcement; this section is only its transcript.
189
+
190
+ | Union constraint | Declared on | What it rejects, and why |
191
+ | ----------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
192
+ | `serverMode?: never` | client variant (`GridClientProps`) | A grouped, client-side grid does not talk to a server. `serverMode` — the name and semantics of `DataTable`, kept for the codemod — exists **only** on the server variant. Together with `groupBy` it is a compile error, not a runtime surprise. |
193
+ | `serverConfig?: never` | client variant (`GridClientProps`) | Same reason: `GridServerConfig` (`totalItems`, `onStateChange`, `serverDebounceTime?`) belongs to the server variant only. `onStateChange` there is an **alias** of `onQueryChange`, never a second channel — with both present `onQueryChange` wins, because two emitters would be two refetches per gesture. |
194
+ | `initialState?: never` | client variant (`GridClientProps`) | The server-mode trio is one trio: `initialState` travels with `serverMode` and `serverConfig`. It is documented as a prop in the table above, where it belongs — on the server variant. |
195
+ | `onQueryChange?: never` | client variant (`GridClientProps`) | A grouped grid does not emit: grouping is client-side by construction, so there is nothing to ask a server for. |
196
+ | `paginated?: never` | client variant (`GridClientProps`) | Pagination slices the rows _before_ grouping sees them, so a group would count — and `aggregatedCell` would sum — only the current page: measured at 6 rows where the group had 340, with another "Italy" reappearing on the next page. That is the number that looks like a total and is not, which is what the union exists to forbid. |
197
+ | `groupBy?: never` | server variant (`GridServerProps`) | Server-side grouping is not representable, and this is where the sentence stops being a promise in a document: a server grid groups nothing. |
198
+
199
+ Passing a rejected combination is caught at compile time. There is no runtime warning for it, and
200
+ that is the point — see `type-probes/` for the probes that assert `tsc` keeps refusing them.
201
+
202
+ ---
203
+
204
+ ## Column builders
205
+
206
+ Four builders ship in Fase 1. They cover both families of the frozen `cell:` contract. **The remaining types have arrived**: `createGridBooleanColumn`, `createGridDateColumn`, `createGridDateTimeColumn`, `createGridSingleSelectColumn`, `createGridMultiSelectColumn` and `createGridComputedColumn` (P10), and since P7 the type of a column decides the filter **control** as well as the operator list — see `filterType` in the table above.
207
+
208
+ | Builder | `cell` arity | `cell` required? | `id` required? |
209
+ | ------------------------- | ------------------------ | ---------------- | -------------- |
210
+ | `createGridStringColumn` | `(value, row, tableRow)` | no | no |
211
+ | `createGridNumberColumn` | `(value, row, tableRow)` | no | no |
212
+ | `createGridActionColumn` | `(row)` | **yes** | **yes** |
213
+ | `createGridDisplayColumn` | `(row)` | **yes** | no |
214
+ | `createGridRowMenuColumn` | — (no `cell`) | n/a | **yes** |
215
+
216
+ `createGridRowMenuColumn` (Fase 2, fetta E) is the odd one out: it has **no `cell`**. The caller
217
+ declares the menu **items** through `rowMenu`, and the grid renders the trigger and the panel — see
218
+ [Row header and row menu](#row-header-and-row-menu-fase-2-fetta-e).
219
+
220
+ **Memoize `columns`.** It is compared by identity; an array rebuilt inline on every render rebuilds the column registry and the internal column defs each time. Define it at module scope or wrap it in `useMemo`, as TanStack requires of its own `columns`.
221
+
222
+ The arity difference is **deliberately copied** from `DataTable` — it is what lets an existing `cell:` closure keep compiling after a rename.
223
+
224
+ ### Accessor builder options
225
+
226
+ | Option | Type | Default | Description |
227
+ | ----------------- | ---------------------------------------------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
228
+ | `accessorKey` | `string` | — | **Required.** Dotted paths (`user.name`) are read as depth. |
229
+ | `header` | `string \| React.ReactNode` | — | **Required.** If not a string, `headerLabel` becomes required. |
230
+ | `headerLabel` | `string` | `header` when it is a NON-EMPTY string, otherwise the column `id` | The text form, used by assistive tech and menus. ⚠️ **The default changed on 2026-09-09** and the reason is measurable: an EMPTY `aria-label` is skipped by accname, so with `header: ""` and no `headerLabel` the `columnheader` name recomposed itself from the `<th>` contents — **the resize handle's pixel width included**, i.e. the column's accessible name was a number. Falling back to the `id` keeps it non-empty. Applies to the six accessor builders plus action, display, row-menu and group. |
231
+ | `id` | `string` | `accessorKey` | Registry key. Must be stable across reloads. |
232
+ | `sortable` | `boolean` | `true` | ⚠️ **Still `true`, and it is the code and not an oversight**: the frozen twin defaults it to `false`, so a migrated accessor column gets a sort `<button>` (and the «Sort: …» accessible name, and the query action its click dispatches) where `DataTable` gave none. Aligning it is an owner-level decision, not a doc fix: it would move a visible control in every migrated table and would need its own story pass. Same for `filterable` below. `searchable` was NOT in that class — it changes the payload. |
233
+ | `filterable` | `boolean` | `true` | ⚠️ **Still `true` against the frozen twin's `false`**, deliberately, like `sortable`: the consequence is that every accessor column is OFFERED in the filter inventory and in the zero row (`use-grid-filters.ts` copies the flag, `parts/grid-filter-panel.tsx` reads it), where `DataTable` listed only what the call-site declared. Nothing compiles it into `computedFilter` by default, so the wire is unaffected. |
234
+ | `searchable` | `boolean` | `false` | Whether the global search looks at this column. ⚠️ **OPT-IN, and the default CHANGED on 2026-09-10: it used to be `true`.** It is `false` because `buildColumnBase` in the frozen `DataTable` (`data-table.utils.ts`) is `false`, and because this flag decides WHAT REACHES THE WIRE: `searchableColumns` (`engine/use-grid-engine.ts`) takes every column carrying it and `buildComputedFilter` (`grid-reducer.ts`) compiles one branch of an `_or` for each. With the old default a term was compiled against dates, booleans and id arrays too, and it is not an aesthetic detail — measured against the template's real backend: `/users/search` with «admin» answered **404**, because `roles_ids` was compiled as `array_overlap` with `["admin"]` and `integer[] && text[]` is not a Postgres operator (`CAST(dat_connection AS VARCHAR) ILIKE '%admin%'` travelled in the same payload); `/permissions/search` with «2» answered 200 with **27 rows out of 33**, matched through `id LIKE '%2%'` and `roles_count LIKE '%2%'`. Declare `searchable: true` on the columns the search must look at — the same thing a `DataTable` call-site already writes, which is why `data-wire-equal` now compares the search fan-out too. ⚠️ **In client mode the same flag gates the LOCAL filter** (`getColumnCanGlobalFilter` reads `registry.get(id)?.searchable ?? false`), so a grid whose columns declare nothing does not filter on typing — the frozen table's behaviour as well. |
235
+ | `pinned` | `"left" \| "right"` | `undefined` | Freezes the column against one edge while the grid scrolls horizontally. `"left"` since P4, `"right"` since Fase 2. Offsets accumulate over the pinned columns of that side, from that edge: on the right the **last** pinned column sits at `0` and each earlier one steps back by the widths to its right. |
236
+ | `align` | `"left" \| "right"` | `"left"` | Which edge the column's content aligns to. `"right"` also brings `tabular-nums`, because right-aligning a numeric column without fixed-width digits does not line the units up — which is the only reason to right-align numbers. It applies to the leaf `<th>`, the data `<td>` **and** the group-row cell: a column aligned in its cells but not in its own header reads as two overlapping columns. |
237
+ | `rowHeader` | `boolean` | `false` | Renders this column's cell as `<th scope="row">` instead of `<td>` — the word with which HTML says "this cell heads the others in its row". **At most one column per grid may declare it**, and a second one throws: two row headers make the row announce itself with two different names. It is an explicit opt-in, never derived from "the first column" — the first column may be the one that _groups_ (its cell is then muted, so the row header would have no name) or hidden by `columnVisibility`. |
238
+ | `rowMenu` | `(row: TData) => GridRowMenuItem[] \| undefined` | `undefined` | Only on `createGridRowMenuColumn`, where it is **required**. The cell renders the `…` trigger instead of its own content. Returning an empty list renders no trigger. See [Row header and row menu](#row-header-and-row-menu-fase-2-fetta-e). |
239
+ | `width` | `number` | `160` | Initial width in px. A drag replaces it; from then on this number no longer participates. |
240
+ | `cell` | `(value, row, tableRow) => ReactNode` | `undefined` | Falls back to the stringified value. |
241
+ | `cellClassName` | `string \| ((value, row) => string)` | `undefined` | Note the **two**-parameter callback — a third asymmetry, copied. |
242
+ | `headerClassName` | `string` | `undefined` | — |
243
+ | `aggregatedCell` | `(rows: TData[]) => ReactNode` | `undefined` | The aggregate rendered in the group row (P9). It receives the **original rows** descending from the group at any depth — not the values of its own column — so it can write "three of twelve above the threshold". **Absent = an empty group cell**: this is the only way to fill one. |
244
+ | `aggregate` | `(rows: TData[]) => ReactNode` | `undefined` | **Fase 5.** The column total rendered in the `<tfoot>`. **It sums the RENDERED rows, not the group aggregates** — with two `groupBy` levels every row belongs to two group nodes (`buildGridGroupTree` accumulates it at each level traversed), so summing the group totals counts each row once per level: 2× with two levels. «Rendered» is the **row model** — after search, filters and sorting, and with `paginated` the current page slice — **not** the rows visible in the DOM: a collapsed group hides its leaves and does not remove them from the total. Absent = an empty footer cell, and if NO visible column declares one the `<tfoot>` does not exist at all. Only on accessor builders: an action or display column carries no per-row value to aggregate. |
245
+ | `options` | `readonly string[] \| readonly GridSelectOption[] \| readonly AppSelectOption[]` | `undefined` | The options of a list column (P10), for `createGridSingleSelectColumn` / `createGridMultiSelectColumn`. Bare strings are normalised by the builder into `{value, label}`, so downstream there is **one** shape only. |
246
+ | `filterType` | `"string" \| "number" \| "boolean" \| "date" \| "datetime" \| "list_single_select" \| "list_multi_select"` | `columnType` | **P7. How the column is FILTERED, when its `columnType` does not say it.** `columnType` decides how the value is RENDERED; `filterType` decides which operators the panel offers and which control writes the value — a `string` column that declares `filterType: "list_single_select"` plus `options` is filtered from a `<select>` over those options instead of a free text field, which is the whole point of the phase. Absent falls back to `columnType`, so every column written before P7 keeps filtering exactly as it did. The union is `Exclude<GridColumnType, "other">`: a computed column (`columnType: "other"`) has **no** filter vocabulary, so `gridColumnFilterControl` returns `undefined` and the panel does not offer it at all — which is how «no filterType is left without operators» holds by construction rather than by a fallback. It is a **per-column** option, so `check-grid-props-documented.mjs` does not require it (that gate reads `AppDataGridProps`); it is documented here because for an agent writing a call-site a prop that is not in this table does not exist. |
247
+
248
+ ### Column group builder (P5)
249
+
250
+ `createGridColumnGroup({ id, header, headerLabel?, columns })` produces a header that **covers** other
251
+ columns. A group is not a column: it has no `accessorKey`, no `cell`, and is never sortable, filterable or
252
+ resizable.
253
+
254
+ | Option | Type | Default | Description |
255
+ | ------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
256
+ | `id` | `string` | — | **Required, and never derived from the label.** It is the root of the DOM ids in the a11y chain: two groups both called "Anagrafica" in different branches would produce the same chain — body cells pointing at the wrong header, with `tsc` green. It must also be unique against the **leaf** column ids, not only against the other groups: `validateGridColumnTree` throws «due nodi condividono l'id "…"» when a group reuses the id of a column it covers, so a group over `avanzamento` is named `progresso`, never `avanzamento`. |
257
+ | `header` | `string \| ReactNode` | — | **Required.** |
258
+ | `headerLabel` | `string` | `header` | Required when `header` is not a string — it is the text an assistive technology reads. |
259
+ | `columns` | `GridColumnNode<TData>[]` | — | **Required.** Leaf columns or further groups. Nesting has no ceiling. |
260
+
261
+ Groups reach nothing below the `<thead>`: the engine **flattens** them into leaves before the registry sees
262
+ them, so rows, sorting, filtering, widths and pinning keep seeing the flat list of P1-P4.
263
+
264
+ ---
265
+
266
+ ## Usage
267
+
268
+ ```tsx
269
+ import {
270
+ AppDataGrid,
271
+ createGridActionColumn,
272
+ createGridNumberColumn,
273
+ createGridStringColumn,
274
+ type GridColumnDef,
275
+ } from "laif-ds";
276
+
277
+ interface Invoice {
278
+ number: string;
279
+ customer: string;
280
+ total: number;
281
+ }
282
+
283
+ const columns: GridColumnDef<Invoice>[] = [
284
+ createGridStringColumn<Invoice>({
285
+ accessorKey: "number",
286
+ header: "Number",
287
+ }),
288
+ createGridStringColumn<Invoice>({
289
+ accessorKey: "customer",
290
+ header: "Customer",
291
+ }),
292
+ createGridNumberColumn<Invoice, number>({
293
+ accessorKey: "total",
294
+ header: "Total",
295
+ cell: (value) => `€ ${value.toFixed(2)}`,
296
+ }),
297
+ createGridActionColumn<Invoice>({
298
+ id: "actions",
299
+ header: "Actions",
300
+ cell: (row) => <Button onClick={() => open(row.number)}>Open</Button>,
301
+ }),
302
+ ];
303
+
304
+ export const InvoicesGrid = ({ invoices }: { invoices: Invoice[] }) => (
305
+ <AppDataGrid
306
+ columns={columns}
307
+ data={invoices}
308
+ onQueryChange={(state) => {
309
+ // Fires on search / sort / filter / page — never on a view change.
310
+ void fetchInvoices(state);
311
+ }}
312
+ />
313
+ );
314
+ ```
315
+
316
+ ---
317
+
318
+ ## Coexistence with `DataTable`
319
+
320
+ Both can be mounted on the same page. What makes that safe is enforced mechanically by six CI gates, not by convention:
321
+
322
+ | Guarantee | Enforced by |
323
+ | ----------------------------------------------------- | ------------------------------------- |
324
+ | No import from `tables/data-table/`, barrels included | `check-grid-isolation.mjs` |
325
+ | No `ColumnMeta` module augmentation, no `meta:` | `check-grid-isolation.mjs` |
326
+ | No `export *` (a collision would be TS2308) | `check-grid-isolation.mjs` |
327
+ | `@tanstack/*` confined to one file | `check-grid-isolation.mjs` |
328
+ | Copied contracts cannot drift from the originals | `check-datatable-grid-parity.mjs` |
329
+ | Grid and a shared primitive never in one change | `check-datatable-shared-surface.mjs` |
330
+ | `designTokens` is append-only | `check-design-tokens-append-only.mjs` |
331
+ | No arbitrary Tailwind classes; all resolve in v3 | `check-grid-css.mjs` |
332
+ | Hand-written lane rules identical in the THREE CSS sheets | `check-grid-css.mjs` |
333
+ | The same rules identical in the two BUILT bundles | `check-grid-css.mjs` |
334
+
335
+ **The search field's native «×» is suppressed by a hand-written rule that has to live in THREE stylesheets.** `[data-grid-search]` keeps `type="search"` — that is what gives it the implicit `searchbox` role and the platform's Escape-to-clear, and a story asserts the attribute — so Blink paints its own `::-webkit-search-cancel-button` next to the grid's `[data-grid-search-clear]`. The suppression cannot be a Tailwind class (it would need the arbitrary variant `&::-webkit-search-cancel-button`, which rule 1 of `check-grid-css.mjs` refuses in the lane), so it is written by hand as `[data-app-data-grid] input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none }` — ONE declaration, `-webkit-` prefixed, because the unprefixed form is dead by construction: the selector does not exist outside WebKit/Blink. It must be in `src/styles/index.css`, `src/styles/index.v3.css` **and** `src/styles/css-for-template.css`, because a consumer receives `dist/styles.css`, or `dist/styles.v3.css`, or — if it is a template app — **only** `dist/css-for-template.css`, which `copy:template-css` produces as a verbatim `cp` of that third sheet. ⚠️ **It shipped in the first two and not the third, and the template showed TWO «×»**: measured on the live page, 6 stylesheets and 2936 rules, ZERO of them mentioning `search-cancel-button`. `check-grid-css.mjs` rule 3 now compares all three sheets (`CSS_LANE_SHEETS`), so the same omission is red — count mismatch when the rule is absent, per-rule diff when it is present but different. **Declared residual, written in the gate:** the third sheet is NOT in rule 4's freshness reference (it is not an input of `dist/styles.v3.css` at all), so editing only that sheet leaves `dist/styles.css` genuinely stale without the guard noticing — `npm run build:css` rebuilds it before publishing either way. And `index.css` `@import`s the third sheet, so the v4 bundle sees the rule twice: measured, Lightning CSS merges the identical duplicates and rule 4 compares 1 against 1. If a future toolchain stops merging, the repair is removing the copy the `@import` makes redundant, not weakening rule 4.
336
+
337
+ Grid menus use `modal={false}`, so opening one does **not** put `aria-hidden` on the old table or lock page scroll. A story mounts both and asserts exactly that.
338
+
339
+ ---
340
+
341
+ ## DOM contract (for tests)
342
+
343
+ Stable hooks, independent of the i18n language:
344
+
345
+ | Selector | What it marks |
346
+ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
347
+ | `[data-app-data-grid]` | The grid container — carries `data-emissions`, `data-render-count`, `data-mount-count`, `data-loadmore-call-count` and `data-grid-fullscreen` |
348
+ | `[data-grid-search]` | The search input. **From Fase 3 it carries a VISIBLE `<label>`** bound by `htmlFor` (supplied by the `Input` primitive from `toolbar.searchLabel`) and **no `aria-label`** — the two could otherwise diverge, and the `aria-label` would win. **From `adg-m3` the label sits BESIDE the field, not above it**, the field carries a magnifier INSIDE it, and the cell no longer grows: four attributes on this node (`iconLeft="Search"`, `size="sm"`, `labelClassName`, and a `className` that turns the primitive's `flex-col` into `flex-row items-center`), no slot and no change to `ui/input.tsx`. ⚠️ **`[data-grid-search]` is the `<input>` itself, not the box you see**: the primitive wraps input + magnifier in a bordered box and that box in a label/field row, so a geometric probe wants `.parentElement` (the field) — reading the `<input>` measures the text area inside the padding. ⚠️ **The label's `top` does NOT equal the field's `top`** — the label is shorter and vertically CENTRED in the row, 8-9px lower; the measurable property is that the two vertical CENTRES coincide (within 2px) and the label's `right` is at or before the field's `left`. The mockup itself behaves this way, measured. ⚠️ **`flex-1` is gone, and that is load-bearing**: with the field growing, the `ml-auto` on the toolbar's right group has no residual margin to take and is dead — see `[data-grid-toolbar]`. ⚠️ **Closed with a declared residual**: the field measures **182×32** against the mockup's **208×32**. The height is exact (`size="sm"` → `h-8`); the width is not, and is deliberately out of reach — copying the mockup's `w-52` would hardwire a second truth about how wide a search field is, to be kept in agreement with the first at every touch. Without `flex-1` the cell sizes to the `<input>`'s intrinsic content, which is the same scale without the number |
349
+ | `[data-grid-viewport]` | The scroll container (P3 takes it over) |
350
+ | `[data-grid-table]` | The `<table>` |
351
+ | `[data-grid-col="<id>"]` | The `<col>` of a column |
352
+ | `[data-grid-header="<id>"]` | A **leaf** `<th>`; carries `aria-sort`, `id` and `aria-label` |
353
+ | `[data-grid-header-row="<n>"]` | A header `<tr>`, 0-based from the top (P5) |
354
+ | `[data-grid-header-group="<id>"]` | A **group** `<th>`; carries `scope="colgroup"` and its `colSpan` |
355
+ | `[data-grid-header-placeholder]` | An empty `<th>` standing in for a leaf in the rows above it — never a `rowSpan` |
356
+ | `[data-grid-sort="<id>"]` | The sort button |
357
+ | `[data-grid-row]` | A **data** row — never a spacer |
358
+ | `[data-grid-cell="<id>"]` | A cell — a `<td>`, or the `<th scope="row">` of the row-header column |
359
+ | `[data-grid-row-header]` | The one cell per row that heads it. Always a `<th scope="row">`, and always exactly one when a column declares `rowHeader` (Fase 2, fetta E) |
360
+ | `[data-grid-cell-text]` | A text cell that **fits** its column — no target, no tooltip, no tab stop added |
361
+ | `[data-grid-cell-expand]` | The focusable target of a text cell that **is** truncated. Its accessible name is the full value; the same value is in an `sr-only` node |
362
+ | `[data-grid-cell-tooltip]` | The tooltip panel of a truncated cell. Portaled to `<body>` — never inside the row |
363
+ | `[data-grid-row-menu]` | The `…` trigger of the row menu. Accessible name = the label plus the row's identity (from the `rowHeader` column) |
364
+ | `[data-grid-row-menu-item="<id>"]` | One item of the row menu. A destructive item carries `data-variant="destructive"` |
365
+ | `[data-grid-spacer="top\|bottom"]` | The spacer rows. Height `0` unless virtualized; inline `style.height` when it is |
366
+ | `[data-grid-pinned="true"]` | A cell pinned to **either** edge (header stub or body cell). The side is in the computed style: `left` for the left band, `right` for the right one |
367
+ | `[data-grid-resize-handle="<id>"]` | The resize handle of a column — `role="separator"`, focusable, `aria-valuenow`. The 8px band itself is **never painted at rest** (WCAG 2.5.8 target, not decoration); the visible mark is `[data-grid-resize-track]` inside it |
368
+ | `[data-grid-resize-track="<id>"]` | The visible **1px** mark inside the resize handle. It is **not a line that matches the group separator — it is that line, continued**: `w-px` + `translate-x-full` put it on the strip `[L, L+1]`, where `L` is the column boundary, which is exactly where `gridHeaderGroupShadow` (`inset 1px 0 0 0 var(--d-border)`) draws the rule between column groups. `bg-d-border` at rest, `d-primary` and 4px on hover. At rest the translation is `full` and not `1/2` because that is what puts the left edge exactly on the boundary at 1px. ⚠️ **On hover the translation is cancelled (`group-hover:translate-x-0`) so the 4px grow INWARDS, `[L-4, L]`:** with the translation still active they landed at `[L, L+4]`, past the boundary — and on a column whose boundary IS the table's right edge (the last one) those pixels fall outside the scrolling viewport, so the hover was never visible; the same happened at a right-pinned boundary, where the pinned cell paints its own background above it. Growing inwards is also the right reading: the handle belongs to the column being resized. Measured by `In hover la traccia cresce verso l'interno` with the browser's REAL pointer (`@vitest/browser/context`) — `storybook/test`'s `userEvent.hover` dispatches synthetic events that do not activate `:hover`, and the target's rect must be re-read after the hover because the driver scrolls it into view first (measured: 228px of stale offset). ⚠️ **Three geometries were rejected before this one**, and the measurement that settled it is that header cells carry NO vertical border at all (`borderRightWidth: 0px` on every one): flush at `right-0` the mark sat at `[L-1, L]`, inside the column where no other rule passes; centred with `translate-x-1/2` it sat half a pixel to the left of the groups' strip. Two specs guard the pair — `La maniglia si vede a riposo` (position and width) and `La traccia della maniglia continua la linea fra i gruppi` (the two rules coincide, DOM against DOM), both with a 0.1px tolerance because a 0.5px one swallowed exactly the half-pixel defect. ⚠️ **Three passes, and the last two are the decision:** at 1px this same subtle tone is invisible (**1.21:1** against the background, measured in the browser), and `bg-d-foreground/40` fixed the ratio (**2.82:1**) at the cost of leaving the family — a line darker than every other rule in the grid reads as content, not as a hint. The delivered form keeps the subtle tone (`--d-border`, the token behind the grid's other 28 border/divide usages) and buys perception with WIDTH. ⚠️ **The rest contrast is 1.21:1 and stays under the 3:1 of WCAG 1.4.11 — declared, not ignored:** at rest the mark carries no information missing elsewhere (the handle is already a named, focusable `separator` with `aria-valuenow`), and the hover state is accent at 4px. The spec therefore asserts the token identity and the 2px width, not a contrast floor this choice does not claim. `aria-hidden` and `pointer-events-none`: the separator is already named and must keep receiving the pointer. It exists because a fully transparent handle is an affordance that never announces itself |
369
+ | `[data-grid-col-filler]` | The layout-only extra `<col>` (P4). Never matched by `[data-grid-col]`. ⚠️ **From `adg-m3` it is much THINNER in the underflow regime, and still indispensable.** The slack is now distributed over the columns that fell back to `gridColumnWidth.default` and WRITTEN to their `<col>` (`distributeGridColumnSlack`, `grid-tokens.ts`), so this node absorbs only the `Math.floor` remainder — strictly fewer px than the number of elastic columns — plus whatever `gridColumnWidth.max` refused. It is NOT removable: without it `table-layout: fixed` inflates the declared widths whenever their sum is below the table width (measured: declared 160, rendered 225 on four columns in a 1000px box), and pinned offsets computed from the declared numbers desynchronise at the first paint. A grid that declares every width, or whose columns the user has dragged, keeps its FULL filler — those columns are never distributed to |
370
+ | `[data-grid-density-control]` | The density switcher `role="group"`; each button is `[data-grid-density="<v>"]`. ⚠️ **From `adg-c12` the grid never renders this** — it is `GridDensityControl`, a public export the CONSUMER mounts (in `toolbar.actions`, or outside the table). The attributes are unchanged, so a test that looked for it still finds it wherever it is now mounted; a test that asserted it was rendered by default must be inverted |
371
+ | `[data-grid-fullscreen-toggle]` | The full-screen toggle button (P6). Its accessible name changes with the state, and there is deliberately **no `aria-pressed`**: a toggle that both renames itself and announces «pressed» is heard twice and contradictorily. **From `adg-m3` the name is the VISIBLE text** — `innerText` is `i18n.toolbar.fullscreenEnter` / `fullscreenExit` — and the button carries **no `aria-label` and no `title`**: an `aria-label` REPLACES the accessible name, so keeping it beside visible words would reopen WCAG 2.5.3 «Label in Name», the same defect the P6 review fixed on the density buttons and Fase 3 on the search field. The icon is `aria-hidden`. Its dress is the toolbar triggers' shared `gridToolbarTriggerClass` (`h-6 px-2 text-xs`, ≥24px per WCAG 2.2 SC 2.5.8) and no longer a local `cn(...)`, so a probe can assert it by IDENTITY — a `toContain("h-6")` would stay green on a copy |
372
+ | `[data-grid-actions-row]` | **`adg-m3`.** The wrapper of the `actions` prop's commands — `role="toolbar"`, INSIDE `[data-grid-toolbar]`, in its `.ml-auto` right group. ⚠️ **The name says «row» and it is no longer a row**: it was one until `adg-m3`, when it moved into the bar, and it was deliberately NOT renamed — three story files resolve this selector and none of them asserts a POSITION, so renaming a layout change into a hook migration would have been the more expensive mistake. Absent when `actions` is empty or `hideActionsRow` is on |
373
+ | `[data-grid-action="&lt;label&gt;"]` | **`adg-m3`.** One command. ⚠️ **In the DOM only where it is REACHABLE:** with ONE action it is the inline button in the bar; with TWO OR MORE it lives inside `[data-grid-actions-panel]` and therefore exists only while the popover is OPEN. A test that counts these must click the trigger first — before `adg-m3` all of them were in the DOM at rest |
374
+ | `[data-grid-actions-trigger]` | **`adg-m3`.** The single collapsed trigger, rendered INSTEAD of the buttons from two actions up. Named `i18n.tableActions.actions`, dressed with `gridToolbarTriggerClass`, `iconRight="ChevronDown"`. It re-declares `data-slot="button"`, because with `asChild` Radix's `data-slot="popover-trigger"` would win otherwise — same reason as the columns trigger |
375
+ | `[data-grid-actions-panel]` | **`adg-m3`.** The popover holding the collapsed commands: `align="end"`, `modal={false}`, and portalled into the grid ROOT — same `container` + `rootRef` pattern as `[data-grid-toolbar-columns-panel]`, for the same measured reason (at full screen the root creates a stacking context and a body-portalled panel paints underneath) |
376
+ | `[data-grid-skeleton-row]` | A skeleton row (P8) — `aria-hidden`, no `aria-rowindex`, exactly the height of a data row. Their count is the LIVE page size **capped at `1_000`** (`Math.min`, see the `pageSize` row: the cap is what keeps `Array.from({length})` from raising `RangeError` or exhausting the heap), so outside `loading` it is 0 by construction and «zero skeletons» is a vacuous assertion there (limit declared since Fase 4) |
377
+ | `[data-grid-live-region]` | The `role="status" aria-live="polite"` region announcing that a page is loading / has loaded (P8). **From Fase 5 it announces all three outcomes and stays the ONLY one in the grid** — exactly one `[role="status"]` per grid, in the empty, error and loading states alike. The branch order is loading → error → empty, and the error branch comes FIRST of the two because a failed fetch also has zero rows. What it announces is always a STRING: `i18n.notFoundMessage` for the empty outcome (**never** `emptyComponent`) and `i18n.table.errorMessage` for the error one (never the surface, which contains a button a screen reader would read twice). **From M4 it can also be READ on screen**: with `statusRowVisible` it is wrapped in `[data-grid-live-region-frame]` and loses `sr-only`, and nothing else about it changes — same node, same attributes, same single text node, same place between the scroll area and the tail sentinel |
378
+ | `[data-grid-live-region-frame]` | **M4.** The frame that makes the status line readable, rendered ONLY with `statusRowVisible`. Holds the megaphone `<svg>` (`aria-hidden`) plus the live region, in that order. **It carries no `role` and no `aria-live` on purpose**: a role here would take the count of live regions per grid to two, and the icon inside the region would have the sentence announced in pieces. Absent by default, and then the region is again a direct child of the root with `sr-only` |
379
+ | `[data-grid-group-row="<path>"]` | A group row (P9) — carries `aria-expanded` and `data-grid-group-level` |
380
+ | `[data-grid-group-toggle="<path>"]` | Its expand/collapse control. The accessible name changes with the state |
381
+ | `[data-grid-group-cell="<id>"]` | A cell of a group row — one per visible leaf column, never one spanning many |
382
+ | `[data-grid-group-label]` | The grouping value. It lands in the **grouping column's** cell by default, or in the **first rendered column** with `groupLabelPlacement="first-column"` (M5) — the chevron and the count travel with it, always inside the same cell |
383
+ | `[data-grid-group-count]` | The number of data rows under the group, at any depth. ⚠️ **Read the ATTRIBUTE, not the text:** the attribute is the bare numeral (`78`), while the text reads `(78)` — the parentheses are literal in the node, wrapping the numeral only, as in the mockup. The word (`grouping.rowCount`: «rows» / «righe») follows inside an `sr-only` span, so the accessible name is the SUM of the visible number and the word. **No `aria-label` on this node**, deliberately: it would replace the visible number instead of extending it (WCAG 2.5.3 Label in Name) |
384
+ | `tr[data-grid-filter-row]` | **The ZERO ROW** — the FOURTH `<tr>` of `<thead>`, rendered only with `filterMode="row"`, and in that mode only while the toggle is OPEN. **Not rendered at all while the toggle is closed** — since R13 hiding the row unmounts it, so «the row is hidden» is measured as the ABSENCE of this node and there is no state attribute left to read. ⚠️ **Match it as `tr[data-grid-filter-row]`, never bare**: a badge row inside the inventory panel is a `<div>` carrying `data-grid-filter-row="<columnId>"`, and with both surfaces mounted a bare selector takes whichever comes first in the DOM |
385
+ | `[data-grid-filter-cell="<id>"]` | A cell of the zero row — one per leaf column plus `"__select"` when `checkable`, in the order of the columns, **never** with `colSpan`. Carries `data-grid-filter-cell-label` (the column's name) and `data-grid-filter-cell-applied="true"\|"false"`. Until `adg-r14` these were also the nodes the «N filters out of view» count was measured on; that indicator has been REMOVED from the toolbar and nothing reads their rects any more |
386
+ | `[data-grid-filter-cell-tick="<id>"]` | The zero row's **column mark**: a 1px-wide, `h-3` (12px) span pinned to the cell's LEFT edge and vertically CENTRED, `aria-hidden` and `pointer-events-none`. Present on every filter cell except the first and except a right-pinned block's edge cell. ⚠️ **It is a node and not a `box-shadow`:** an `inset` shadow covers the box's whole side, so the mark came out as tall as the cell and welded the zero row to the headers. Its height is asserted, not just its presence, because a full-height mark would satisfy a probe that only counts nodes |
387
+ | `[data-grid-filter-cell-trigger="<id>"]` | The one target of a filter cell: a real `<button>`, the cell's only tab stop, opening the Fase 7 filter atom in a popover. Carries `data-filter-truncated="true"\|"false"` — **always present**, so asserting its PRESENCE is green on a cell that truncates nothing — plus `title` and an `sr-only` node with the whole text **only when truncated**. **No glyph marks the cut at any width** — the ellipsis is the CSS one, drawn by `truncate` on the text span. Its `aria-label` names the operator IN FULL in both branches, even when the visible text takes the short form |
388
+ | `[data-grid-filter-cell-panel="<id>"]` | The popover of a filter cell. Radix **portals it to `document.body`**, so it is not inside the grid element. Inside: `[data-grid-filter-cell-operator="<id>"]` (the operator `<select>`, also carrying `data-grid-filter-cell-type`) and the atom's editor, named `filter-row-<id>` |
389
+ | `[data-grid-toolbar-filter-row-toggle]` | The «Show / Hide filter row» button, rendered in the toolbar with `filterMode="row"`. `aria-expanded` says whether the ZERO ROW is MOUNTED — since R13 closing it removes the `<tr>` entirely, and this button is the only way back, so it is rendered in both states |
390
+ | `[data-grid-toolbar]` | The toolbar row (Fase 3 **named** it; the row itself predates the phase and P6 renders it always, so it is present even at a minimal call site). `flex flex-wrap items-center gap-2` — `items-center` and no longer `items-end`, because from `adg-m3` the search cell is one line tall like the others. **From `adg-m3` it has TWO groups**: the direct children up to the filter-row indicator (what NARROWS the data), then one `.ml-auto` wrapper holding «columns», the `actions` prop's own buttons (`[data-grid-actions-row]`, from `adg-m3`), the `toolbar.actions` slot and the full-screen toggle, in that order (what changes the VIEW or takes the data out). ⚠️ **`ml-auto` only does work because the search lost `flex-1`**, and the measured four-way table is in the `play` of `AppDataGrid/Confronto`: with the field growing, `ml-auto` present or absent gives the identical layout. So a probe on the group's RIGHT edge alone is vacuous — the group ends flush right in three of the four combinations. Measure the GAP before the group instead: `gap-2` is 8px, and an auto margin that works makes it more |
391
+ | `[data-grid-toolbar-columns]` | The «columns» trigger — opt-in via `toolbar.columns`, and rendered ONLY when the visibility map can actually be written (see `onColumnVisibilityChange`) |
392
+ | `[data-grid-toolbar-columns-panel]` | The popover content holding `GridColumnRegistryPanel`, owned by the grid. Portaled **inside `[data-app-data-grid]`**, not to `<body>` — at full screen a body-level panel sits outside the sentinel cycle and outside `aria-modal`, so its checkboxes are neither tab-reachable nor announced |
393
+ | `[data-grid-toolbar-filters]` | The «filters» trigger — opt-in via `toolbar.filters`, and rendered ONLY when the slot carries `onOpen` or `render`. Also carries `data-grid-toolbar-filter-count`, its own attribute and deliberately **not** the chips row's `data-grid-filter-count`: two nodes sharing one attribute would make an exact `querySelector` ambiguous |
394
+ | `[data-grid-column-registry-item="<id>"]` | One row of the registry — the `visibility + position` pair. **New in Fase 3.** It is the row container, not the `<label>`: the reorder handles cannot live inside a `<label>` (a click on a label descendant toggles the control the label names, so moving a column would hide it). The panel carries one row per **leaf** column, `accessorKey` or not |
395
+ | `[data-grid-column-registry-label="<id>"]` | The `<span>` holding the row's column name. **There is NO `<label>` element**: the toggle is a `<button role="checkbox">` and a `<button>` is not a labelable element, so the name is wired with `aria-labelledby` pointing at this span. The handles sit OUTSIDE it, or clicking one would toggle visibility |
396
+ | `[data-grid-column-registry-toggle="<id>"]` | The visibility checkbox for the row (`role="checkbox"`, DS `Checkbox`, never a native `input`). Its accessible name comes from `aria-labelledby` -> the label span, so it deliberately carries no `aria-label` (an `aria-label` next to visible text is the WCAG 2.5.3 Label-in-Name defect). **`disabled` when the column is visible and it is the last visible one** (`puoNascondere` false) — re-checking a hidden column is never disabled. The prop defaults to `true`, so a standalone mount is unconstrained |
397
+ | `[data-grid-column-registry-visible="true|false"]` | On every row: whether that column is currently visible. Read this instead of inferring visibility from the checkbox node, which is re-created when the row re-renders |
398
+ | `[data-grid-column-registry-position="<n>"]` | On every row: the column's zero-based place in the DECLARED flat order. ⚠️ Sort by THIS, not by DOM order: DOM order is an effect of the rendering choice (today one flat list), while this is the order itself. An assertion that reads DOM order silently starts measuring visibility if the list is ever split or grouped |
399
+ | `[data-grid-column-registry-type="<GridColumnType>"]` | On every row: the column type that selects the row's icon, falling back to `other`. Populated from `GridColumnRegistryEntry.columnType`; a mount that omits it renders one identical icon for every row |
400
+ | `[data-grid-column-registry-block="left|right|scroll"]` | On every row: which pinned block the column belongs to (`pinned ?? "scroll"`). It is what makes the reorder constraint enforceable — a drag is refused when source and target blocks differ |
401
+ | `[data-grid-column-registry-dropzone="before|after|allowed|denied"]`| On a row DURING a drag only, and absent outside the gesture (a permanently present attribute would force the reader to tell «not dragging» from «not accepting», which are two different things). `denied` = the row is in a different block from the dragged one |
402
+ | `[data-grid-column-registry-section="visible"\|"hidden"]` | The section wrapper. TWO sections, labelled and independently scrollable, mirroring the frozen DataTable: `visible` and `hidden`. Hiding a column MIGRATES its row from one section to the other, so React unmounts and remounts it and the node changes identity — a spec that holds a reference across the gesture clicks a detached node. Query the node again after every gesture, and address rows by id rather than by DOM position |
403
+ | `[data-grid-column-registry-section-label="visible"\|"hidden"]` | That section's visible heading (`columnVisibility.visibleColumns` / `columnVisibility.hiddenColumns`) |
404
+ | `[data-grid-column-registry-list="visible"\|"hidden"]` | That section's own scroll container, one per section (`overflow-y:auto` plus a finite `max-height`: without the finite height `auto` never scrolls and an assertion on scrolling is green by absence) |
405
+ | `[data-grid-column-registry-bulk]` | The wrapper for the two bulk actions |
406
+ | `[data-grid-column-registry-hide-all]` | The «hide all» bulk action. Leaves visible ONLY the columns that cannot be hidden, so the last-visible guard still holds afterwards |
407
+ | `[data-grid-column-registry-show-all]` | The «show all» bulk action. Returns exactly to the all-visible state |
408
+ | `[data-grid-column-registry-handle="<id>"]` | The reorder handle `role="group"`. Hosts the arrow-key listener — a keydown bubbles up from either button, so one handler covers both |
409
+ | `[data-grid-column-registry-move-earlier="<id>"]` | Moves the column one position earlier. Native `<button>`: Enter/Space and click both work, and ArrowLeft/ArrowUp do too. Its glyph is a **ChevronUp** — a logical direction, because the i18n deliberately says «earlier»/«later» and a left-pointing chevron would point the wrong way in RTL |
410
+ | `[data-grid-column-registry-move-later="<id>"]` | Moves the column one position later. ArrowRight/ArrowDown as well. Glyph: **ChevronDown**, for the same reason. The horizontal keys stay physical, which is a declared limit: in RTL ArrowLeft moves «later» |
411
+ | `[data-grid-focus-sentinel]` | The two focus sentinels, first and last children **inside** the root, rendered only while fullscreen. `sr-only`, `tabIndex=0`. They are how the trap lives inside the component instead of writing `inert` on siblings it does not own |
412
+ | `[data-grid-empty]` | **Fase 5 — it moved, and it changed nature.** The `<td>` of the EMPTY outcome, inside the `<tbody>`, at the place of the rows: until Fase 4 it was a `<div>` sibling of the table and outside the scrolling viewport. Its `colSpan` equals the number of `<col>`, **ghost column INCLUDED** (`aria-colcount` + 1: that attribute subtracts the ghost by design), it is the ONLY cell of its row, and it carries **no `role`** — the announcement goes through `[data-grid-live-region]`, which stays ONE. `emptyComponent` renders as a descendant of this node |
413
+ | `[data-grid-status-cell]` | **Fase 5.** The same `<td>` as above, marked **unconditionally** with the outcome (`"empty"` / `"error"`): it is the anchor `scripts/check-grid-colspan-perimeter.mjs` admits to the R12 derogation, and the one to use in a test that does not know which of the two outcomes is on screen. Its `<tr>` carries `[data-grid-status-row]` with the same value, plus `role="presentation"` — see the a11y deviations below |
414
+ | `[data-grid-error]` | **Fase 5.** The same `<td>`, on the ERROR branch only (`error` is true and `loading` is false). Present ⇒ `[data-grid-empty]` is absent, and vice versa: the two outcomes never render together |
415
+ | `[data-grid-status="empty"]` / `[data-grid-status="error"]` | **Fase 5.** The visible CONTENT inside that cell (`parts/grid-status-surface.tsx`), one node per outcome. **Neither carries any `role`**: a live region born with its own text inside has no mutation to announce, so it would stay mute — and it would be a second one. The error node's text contains **no digit** (`/\d/` never matches): a figure above a body with no data describes something you cannot see |
416
+ | `[data-grid-status-message]` | **Fase 5.** The `<p>` of the error message alone, without the button's label |
417
+ | `[data-grid-retry]` | **Fase 5.** The «Retry» button. **ABSENT when `onRetry` is omitted.** One click invokes the callback exactly once |
418
+ | `[data-grid-footer]` | **Fase 5.** The `<tfoot>` of the totals, between `</tbody>` and `</table>`. `sticky` at `bottom: 0` — measured: Chromium constrains a sticky to the scroller's CONTENT box, so `0` lands its bottom edge exactly on the top of the horizontal scrollbar, which is the useful bottom. Separator is an inline `box-shadow` (`0 -1px 0 0 var(--d-border)`, the sticky-header token with the sign flipped), **never a border**: under `border-collapse` a border belongs to the TABLE's border grid and scrolls away with the body. `gridZClass.stickyHeader` (z-20), opaque `bg-d-background`. **ABSENT when no visible column declares `aggregate`, and absent above a body with no data** — empty, error or loading |
419
+ | `[data-grid-footer-row]` | **Fase 5.** The `<tr>` of the totals — exactly the height of a data row, from the same map the frame formula reads |
420
+ | `[data-grid-footer-cell="<columnId>"]` | **Fase 5.** One cell per visible column (`"__select"` for the checkbox one), in the ORDER of the body cells and never one for the ghost column. Pinned cells carry `data-grid-pinned="true"` and read the SAME `--grid-pin-*` variables as the body: left/right coincide with the body cell of the same column, right band included |
421
+ | `[data-grid-footer-label]` | **Fase 5.** The label cell carrying the unit (`i18n.table.totalsLabel`), on the FIRST visible column that declares no total. It also carries `data-grid-footer-cell`. **Declared limit:** if EVERY visible column declares a total the row has no label. It is the second node the colSpan gate admits, and today it uses no horizontal extension — the footer renders one `<td>` per column |
422
+ | `[data-grid-pagination]` | The paginator container (Fase 4 filled it; the node predates it). Rendered when **the body has data rows** AND (`paginated` is true OR (`pagination.pageSize` is set AND `groupBy` is absent)). **From Fase 5 the «body has data rows» half is not optional**: above an empty, errored or loading body «Rows 1-10 of 128» is a lie with the air of a detail, so the whole paginator — container, range, indicator and rows clause — is absent. On the client branch it holds ONLY `[data-grid-page-range]` / `[data-grid-row-count]` |
423
+ | `[data-grid-page-indicator]` | The «i / N» indicator, on the SAME two figures the bar navigates on (`currentPage` / `scope.pageCount`, falling back to the engine's count when no total is declared). It used to read `pageIndex + 1` and the ENGINE's count while everything else read the scope's — two derivations of one scope figure in one control, which on the client branch printed «1 / 1» next to «1 2 3 … 13» and «2 / 1» after a click. On the server branch the two coincide by construction (TanStack gets `rowCount: totalItems`), so the three spec files that read it by text (`"1 / 5"` verbatim, `split("/")`) are unchanged. **ABSENT when the paginator is not navigable** (client branch) |
424
+ | `[data-grid-page-range]` | The range — «Rows 1-10 of 128» (Fase 4, NEW node). ASCII hyphen, composed inside `gridResultScope`, never an en dash. Rendered only when the total is KNOWN and non-zero — declared (`resultTotal` / `serverConfig.totalItems`) or, from `adg-m2`, DERIVED on a grid that paginates itself |
425
+ | `[data-grid-row-count]` | The rows clause — «1 row» / «128 rows». Rendered only on ONE page: next to a range it would print the same figure twice. **From Fase 5 never above a body with no data** — the paginator that holds it is not rendered at all there, so the old «or when empty» case is gone |
426
+ | `[data-grid-page-size]` | The «rows per page» `<select>` (values `"10"`, `"25"`, `"50"`, `"100"`; option text `«25 per page»`). Its change dispatches `query/page-size` — one emission, no remount. **The LIVE size always has an option**, even out of scale: `pageSize` is `number`, not `GridPageSizeOption`, so `pageSize={20}` would otherwise leave a controlled `<select>` at `selectedIndex` -1 — an empty selector next to 20-row pages. Absent when not navigable |
427
+ | `[data-grid-page-first]` / `[data-grid-page-last]` | First / last page. Disabled at the ends, never hidden. Absent when not navigable |
428
+ | `[data-grid-page-prev]` / `[data-grid-page-next]` | Previous / next page. They navigate from the CLAMPED page, not from the raw `pageIndex`: with a stale high `pageIndex` (the consumer narrowed `totalItems` without dispatching) «previous» used to emit a `query/page` — a refetch — while nothing moved on screen. Absent when not navigable |
429
+ | `[data-grid-page-number="<n>"]` | One page button, `n` 1-based. Exactly one carries `aria-current="page"` (the STRING, not `true`); its accessible name contains the visible digit. Absent when not navigable |
430
+ | `[data-grid-page-ellipsis]` | The gap between two runs of page numbers — `…` `aria-hidden` plus an `sr-only` name. Absent when the pages are contiguous (e.g. 2 pages) |
431
+ | `[data-grid-sort-index]` | The circular multi-sort priority badge on a leaf header (Fase 4). Its value IS the 1-based position, which the sort button's accessible name also contains |
432
+ | `[data-grid-sort-glyph="ascending\|descending\|none"]` | The sort glyph of a leaf header. Its value IS the direction, so a probe can say which of the three shapes it holds. The two ACTIVE shapes carry `text-d-primary`; `none` (the double chevron) declares no colour and inherits the `<th>`'s — colouring it too would light up every sortable header, i.e. switch off the information the tint exists to give. Same measured contrast price as the filter glyph. ⚠️ **Its box is locked (`shrink-0`)**: in the header's flex row `size-4` declares a PREFERRED width, not a minimum, so a squeezed column used to compress the glyph — measured **5.86px** on a column at the 64px minimum. The label is the only node that gives way there (`truncate`); the glyph, the multi-sort dot and the filter funnel all hold their size |
433
+ | `[data-grid-filter-add-chip]` | The dashed «+ Filter» chip inside `GridFilterChips`, carrying `border-dashed`; its parent is the `role="listitem"` wrapper. Its accessible name is `filters.addFilter` («Add filter»), and its **short side is ≥ 44px** — it was 24 (`badge.sizes.lg` = `h-6`) while the pills next to it in the same row were 44. Outside the letter of the touch-target criterion, which speaks of an APPLIED filter's commands, but it is the entry point of the inventory flow. Same repair on `[data-grid-filter-clear-all]`. **It used to carry `[data-grid-filter-add]`, which is the PANEL's add button**: the selector then matched two nodes and an unscoped `querySelector` took the first in DOM order and seemed to work. Repaired — one attribute, one node |
434
+ | `[data-grid-filter-add]` | The panel's add button (`grid-filter-panel.tsx`), icon-only. Its accessible name NAMES the target column — «Add filter: Category», falling back to `filters.column` before one is chosen — so it no longer shares a name with the chip above |
435
+ | `[data-grid-filter-scope]` | The chip row's counting clause (`role="listitem"`), carrying `data-grid-filter-scope-total` (the raw figure) |
436
+ | `[data-grid-filter-scope-rows]` | «128 rows» / «1 row» — the scope, from the same `GridResultScope` the paginator reads |
437
+ | `[data-grid-filter-scope-filters]` | «1 filter» / «3 filters» — present only with at least one active filter |
438
+ | `[data-grid-filter-panel-toggle]` | **P7.** The disclosure of the lifted inventory (`GridFilterPanel`), carrying `aria-expanded` + `aria-controls`. ⚠️ **This — not `[data-grid-toolbar-filters]` — is the «filters» button every scene of the lane actually renders**, because the scenes pass `toolbar.filters.render` (the only graft that mounts the inventory INSIDE `[data-app-data-grid]`). The grid's own default trigger exists and counts, but it is rendered only when the slot carries `onOpen` and no `render`. A probe that wants «the filters button on screen» must ask for this one; asking for the other returns 0 nodes and passes for the wrong reason. **From `adg-m3` it carries the active-filter count** |
439
+ | `[data-grid-filter-panel-count]` | **`adg-m3` (scostamento 18).** The count badge on that toggle, whose attribute value is the number. Absent — the node does not exist — when the count is 0: a circle with «0» in it claims something is there while saying nothing is. The number comes from `gridActiveFilterCount`, the one place where the choice between the two possible populations is written (decision 27: what is counted is what is SEEN), never from `badges.length` at the call site. The dress is `gridToolbarCountBadgeClass`, shared with the grid's own trigger, and uses **only named Tailwind steps** — `h-4 min-w-4 px-1`, never a bracketed value: the library ships two CSS bundles built by two Tailwind majors and `scripts/check-grid-css.mjs` is the executable form of that ban. `min-w-4` keeps it round at one digit; `px-1` grows it into a pill at two, which is the «sized for its content» half of the criterion and is measured as a RELATION between the two widths, not as a class |
440
+ | `[data-grid-filter-panel-body]` | **P7 — this node is now the OVERLAY.** The panel body: `absolute`, `w-72` (288px), `z-50` from `gridZClass.overlay`, anchored to the panel root's **right** edge (`right-0`), painting **over** the table band. ⚠️ **Right and not left**, and the reason has a HISTORY: when the anchor was chosen the filters slot sat at the RIGHT end of the toolbar, so a left anchor grew the 288px outwards and off-screen — measured in full screen, `left 1107.23 → right 1395.23` on a 1200px window, 195px of the 288 outside the viewport and `document.elementFromPoint` on its centre returning `null`. **From `adg-m3` the slot is no longer at the right end**: «columns» moved into the toolbar's `.ml-auto` group and the filters slot stayed in the left one, third child. The right anchor still holds and is now the safer of the two — re-measured after the move, the body renders `left 287 → right 575` on a 900px window, fully inside it, with `elementFromPoint` on its centre returning the panel. ⚠️ **But the anchor is now load-bearing in the OPPOSITE direction**: pushing the slot further left (the mockup puts it second, right after the search — still an OPEN decision, see `adg-m3`) walks the 288px towards the left edge, and there `right-0` becomes the anchor that overflows. Whoever moves it re-measures this node. ⚠️ **Re-measured once more for the `actions` buttons, and the anchor could not have moved:** `right-0` resolves against the panel's OWN root `[data-grid-filter-panel]` (which carries `relative` for exactly this), and that root is emitted by the `toolbar.filters` branch, in the LEFT group. Content added to the RIGHT group changes where that group STARTS, never where the left group's children sit. The only residual coupling is the wrapping of a `flex-wrap` bar, which is what the two-or-more collapse keeps small. `null` when closed, never `display:none`. It used to be an in-flow block and opening it pushed the grid down; the spec measures the effect and not the class — **four** independent quantities on the same overlay: the rendered width, the geometric **intersection** with the viewport and with at least one leaf cell (the plane), the node hit by `elementFromPoint` at its centre (the stack), and the first data row's `top` plus the count of fully visible leaf cells, both changing by **exactly 0** across the open. The three «is it ON TOP» quantities arrived with the phase verification: without them a panel opening **upwards**, entirely outside the band, stayed green. ⚠️ It must be rendered INSIDE `[data-app-data-grid]` (mount it through `toolbar.filters.render`): outside the root, in full screen the `fixed inset-0` covers it and the two focus sentinels exclude it from the tab cycle — invisible AND unreachable, with no error |
441
+ | `[data-grid-filter-search]` | **P7.** The inventory's search field (the `<input>` inside the `Input` primitive, since `data-*` reach the input and not the wrapper). The focus **enters** here when the panel opens and **returns** to whoever had it when the panel closes — not to a trigger chosen a priori, because this state has two triggers (the panel toggle and the dashed «+ Filter» chip) |
442
+ | `[data-grid-filter-inventory]` | **P7.** The list of AVAILABLE filters — `role="group"`, `aria-label = filters.availableFilters`. ⚠️ Count its entries with the SCOPED selector `[data-grid-filter-inventory] [role="checkbox"]`: the column registry, when rendered inside the panel, uses NATIVE `<input type="checkbox">`, which a `getAllByRole("checkbox")` would include and a `querySelectorAll('[role="checkbox"]')` would not — the two probes give different numbers on the same DOM |
443
+ | `[data-grid-filter-inventory-item="&lt;id&gt;"]` | **P7.** One entry, `role="checkbox"` + `aria-checked`. Ticked = at least one applied filter names that column; the state is READ FROM THE BADGES, so the two lists cannot diverge. The tick is a SHAPE (an outlined box, a check mark) and never a colour |
444
+ | `[data-grid-filter-inventory-scroll]` | **P7.** The part that scrolls. The primary action lives OUTSIDE it — that is the whole of «the primary action does not scroll away» |
445
+ | `[data-grid-filter-inventory-empty]` | **P7.** `filters.noFilterableColumns` when no column is filterable at all, `notFoundMessage` when the search found nothing |
446
+ | `[data-grid-filter-build]` | **P7.** «Build a condition», the panel's primary action. `disabled` when `onBuildCondition` is absent rather than hidden: the criterion measures its POSITION, so the node has to exist always |
447
+ | `[data-grid-filter-row-type]` | **P7.** On the operator `<select>` of an applied row: the `filterType` that PRODUCED that list, declared in the DOM so a spec derives the expectation from the DOM instead of copying it by hand |
448
+ | `[data-grid-filter-editor="&lt;name&gt;"]` | **P7.** The root of the value ATOM — a snug `<span>` (no padding, no gap, one child), so its rectangle is the control's rectangle. Carries `data-grid-filter-editor-kind` (`text\|number\|boolean\|date\|datetime\|select\|multiSelect`, the editor DERIVED and read back from the DOM), `data-grid-filter-editor-type` (the `filterType`) and `data-grid-filter-editor-arity` (`single\|list`; with arity `none` — `eq_null`, `checked` — the atom renders NOTHING) |
449
+ | `[data-grid-filter-editor-control="&lt;name&gt;"]` | **P7.** The focusable control inside the atom (`<input>` / `<select>`). ⚠️ **Absent for `date` and `datetime`**: `DatePicker` accepts no `data-grid-*`, so there the control is the only `[role="button"]` inside the root and carries `data-testid="&lt;name&gt;"`. On a panel row the same node also carries the historical `[data-grid-filter-row-value="&lt;columnId&gt;"]`, which three already-green specs type into |
450
+ | `[data-grid-filter-editor-placeholder]` | **P7.** The empty `<option>` of a select editor. Enumerate a column's choices as `select.querySelectorAll("option:not([data-grid-filter-editor-placeholder])")` |
451
+ | `[data-grid-filter-editor-unknown]` | **AUDIT 2026-09-09.** The synthetic `<option>` a list editor renders when the badge's value is NOT among the options the column declares. Without it the `<select>` fell back to the placeholder while the filter was active, so an applied filter looked empty. Enumerating a column's DECLARED choices therefore excludes it too: `select.querySelectorAll("option:not([data-grid-filter-editor-placeholder]):not([data-grid-filter-editor-unknown])")` |
452
+ | `[data-grid-filter-editor-empty]` | **P7.** A list editor whose column declares ZERO options. It is an INCOMPLETE DECLARATION and is marked as such — never a reason to fall back to a free text field, which would put back the very defect the phase repairs |
453
+ | `[data-grid-filter-composer="&lt;columnId&gt;"]` | **P7.** The «from … to …» composer (a `PopoverContent`, portaled — look for it in the `document`, not in `canvasElement`). Its draft is LOCAL: `Escape`, «Cancel» and a click outside throw it away without touching the state or the emission counter; `[data-grid-filter-composer-apply="&lt;columnId&gt;"]` is the only gesture that promotes it. Also carries `[data-grid-filter-composer-title]`, `[data-grid-filter-composer-side="from\|to"]` (whose editors are named `&lt;columnId&gt;-from` / `-to`) and `[data-grid-filter-composer-cancel="&lt;columnId&gt;"]` |
454
+ | `[data-grid-filter-chip-trigger="&lt;columnId&gt;"]` | **P7 — the chip is now ONE target.** The pill's `<button>`; accessible name «Condition on &lt;column&gt;: &lt;operator&gt; &lt;value&gt;», short side ≥ 44px. It also carries `data-grid-filter-chip-operator="&lt;operator&gt;"`, the operator id declared in the DOM. ⚠️ Inside `[data-grid-filter-chip]` there is **exactly one** focusable node — count them, do not query the accessible name: with the old `✕` still there the name query stays green because the `✕` is called something else |
455
+ | `[data-grid-filter-chip-edit="&lt;columnId&gt;"]` | **P7.** The «Edit value…» menu item; opens the composer through `onEditValue`, `disabled` without it. ⚠️ **Portaled**: look for it in the `document` |
456
+ | `[data-grid-filter-chip-remove="&lt;columnId&gt;"]` | **P7 — same name, different node.** No longer the `✕` in the canvas: it is the «Remove» menu item, `data-variant="destructive"`, separated from «Edit value…» by a `[role="separator"]`. ⚠️ **Portaled** out of `canvasElement`. Both items are ≥ 44px on the short side |
457
+ | `[data-grid-header-filtered="&lt;columnId&gt;"]` | **P7.** The glyph on the header of a CONSTRAINED column — a funnel `role="img"` whose accessible name is `filters.columnFiltered`. **Shape plus name FIRST, colour added** (WCAG 1.4.1): the discriminator is still the funnel outline no other header carries, plus the accessible name; the accent tint (`text-d-primary`) is added to those, not put in their place, so a reader who does not distinguish the hue still reads the shape. A probe must key on `[data-grid-header-filtered]`, never on the colour — the token is consumer-overridable. ⚠️ **Measured price:** in the light theme `--d-primary` resolves `#64c2d1` on `#fafafa` = 1.86:1, under the 3:1 WCAG 1.4.11 asks of a graphical object, and paler than the `#7a7a7a` (3.94:1) the glyph had by inheritance. It holds only because colour carries no state here; a theme wanting 3:1 on the glyph moves `--d-primary` one step darker (measured: `#3e8b97` = 3.54:1). It is not a button: the filter is removed and edited from its own pill. It is driven by the SAME list the filter count reads, so «filtered» in the header and the chip that names it cannot diverge |
458
+ | `[data-grid-selection-bar]` | **Fase 6.** The page-selection bar — `role="group"` + `aria-labelledby` pointing at the visible count, **never `role="status"`** (the mockup's `CompleteSelectionBar` carries `role="status" aria-live="polite"`; the component does NOT, because the invariant is ONE live region per grid — `UnaSolaRegioneLive` counts them). **In flow ABOVE the scroll area since 2026-09-03**, between the filter band and the table band; its wire is therefore `gridStickyHeaderShadow` (downward, `0 1px`) and no longer the footer's upward one. ⚠️ **The node does not exist while nothing is selected** — not "exists with height 0": the frame is a `flex flex-col gap-2`, so an always-rendered wrapper would still cost 8px of gap, i.e. one data row for ever. Its height comes from `gridRowHeightClass[density]`, the same map the `selectionBar` addend of `gridFrameHeight` reads. **See: "Declared limit — in fullscreen the band is paid by the viewport"** below — the no-lost-row promise holds for a bounded frame, and in fullscreen it costs one row by decision |
459
+ | `[data-grid-selection-count]` | **Fase 6.** The visible count — «2 rows selected». Carries the `id` the bar's `aria-labelledby` points at, and the number as its attribute value. The figure is `engine.selectedPageRowIds.length`, i.e. the ticked ids the **current row model** still knows — never `Object.keys(rowSelection).length`, which survives paging and filtering. ⚠️ **From M4 it holds the count ALONE** — «2 rows selected» — because the scope moved into the sibling `[data-grid-selection-scope]`; it is also where the bar's typography starts, `text-sm font-medium` (14px / 500) as in the mockup |
460
+ | `[data-grid-selection-scope]` | **M4.** The scope of the selection — «of 8 on this page» — a DIRECT child of the bar and a sibling of the count, never inside the totals band nor inside `[data-grid-selection-summary]`: in there it would disappear with them, while «out of how many rows» is a property of the selection and not of the totals. `text-xs` (12px), the mockup's second step. **The separator lives INSIDE this node's string** and never as a bare text node between siblings, so it disappears together with what it introduces when no column aggregates and no `selection.summary` renders |
461
+ | `[data-grid-selection-aggregate]` | **Fase 6.** The bar's totals band. **This is the node to compare with `[data-grid-footer]`**, not the whole bar: the bar also holds the count and the buttons. Its cells come from `engine.selectionCells` = `aggregateCells(selectedPageRowIds)`, the same `cellsForRows` → `applyGridAggregate` that feeds the `<tfoot>`, so with the whole page ticked the two `textContent` are equal by construction |
462
+ | `[data-grid-selection-cell="<columnId>"]` | **Fase 6.** One cell of the totals band, in the footer's own column order |
463
+ | `[data-grid-selection-label]` | **Fase 6.** The label cell of the totals band — the first column **without** an aggregate, same rule as `[data-grid-footer-label]` |
464
+ | `[data-grid-selection-summary]` | **Fase 6.** Rendered **instead of** the totals band when `selection.summary` is passed |
465
+ | `[data-grid-selection-actions]` | **Fase 6.** The container of the bulk actions |
466
+ | `[data-grid-selection-action="<label>"]` | **Fase 6.** One bulk-action button. Visible text is `label + count`; the **accessible name extends it** with the unit read next to the number («Delete 5 rows selected», WCAG 2.5.3), composed at render so it changes on every tick |
467
+ | `[data-grid-selection-destructive="true"]` | **Fase 6.** Present only on the destructive action (`variant="destructive"` of the shared `Button`) |
468
+ | `[data-grid-selection-dismiss]` | **Fase 6.** The way out. Value `"dismiss"` with a partial selection («Cancel») and `"deselect-all"` when the whole page is ticked («Deselect all») |
469
+ | `[data-grid-select-all]` | **Fase 6 gave it a value.** The header checkbox — `"all"` \| `"indeterminate"` \| `"none"` (it used to be the empty string). It also carries `aria-checked="mixed"` when and only when the selection is partial. ⚠️ **Do not assert `data-state`**: the shared `Checkbox` passes `false` to Radix for `"indeterminate"`, so `data-state` says `unchecked` on a visibly mixed box — the ARIA attribute is overridden from the call-site instead of touching the shared primitive |
470
+ | `[data-grid-group-select="<path>"]` | **Fase 6.** The checkbox of a **group row**. It is Radix's `<button role="checkbox">`, so query it directly (not `[…] button`). Works on a **collapsed** group: the leaf ids come from `GridGroupRowView.leafIds`, collected on the tree above the flattener's `if (!expanded) continue`, never from the DOM |
471
+ | `[data-grid-group-select-cell="<path>"]` | **Fase 6.** The `<td>` of the `__select` column on a group row — previously an empty placeholder |
472
+ | `[data-grid-group-select-state]` | **Fase 6.** `"all"` \| `"indeterminate"` \| `"none"` on the group checkbox, the analogue of `data-grid-select-all` |
473
+ | `[data-grid-toolbar-custom-filters]` | **Fase 8.** The DASHED GROUP of the filters without a column, inside `[data-grid-toolbar]`. `role="group"`, named by `i18n.filters.customFilters`, and it carries `data-grid-toolbar-custom-filter-count`. ⚠️ **With zero custom filters the node does not exist** (it renders `null`, not an empty dashed rectangle), so a `querySelectorAll(...).length` on a scene without custom filters is 0 and passes for the wrong reason — demand the node first. ⚠️ It is NOT `[data-grid-filter-add-chip]`, the other dashed node of the lane: tell them apart by attribute and by container, never by the `border-dashed` class, and measure the dashing with `getComputedStyle` (`borderStyle === "dashed"` AND `borderWidth !== "0px"`) — the class alone is green on a border of zero pixels |
474
+ | `[data-grid-toolbar-custom-filters-caption]` | **Fase 8.** The VISIBLE caption of that group: `` `${count} ${i18n.filters.withoutColumn}` `` → «2 senza colonna». It is the on-screen half of criterion 7 — the status-line tail says the same sentence but lives inside an `sr-only` node, so a probe reading its `textContent` stays green on something nobody can see. Measure this one geometrically (`getBoundingClientRect().width`): `sr-only` would take it to 1px |
475
+ | `[data-grid-toolbar-custom-filter="<badge id>"]` | **Fase 8.** One chip of that group, anchored to the badge `id` and **not** to `customKey`: two custom filters of the same type share the registry key, and two nodes with the same attribute make a `querySelector` take the first one |
476
+ | `[data-grid-toolbar-custom-filter-label]` | **Fase 8.** The full name of a filter without a column. It contains `customLabel` and NOTHING else — the return value of `describe`, never a recomposition. Not truncated: «in full» is literal, and the group wraps |
477
+ | `[data-grid-toolbar-custom-filter-remove="<badge id>"]` | **Fase 8.** Its «×»: a real `<button type="button">` with **no explicit `role=`** (an explicit role replaces `button` in the computed role, and `getAllByRole("button", …)` would return 0). Accessible name = `` `${i18n.filters.removeFilterNamed} ${customLabel}` `` → «Togli il filtro …» in Italian, «Remove filter …» by default. Calls `onRemoveFilter(badge.id)` |
478
+ | `[data-grid-toolbar-custom-filter-unknown]` | **Fase 8.** Present on a chip whose `customKey` is not a key of `filterControls` — a badge that did not come from `createGridCustomFilterBadge`. Absent on every chip when `filterControls` is not passed: without a registry there is nothing to recognise, and marking them all would be a false positive |
479
+ | `[data-grid-filter-row-kind="custom"]` | **Fase 8.** Marks an APPLIED PANEL ROW belonging to a filter without a column. Such a row shows the name (`[data-grid-filter-row-column]`, again `customLabel`) and its «×», and renders **no operator select and no value editor** — a badge with `columnType: "other"` and no `filterType` has no vocabulary, so those controls would be inert. This is why «12 filters, 10 filled cells» is a fact of the component and not of the story |
480
+
481
+ ### The toolbar order, and the one decision still open (`adg-m3`)
482
+
483
+ The row of controls above the table is a **fixed order in the JSX** — no prop reorders it, and none is
484
+ planned: nobody asked for one, and it would be configurability without a caller.
485
+
486
+ What it renders, in DOM order:
487
+
488
+ | # | Node | Group |
489
+ | --- | --------------------------------------------------------------------------------------------------------------- | ------------------ |
490
+ | 1 | the two bypass links (`absolute`, zero layout cost) | left |
491
+ | 2 | `[data-grid-search]` — label beside the field, magnifier inside, no longer growing | left |
492
+ | 3 | ~~`[data-grid-density-control]`~~ — **removed from the toolbar by `adg-c12`**; the grid renders no density control | — |
493
+ | 4 | the filters slot — `[data-grid-filter-panel-toggle]` with `render`, `[data-grid-toolbar-filters]` with `onOpen` | left |
494
+ | 5 | `[data-grid-toolbar-custom-filters]` — the dashed group, only with lifted filters AND `onRemoveFilter` | left |
495
+ | 6 | `[data-grid-toolbar-filter-row-toggle]` — only in `filterMode="row"` | left |
496
+ | 7 | `[data-grid-toolbar-columns]` | right (`.ml-auto`) |
497
+ | 8 | `[data-grid-actions-row]` — the `actions` prop's buttons: ONE inline, `[data-grid-actions-trigger]` from two up | right |
498
+ | 9 | the `toolbar.actions` slot — the consumer's own content, no default node | right |
499
+ | 10 | `[data-grid-fullscreen-toggle]` | right |
500
+
501
+ **«Esporta» before «Schermo intero», and the target is the MOCKUP.** The code used to emit `fullscreen`
502
+ before `actions`. `PIANO-COMPLETAMENTO.md` row 27 writes «Esporta → Schermo intero» for the mockup, and the
503
+ mockup lane of `AppDataGrid/Confronto` **confirms it on the DOM**: `search → filtri → densità → colonne →
504
+ esporta → fullscreen`. The two sources agree; the code was the only one of the three that diverged, so there
505
+ was no inversion to arbitrate. ⚠️ **`adg-m3` also inserted the `actions` prop's own buttons between «columns» and the consumer slot** — they used to be a row of their own ABOVE this bar. The mockup's three-node order is preserved («Colonne» → «Esporta» → «Schermo intero»): the compat buttons sit INSIDE it, not above it, and collapsed into one trigger from two actions up so the group grows by one h-6 node and not by three.
506
+
507
+ **What is STILL OPEN: where the filters slot goes.** The mockup puts it **second**, immediately after the
508
+ search; here it is fourth (third among the visible nodes). It is not a free reorder, and the cost is written
509
+ into two places:
510
+
511
+ - the inventory's overlay body is anchored `right-0` (see `[data-grid-filter-panel-body]`), so walking the
512
+ slot further left walks 288px towards the left edge of the window — the anchor that protects it today
513
+ becomes the anchor that overflows;
514
+ - the two nodes the mockup does not have at all — the filter-row toggle and the off-screen-filters
515
+ indicator — sit after the slot, so moving it changes what they read as belonging to.
516
+
517
+ The decision belongs to whoever ran the mockup comparison, not to an agent. The `play` of
518
+ `AppDataGrid/Confronto` asserts the current order as a **literal**, precisely so that the day the slot moves
519
+ that line turns red and whoever moved it updates it, instead of leaving the comparison green on an order
520
+ nobody looked at again.
521
+
522
+ ### Column widths, resize and pinning (P4)
523
+
524
+ Widths, resizing and left-pinning need no prop on the grid: they are declared **per column** (`width`,
525
+ `pinned: "left"`), which is the same field the frozen `DataTable` exposes, and every leaf column carries its
526
+ own handle. Resizing is a **view** action, so it can never reach `onQueryChange` — that is R13 (“a column
527
+ resize must not cause a refetch”) as a property of the action type rather than a rule someone has to
528
+ remember.
529
+
530
+ **A committed width is now observable**, through `onColumnWidthsChange`: the callback fires **once per
531
+ commit** — on `pointerup` for a drag (never during it, never on `pointercancel`) and **once per keypress**
532
+ on a focused separator — and hands over the whole, partial widths map. It is still a view action, so
533
+ `data-emissions` does not move. Two things to know before you persist it: a **zero-delta** click on the
534
+ handle commits and therefore notifies, and the number handed over is the **rendered** width, i.e.
535
+ post-slack-distribution and so viewport-dependent. Both are declared on the prop.
536
+
537
+ | Attribute | Where | Value |
538
+ | ------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
539
+ | `style.width` | `[data-grid-col="<id>"]` | Always present, in px. During a drag it is written **directly to the DOM** |
540
+ | `aria-valuenow` | `[data-grid-resize-handle="<id>"]` | The column's current width; `aria-valuemin` 64, `aria-valuemax` 1200 |
541
+ | `data-render-count` | `[data-app-data-grid]` | React commits of the **root** since mount. Rises by **exactly 1** over a drag |
542
+ | `data-grid-table-commits` | `[data-grid-table]` | React commits of the **table subtree**. Also exactly 1 over a drag. ⚠️ **From `adg-m3` it STARTS at 2, not 1** — the horizontal measurement commits once in a `useLayoutEffect`; the drag DELTA is unchanged, because the viewport's width does not move during a drag |
543
+ | `left` | `[data-grid-pinned="true"]` | Left band only: `var(--grid-pin-N)`, N being the position among the columns pinned left |
544
+ | `right` | `[data-grid-pinned="true"]` | Right band only: `var(--grid-pin-right-N)`, N being the position among the columns pinned right, **in column order** |
545
+ | `--grid-pin-N` | `[data-grid-table]` | The left offsets, computed from the **declared** widths — no measuring |
546
+ | `--grid-pin-right-N` | `[data-grid-table]` | The right offsets, same arithmetic accumulated from the **right** edge: the last pinned column is `0` |
547
+
548
+ Measured invariants (see `docs/analysis/datatable-v2/VERDETTO-P4.md`):
549
+
550
+ - A pointer drag commits **once**, at release. Thirty `pointermove` events produce zero commits: the width
551
+ goes straight onto the `<col>` and the pinned offsets onto one CSS variable on the `<table>`.
552
+ - Keyboard resizing is 8px per `ArrowLeft`/`ArrowRight`, one commit per press, `aria-valuenow` following.
553
+ - Pinned offsets are correct at the **first commit**, before any paint. They are arithmetic over declared
554
+ widths, not a measurement — which is the difference from `DataTable`, whose offsets arrive from a
555
+ `useState` filled after the first paint.
556
+ - Horizontal overflow is born, not configured: `[data-grid-viewport]` scrolls on both axes, and the
557
+ bar appears as soon as the declared widths exceed it. The page itself never gains a horizontal scrollbar.
558
+
559
+ ⚠️ **The two scrollbar lanes are reserved as padding on `[data-grid-viewport]`, and this changes two numbers
560
+ a test may read.** Both bars are always visible (`type="always"`) and both are `position: absolute` — at
561
+ `bottom: 0` and `right: 0` — so they paint _over_ the content unconditionally, with no overflow test. The
562
+ viewport therefore carries `padding-bottom` and `padding-right` of one lane each (10px, `gridScrollbarLaneSize`
563
+ in `grid-tokens.ts`). Consequences:
564
+
565
+ - `clientHeight` / `clientWidth` are **unchanged** — padding is inside them — so `viewportHeight` remains the
566
+ contract: `[data-grid-viewport].clientHeight === viewportHeight`.
567
+ - ⚠️ **And `adg-m3` adds a READER of that same fact on the horizontal axis.** The column-width distribution
568
+ needs the width available to the `<table>`, which is `[data-grid-viewport].clientWidth` **minus one lane**:
569
+ the lane is padding, so it is inside `clientWidth` but outside the content box the table's `w-full` resolves
570
+ against. It subtracts `gridScrollbarLaneSize` — the same token that writes the padding in
571
+ `grid-scroll-area.tsx` — so the two numbers cannot drift.
572
+ - `scrollWidth` / `scrollHeight` are **one lane larger** than the content, because a scroll container's padding
573
+ is part of its scrollable overflow region. A test asserting an absolute `scrollWidth` must add
574
+ `gridScrollbarLaneSize`; relational assertions (`scrollWidth > clientWidth`, "constant across frames") are
575
+ unaffected.
576
+ - The last row and the right-pinned band therefore end **at the bar's edge**, not under it. Measure clearance
577
+ from the bar (`[data-slot="grid-scroll-area-scrollbar"][data-orientation="…"]`), never from the viewport's
578
+ border box: the border box is what the bar covers, so `viewport.right - cell.right === 0` is the _overlap_
579
+ condition, not its negation.
580
+
581
+ ⚠️ **`colgroup` holds one `<col>` more than there are columns.** It has no width and no cells, and it is
582
+ required: measured in Chromium, `table-layout: fixed` with `width: 100%` **inflates** declared widths when
583
+ their sum is below the table width (four 200px columns in a 1000px box render at 250px each), and on inflated
584
+ widths the pinned offsets cannot be arithmetic. The extra `<col>` absorbs the slack, so declared widths stay
585
+ exact in both regimes. It is excluded from `[data-grid-col]` on purpose: a test counting columns must count
586
+ `[data-grid-col]`, and assert `colgroup col === [data-grid-col] + 1` separately. ⚠️ **From `adg-m3` the
587
+ filler no longer absorbs ALL of the slack in the underflow regime** — see the paragraph below: most of it is
588
+ distributed to the columns and written to their `<col>`, and this node keeps the remainder. Nothing about the
589
+ sentence above changes, because the distributed number is a DECLARED width like any other.
590
+
591
+ ⚠️ **Columns fill the container by default (`adg-m3`), and the escape hatch is the per-column `width`.**
592
+ Measured defect at 1920×1080 on the consumer's `/user-management/permission/`: a **458px** empty strip on the
593
+ right of the table, swallowed by the phantom `<col>`, because every column had fallen back to
594
+ `gridColumnWidth.default` (160) and nothing added up to the viewport. The grid now measures the viewport
595
+ (one `ResizeObserver` per grid, in `parts/grid-table.tsx`) and, when the sum of the resolved widths is BELOW
596
+ the width available to the `<table>`, divides the leftover across the columns that took the default —
597
+ `distributeGridColumnSlack` in `grid-tokens.ts`, a pure function. **Four rules bound it, and each one is
598
+ traceable to something on the code path rather than to taste:**
599
+
600
+ 1. **Never in the overflow regime.** If the sum EXCEEDS the container the slack is negative and nothing is
601
+ touched: that is where the horizontal scrollbar is born («horizontal overflow is born, not configured»,
602
+ in the invariants above), and narrowing columns to make it disappear would remove exactly that bar.
603
+ 2. **Never over a column with a declared `width`.** That is a statement, not a default — whoever writes
604
+ `width: 90` on a column of flags does not want 155 — and it is also what keeps the lane's pixel
605
+ assertions valid, since its stories declare their widths.
606
+ 3. **Never over a width the user has DRAGGED** (`view/column-width`): re-flowing it would undo the gesture on
607
+ the next render, i.e. a handle that does not hold.
608
+ 4. **The distributed number is WRITTEN**, never left to fixed-layout inflation, so declared === rendered and
609
+ the pinned offsets stay exact arithmetic at the first paint. Distribution is by `Math.floor`, so fewer px
610
+ than the number of elastic columns go to nobody and the filler takes them; a `Math.ceil` would overshoot
611
+ the sum by a pixel and conjure a scrollbar out of nothing. `clampGridColumnWidth` still applies, so no
612
+ column passes `gridColumnWidth.max`, and what the ceiling refuses returns to the filler.
613
+
614
+ The consequence to know: **`width` is now also the opt-OUT of filling**, which is coherent but was never
615
+ designed as one — a grid whose EVERY column declares a width keeps its dead strip, and that is the one case
616
+ the default does not reach. Knowing side effect of the same rule: dragging a column narrower frees slack that
617
+ the remaining elastic columns absorb at `pointerup`, in the one commit the drag already had.
618
+
619
+ ⚠️ **Provenance of the two numbers, because this document does not launder an inference into a measurement:**
620
+ the 458px strip was measured on the live consumer BEFORE the fix. That the strip is GONE is so far a
621
+ consequence read off the code path, not an observation — it needs a `dist/` rebuild and a copy into the
622
+ consumer to become one. The observation to make there: on `/user-management/permission/` at 1920px every
623
+ `[data-grid-col]` should carry an inline width above 160, `[data-grid-col-filler]` should measure a few px,
624
+ and `viewport.scrollWidth === viewport.clientWidth`.
625
+
626
+ ⚠️ **Pinning honours declaration order, not position.** Offsets accumulate over the pinned columns in the
627
+ order they sit among the columns, so pinning the third column and not the first two glues it to the left edge
628
+ and lets the first two scroll **under** it. This is the frozen table's semantics too; column reordering is
629
+ P10, and until then pinning a non-prefix is a configuration with no sensible appearance. The right band is the
630
+ mirror image: pin a **suffix**, or the columns to its right scroll under it.
631
+
632
+ ⚠️ **The right band is only visible under horizontal overflow, and that is not a limitation.** Without overflow
633
+ the slack goes to the elastic columns and its remainder to the extra `<col>` (`adg-m3`; before it, all of it
634
+ went to the `<col>`), so in either case the declared widths stay exact and the pinned block already sits flush
635
+ against the right edge — which is where a right-pinned column belongs, and where nothing scrolls under it. The
636
+ filler `<col>` stays **last** in the `colgroup` in both regimes: a `<col>` maps to cells by position, so moving
637
+ it earlier would make it swallow a real column's slot. Under overflow it collapses to zero, and there a right
638
+ offset of `0` coincides exactly with the right edge of the column's own `<col>`.
639
+
640
+ ⚠️ **Measuring the right band needs a scroll too, and the vacuous point is the OPPOSITE one.** For the left band
641
+ a cell at `scrollLeft: 0` sits at its natural position whatever its offset is, so every left-pinning assertion
642
+ has to be taken after scrolling. On the right axis the free point is `scrollLeft` **maximum**: there the
643
+ viewport's right edge coincides with the table's, so a right-pinned column sits naturally and any offset gives
644
+ the same number. Measured on 14 columns summing 1888px in a 1422px viewport: at `scrollLeft: 0` the last pinned
645
+ cell is pulled back by exactly 466px (the maximum scroll) relative to its own `<col>`; at maximum scroll the two
646
+ coincide. Assert both, and know which is which.
647
+
648
+ ⚠️ **A group pins to the right at the offset of its LAST leaf**, mirroring the left rule (the first leaf's). With
649
+ the first leaf's offset a two-column group would step back too far and cover columns it does not head. And a
650
+ group whose leaves are pinned to **different** sides is rendered free: it has no single edge to anchor to, while
651
+ each of its leaves stays pinned on its own side.
652
+
653
+ ⚠️ **`aria-valuenow` during a drag is written imperatively.** It does not come from React state until the
654
+ release, for the same reason the width does not. A test that reads it mid-gesture is reading the DOM, which is
655
+ the point; one that expects a React re-render will find none.
656
+
657
+ ⚠️⚠️ **THE `⋮` COLUMN MENU HAS BEEN REMOVED, AND WITH IT WCAG 2.5.7 FOR COLUMN RESIZE — declared debt, not an
658
+ oversight.** Until this change every column carried a `⋮` menu (P4 added it to non-sortable columns too), and
659
+ its two items `data-grid-resize-wider` / `-narrower` were the **single-pointer alternative without dragging**
660
+ that WCAG 2.2 SC 2.5.7 (Dragging Movements, AA) requires — keyboard arrows do not satisfy that criterion,
661
+ because the criterion is about pointers. The menu was removed on request; resize is now reachable by **dragging**
662
+ the handle (pointer) and by **arrow keys** from the focused handle (keyboard), so **SC 2.5.7 is no longer met for
663
+ column resize**. What would repay it: a single-pointer gesture on the handle itself (a click or double-click
664
+ that steps, or fits the column to its content), or a resize command in a consumer-mounted surface — the column
665
+ registry panel is the natural home, and it already hosts the non-dragging pointer path for column REORDERING.
666
+ The absence is measured by `NienteMenuDiColonna` in `app-data-grid-resize-pinning.stories.tsx`, which also
667
+ declares the debt so it cannot be lost with the feature.
668
+
669
+ ⚠️ **The handle's pointer target is 8px wide, below the 24×24 of WCAG 2.2 SC 2.5.8.** Full 24px was not
670
+ reachable while a 24px circle centred on the handle intersected the column menu trigger in the same header;
671
+ that trigger is now gone, so the constraint that kept the target at 8px is gone with it — widening it is
672
+ possible and has not been done, because nothing in this change asked for it. Until then the target stays 8px
673
+ and, with the menu items gone, SC 2.5.8 is **no longer met from the other side either**. Declared, not silently
674
+ accepted.
675
+
676
+ ⚠️ **The `<th>` carries an explicit `aria-label`.** Without it the handle, rendered _inside_ the header,
677
+ becomes part of the header's accessible name, because a `columnheader` is named from its content: measured in
678
+ Chrome, the name was `"Sort: Città Sorting: Città 200"` — the column's **width in pixels** was part of its
679
+ name, and changed on every resize. A test asserting the header's accessible name should expect the plain column
680
+ label.
681
+
682
+ ⚠️ **The focused handle lifts to `z-40` and fills with `d-foreground`.** Both are measured requirements, not
683
+ styling. Without the lift, a focused handle scrolled behind the pinned block is entirely obscured (WCAG 2.4.11)
684
+ — measured: at `scrollLeft: 200` the topmost element at the handle's own centre was the pinned column's handle.
685
+ Without the fill, the only indicator was a `d-ring/50` outline on a transparent element, measured at
686
+ **1.10:1** against the background versus the 3:1 of WCAG 1.4.11. `d-primary` measured **translucent** in the
687
+ default theme (1.89:1) and is used for hover only; `d-foreground` measures **12.46:1**.
688
+
689
+ ⚠️ **Pinned cells follow the row's hover, not its `rowClassName`.** A pinned cell's background cannot be
690
+ transparent — columns would show through during horizontal scroll — so it re-states the row's background
691
+ through a `group-hover` utility on the row. A `bg-*` arriving through `rowClassName` is **not** replicated, so
692
+ a row coloured that way shows its colour only in the non-pinned columns. **Selection is the exception**: since
693
+ R19 the selected row's tint is an inline background on **every** cell, so it does cross the pinning boundary —
694
+ see *Selected row: tint and left bar (R19)*.
695
+
696
+ ⚠️ **Measuring "the pinned column is correct at first paint" needs the computed `left`, not the screen
697
+ position.** At `scrollLeft: 0` a `position: sticky` cell sits at its natural position **whatever** its `left`
698
+ is — the value only takes effect once scrolled. A probe reading `getBoundingClientRect().left` at rest passes
699
+ even with no pinning at all; it was measured passing against the post-paint defect this criterion exists to
700
+ forbid. Read `getComputedStyle(cell).left`.
701
+
702
+ ### Multi-level headers (P5)
703
+
704
+ Declared in the column tree, not through a prop. `Σ th.colSpan === leafCount` holds on **every** header row by
705
+ construction: each node contributes a `colSpan` equal to the number of leaves under it, and a leaf that starts
706
+ above the last row leaves a **placeholder** (`colSpan` 1, empty) in each row it skips.
707
+
708
+ | Attribute | Where | Value |
709
+ | --------- | -------------------------- | ---------------------------------------------------------------------------- |
710
+ | `id` | every header `<th>` | `<useId prefix>-h-<columnId>`; unique per grid instance |
711
+ | `headers` | every body `<td>` | The ids of the headers covering it, outermost first — derived, never written |
712
+ | `scope` | leaf `<th>` / group `<th>` | `"col"` / `"colgroup"` |
713
+ | `colSpan` | group `<th>` | The number of leaves the group covers |
714
+
715
+ Measured invariants (see `docs/analysis/datatable-v2/VERDETTO-P5.md`):
716
+
717
+ - **Zero `rowSpan`, anywhere.** A `rowSpan` in a header desynchronises the `headers` derivation for every row
718
+ below it, which is the most direct way to break the chain this phase exists to build. Leaves and the select
719
+ column live in the **last** header row, with placeholders above.
720
+ - **Leaf `<th>`s of the last row = `colgroup col` = `<td>`s of the first data row**, in count, with and without
721
+ `checkable`. All three come from the same list.
722
+ - **The header rows stack, and stay stacked.** Measured in Chrome on 10.000 rows with virtualization and pinning
723
+ active: the offsets of the three rows are `0|32|64` — **one single value across 31 scroll frames**. Stickiness
724
+ is on the `<thead>` as a block, not per row: three rows each declaring `top: 0` would overlap, and a test
725
+ asserting the CSS `top` property would pass while showing exactly that.
726
+ - **A group is pinned only if ALL its leaves are, to the SAME side.** Half a group pinned would glue it to the
727
+ edge and drag along columns nobody asked to freeze, covering the data scrolling underneath. Its offset is the
728
+ leaf nearest the edge it anchors to: the first leaf on the left, the last on the right.
729
+
730
+ ⚠️ **Pin whole groups, or leaves no group covers.** A group is pinned only when **all** its leaves are, so
731
+ pinning one leaf inside a group leaves that group's header free to scroll — and once you scroll past the group's
732
+ width, the header of a _different_ group slides above the frozen column. Rendering that correctly would mean
733
+ splitting a group's header cell into a pinned part and a free part, which no criterion of this phase asks for.
734
+ Measured and declared, not silently accepted.
735
+
736
+ ⚠️ **An invalid column tree throws, on purpose.** Four configurations pass the type-check and would render a
737
+ silently wrong table, so the grid rejects them with a message instead: a group with `columns: []` (it would emit
738
+ `colSpan={0}`, which in HTML means "to the end of the column group"); two nodes sharing an `id` (two `<th>` with
739
+ the same DOM id, so `getElementById` resolves to the first and a column's cells point at another column's
740
+ header); the reserved id `__select`; and an `id` containing whitespace (`headers` is a space-separated list, so
741
+ one id becomes two broken references). The leaf registry already threw on duplicate **leaf** ids; groups never
742
+ enter the registry, which is why these needed their own guard.
743
+
744
+ ⚠️ **Two grids on one page do not share header ids.** The id prefix comes from `useId()`, per instance. With a
745
+ module-level prefix `document.getElementById` would still resolve — to the **first** match — so the second
746
+ grid's `headers` would point at the first grid's headers: no error, no orphan id, and a screen reader announcing
747
+ the other table's columns.
748
+
749
+ ⚠️ **`headers` is set on every body cell, including in a grid with no groups**, where a single-id chain says what
750
+ `scope="col"` already implies. It is the explicit form of the same fact, and unconditional means one code path
751
+ instead of two.
752
+
753
+ ### Virtualization (P3)
754
+
755
+ Opt-in via `virtualized`. When it is on:
756
+
757
+ | Attribute | Where | Value |
758
+ | --------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
759
+ | `aria-rowcount` | `[data-grid-table]` | The total **traversable body row** count, not the number of rendered rows — the **known scope** (`resultTotal` / `serverConfig.totalItems`, or derived from the grid's own pagination — `adg-m2`) when there is one (Fase 4), else `10000` with no grouping, data **plus group rows** with `groupBy` (P9), and `-1` while an infinite source still has more (P8) |
760
+ | `aria-rowindex` | `[data-grid-row]` and `[data-grid-group-row]` | The row's **absolute** 1-based position in the traversable sequence, not its position in the DOM — and with `groupBy` that sequence includes the group rows |
761
+ | `style.height` | `[data-grid-spacer]` | The pixels the window's top/bottom must absorb |
762
+
763
+ Measured invariants a test can rely on (all verified in Chrome, see `docs/analysis/datatable-v2/VERDETTO-P3.md`):
764
+
765
+ - `Σ [data-grid-row] heights + both spacer heights === rowCount × rowHeight`, **exactly** — the difference is
766
+ `0`, not "within a row". Row height is `32 / 36 / 48` by density.
767
+ - `[data-grid-viewport].scrollHeight` is **constant** while scrolling. This, not the scrollbar thumb, is the
768
+ measure to assert: at 10.000 rows the real thumb sits at the browser's **minimum** size, so its constancy
769
+ is partly the clamp rather than the property under test.
770
+ - Fewer than 60 `tr` live in the `<tbody>`; the count observed is 22 at rest and at most 31 mid-scroll.
771
+ - No `ResizeObserver` measures the **rows**: `estimateSize` is the exact row height, so nothing measures
772
+ what it also sizes. Reintroducing dynamic measurement of rows is what would bring the
773
+ `ResizeObserver loop completed with undelivered notifications` error back.
774
+ ⚠️ **RESTRICTED TO THE ROWS on 2026-09-09, because the wider claim was false and this list is the one a
775
+ test is told it can rely on.** The previous wording said «there is no `ResizeObserver` anywhere in the
776
+ grid». **FOUR exist from `adg-m3`** — three before it — and they are not virtualization:
777
+ `parts/grid-cell-text.tsx` (one per non-empty text cell, to decide whether to emit the tab stop),
778
+ `parts/grid-filter-row.tsx`, `engine/use-grid-infinite-loader.ts`, and — new — **ONE PER GRID in
779
+ `parts/grid-table.tsx`, which observes `[data-grid-viewport]`** to feed the column-width distribution.
780
+ Measured, not deduced: **15 constructions for 6 text cells** on a 3×2 grid of text columns — i.e. at least
781
+ one per rendered cell, and no loop, because the two shapes of `GridCellText` share the MEASURED classes and
782
+ the cycle closes on the first pass. ⚠️ **That 15 is the pre-`adg-m3` figure**: the fourth observer adds one
783
+ per grid MOUNTED, so the same scene now constructs 16 — arithmetic on the new `useLayoutEffect`, not a
784
+ re-measurement. The spec is unaffected either way: it asserts `delta >= cells`, a relation, not the total.
785
+ The measurement lives in `app-data-grid-fix-minori.stories.tsx :: GliOsservatoriDelCorpoSiContano`. A spec
786
+ that asserted «zero observers» was asserting something the component never did.
787
+ ⚠️ **The fourth one is the only one that observes a node OUTSIDE the table subtree**, and it reaches it
788
+ with `closest("[data-grid-viewport]")` from the `<table>`, because `GridScrollArea` forwards its ref to the
789
+ VIEWPORT by contract — its `forwardRef` lands on `ScrollAreaPrimitive.Viewport`, the node the scrolling
790
+ specs write `scrollTop` on — and its ROOT has no ref at all, so reaching the root would mean changing that
791
+ file. The viewport is also the right node on the merits: its CONTENT box is the box the table's `w-full`
792
+ resolves against. **It cannot
793
+ loop:** what the distribution resizes is the `<table>`, which is INSIDE the observed node, and the
794
+ viewport's width depends on the grid root rather than on its content — Radix's two bars are `absolute`, so
795
+ `clientWidth` does not move when the overflow appears or disappears. Its declared cost is one extra commit
796
+ of the TABLE subtree at mount (`data-grid-table-commits` starts at **2**, not 1) and **none on the root**:
797
+ the state is local to `GridTable` precisely because `data-render-count` is asserted exactly `"1"` at the
798
+ first commit.
799
+
800
+ ⚠️ `aria-rowcount` counts the **traversable body rows** — never the header rows, deliberately deviating from
801
+ ARIA's letter (which counts them too). The reason is that the total must stay the number the user can actually
802
+ walk with the arrow keys, and must not change when P5 adds header levels. A test asserting `10001` is asserting
803
+ the spec, not this component.
804
+
805
+ With `groupBy` on (P9) the traversable sequence **includes the group rows**, so the total is data rows plus the
806
+ group rows currently on screen, and closing a group lowers it — the rows inside a collapsed group are not
807
+ traversable, so they are not counted. `aria-rowindex` follows the same sequence and is carried by
808
+ `[data-grid-group-row]` as well as `[data-grid-row]`. A test asserting `aria-rowcount === <number of data rows>`
809
+ on a grouped grid is asserting something this component deliberately does not promise.
810
+
811
+ ⚠️ **Three kinds of row live outside the `aria-rowindex` space, and only one of them is exposed.** Header rows
812
+ are outside the count by the deviation above; the **skeleton** rows carry `aria-hidden="true"`, so they are
813
+ outside the a11y tree entirely; the **totals row** of the `<tfoot>` is inside the tree with **no
814
+ `aria-rowindex`** — deliberately, because it is not a data row (any index would place it in the data space it
815
+ does not belong to) and `aria-hidden` would hide the total from exactly the people who need to hear it. The
816
+ **status row** (`[data-grid-status-row]`) is _not_ in this list: it carries `role="presentation"`, so the table
817
+ exposes **zero** body rows in the empty/error outcomes — matching the `aria-rowcount` of `0` it declares there.
818
+ Per ARIA's presentational-role conflict resolution the role propagates to the row's owned cells, so the `<td>`
819
+ itself carries **no `role`** — which is what the empty-state contract above requires. Its content, message and
820
+ «Retry» button included, stays in the tree; what stops existing is the ROW.
821
+
822
+ ⚠️ **Scrolling never emits.** `data-emissions` is unchanged by any amount of scrolling — virtualization is a
823
+ view concern and cannot reach `onQueryChange`. A filter typed while scrolled deep resets the window and the
824
+ scroll position and emits exactly once.
825
+
826
+ ⚠️ **A zero-height viewport does not mean "render everything".** If the scroll container measures 0px —
827
+ `viewportHeight={0}`, a still-collapsed parent, a closed panel — the virtualizer cannot compute a range. The
828
+ grid renders a **minimal window** (a couple of dozen rows) rather than the full dataset: measured before the
829
+ fix, that path emitted 10.000 `tr` and 50.180 DOM nodes, i.e. the exact cost `virtualized` exists to avoid,
830
+ paid _because_ it was requested.
831
+
832
+ ⚠️ **Opening a big-dataset story: use the manager, not just the iframe.** `iframe.html?id=…` has no addon
833
+ panel, so it cannot show a whole class of defect. Measured on a 10.000-row story: the manager page held
834
+ **341.828** DOM nodes (340.442 of them `<span>` from the dynamic source snippet) while the preview iframe
835
+ held **291** and the grid was virtualizing correctly. Any story shipping a large dataset should set
836
+ `parameters.docs.source.type = "code"`, or the "Show code" snippet serializes the whole dataset into
837
+ syntax-highlighted spans.
838
+
839
+ ⚠️ **Virtualization stays ON while `loading` — this was inverted in P8.** It used to be off, for the
840
+ reason written at the time: skeleton rows are not data rows, so spacers sized on `data.length` put 10
841
+ skeletons above a 359,208px void. That was a remedy, not the cause. From P8 the virtualizer **counts
842
+ what is rendered** — data rows plus skeleton rows — so the spacers are exact in both states and the
843
+ void is unrepresentable rather than avoided. See «Infinite source and skeleton rows (P8)».
844
+
845
+ ⚠️ **Changing density re-measures.** `estimateSize` is not a memo dependency inside react-virtual, so the grid explicitly calls `virtualizer.measure()` when the row height changes. Without it, switching density left the declared height frozen at the mount-time density — measured at 119,736px of error, with the content jittering 12px at every row boundary. A test that changes density must re-assert the height identity, and one that changes it only at `scrollTop` 0 hides half the error.
846
+
847
+ ⚠️ A pinned cell is only _visibly_ pinned when the columns overflow horizontally. Without overflow it still
848
+ carries `data-grid-pinned="true"`, which is what makes it measurable — but a test that wants to prove pinning
849
+ must force the overflow (declare widths whose sum exceeds the container) or it proves nothing.
850
+
851
+ ⚠️ The first `<tr>` in the `<tbody>` is the **top spacer**, so `[data-grid-row]:first-of-type` matches nothing. Use `querySelector("[data-grid-row]")` for the first data row.
852
+
853
+ ⚠️ Sort **priority** is not readable from the headers — they render in column-declaration order regardless of it. Read `computedSorting.sort_by` from the emitted payload instead; a test asserting priority against `aria-sort` order passes even when priority is wrong.
854
+
855
+ ### Density is the consumer's setting, not a toolbar control (`adg-c12`) — BREAKING
856
+
857
+ The grid **no longer renders a density switcher**, and `toolbar.density` **no longer exists** as a key.
858
+ Row height is set by the `density` prop, default `"default"` (standard, 36px) — unchanged. This retracts
859
+ the P6 rule for that one key; `search` and `fullscreen` are still rendered always, and decision 26 still
860
+ holds for them.
861
+
862
+ **Why.** Density is view configuration, and whoever mounts the table is who knows whether its rows should
863
+ be tight or roomy. The retired argument was «a table whose density changes only if the call-site
864
+ remembered a boolean is a table whose density, in practice, never changes» — a rule about the end user's
865
+ experience, imposed on every call-site of the library.
866
+
867
+ **How to migrate.** If you want to offer the switch to the end user, mount the control yourself — it is
868
+ now a public export, so you get the real `role="group"`, the i18n labels and the `sr-only` full words
869
+ instead of three hand-rolled buttons:
870
+
871
+ ```tsx
872
+ import { AppDataGrid, GridDensityControl, type GridDensity } from "@laif/ds";
873
+
874
+ const [density, setDensity] = useState<GridDensity>("default");
875
+
876
+ <AppDataGrid
877
+ columns={columns}
878
+ data={data}
879
+ density={density}
880
+ // in the toolbar, where it used to be…
881
+ toolbar={{ actions: { render: () => (
882
+ <GridDensityControl density={density} onChange={setDensity} />
883
+ ) } }}
884
+ />;
885
+ // …or anywhere outside the table: `AppDataGrid/Completa` puts it above the grid.
886
+ ```
887
+
888
+ ⚠️ **If you pass a fixed `viewportHeight`, derive it from the LIVE density.**
889
+ `gridViewportHeight` multiplies `gridRowHeight[density]` by the row count, so a constant `"default"`
890
+ beside rows rendered at another height promises ten rows and fits eight — the `<tfoot>` then covers rows
891
+ the paginator announces. While the control lived inside the grid this could not be got wrong.
892
+
893
+ ⚠️ **`GridDensityControl` needs no grid in the tree.** It is a standalone control, like
894
+ `GridFilterChips` and `GridFilterPanel`.
895
+
896
+ ### Density, full screen and vertical space (P6)
897
+
898
+ Density is a real control, not the provisional switch P2 left behind: `showDensityControl` **is gone**, and
899
+ the three-button group (`S` / `M` / `L`) plus the full-screen toggle are always rendered in the toolbar. The
900
+ row height still comes from `gridRowHeight` in `grid-tokens.ts` — `32 / 36 / 48`, exact and not estimated.
901
+
902
+ **Full screen is a CSS promotion of the same DOM node.** The root gains `fixed inset-0` and the
903
+ `designTokens.tableZ.fullscreen` z-index; nothing is unmounted, nothing is portaled, and
904
+ `requestFullscreen` is **not** called. Three consequences, each with a spec:
905
+
906
+ - **It cannot remount** — `data-mount-count` is unchanged across two enter/leave cycles. Remounting would
907
+ recreate the engine, so it would refetch and lose scroll position, selection and sort.
908
+ - **It cannot refetch** — `view/fullscreen` is a view action with no path to the emitter, so
909
+ `data-emissions` is unchanged. The property is unrepresentable, not merely untested.
910
+ - **Scroll position survives**, because the scrolling element is literally the same node. Measured on
911
+ `aria-rowindex` of the first rendered row, not on `scrollTop`, which changes legitimately when the
912
+ viewport grows.
913
+
914
+ ⚠️ **Why not `requestFullscreen`.** A natively fullscreen element enters the browser's **top layer**, which
915
+ paints above any `z-index` — so "the grid covers the old table's overlays" would pass with any number in the
916
+ scale, and with no scale at all. That spec would be **vacuous**. Two lesser reasons point the same way: the
917
+ API requires a real user gesture and rejects on the synthetic clicks a spec dispatches, and `Esc` exits
918
+ without going through React, so the reducer and the browser would disagree with nothing to detect it.
919
+
920
+ ⚠️ **The z-index is 110, and it was measured, not chosen.** The grid must cover overlays the old `DataTable`
921
+ portals to the body, and those carry the z-index of the shared primitive they are built from — not
922
+ `tableZ`. Measured across `src/components`: the ceiling is `z-[100]` (the frozen table's own horizontal
923
+ `ScrollBar`), and `popover.tsx` is at `z-[60]`. **60 was the first choice and it was wrong**: it ties with
924
+ `popover.tsx`, and a tie is broken by document order — a Radix popover is appended to the end of `<body>`,
925
+ so the popover would have won. The spec catches exactly this.
926
+
927
+ ⚠️ **The promotion loses to an ancestor stacking context, and no number fixes it.** A `position: fixed`
928
+ element only competes with a body-level portal when no ancestor creates a stacking context. Probed in Chrome
929
+ with `document.elementFromPoint`: with a plain ancestor the fullscreen grid wins; give that ancestor
930
+ `transform: translateZ(0)` or `filter: blur(0px)` and the **portal** wins, because the grid's z-index is then
931
+ scoped inside the ancestor's context. If a host app wraps the grid in a transformed or filtered container,
932
+ full screen will render below that app's own overlays. This is a containing-block fact, not a bug to tune.
933
+
934
+ ⚠️ **The sticky header's separation is a `box-shadow`, not a border.** Under `border-collapse: collapse` a
935
+ border — on `<th>` or on `<tr>` alike — is collapsed into the **table's** border grid, so it belongs to the
936
+ table's flow and not to the sticky `<thead>` box: scrolling leaves the header in place and takes the line
937
+ away with the body. Measured in Chrome at `scrollTop: 200`, two tables side by side: with the shadow on the
938
+ `<thead>` the line is painted across the full table width; with `border-bottom` on the `<tr>` it is **not
939
+ painted at all**. This closes the observation P3 left open. The **last** header row therefore carries no
940
+ border: keeping both would give a 2px line at scroll 0 and a 1px line once scrolled.
941
+
942
+ ⚠️ **And since 2026-09-09 the same holds for the lines BETWEEN header rows — a collapsed border there did
943
+ not merely vanish, it punched a hole.** The intermediate rows used to carry `border-b` on the `<tr>`. That
944
+ border is collapsed into the table's border grid, so it scrolls away with the body, and the 1px strip it
945
+ occupied is covered by **nothing**: not the row above, not the row below, and not the `<thead>`'s own
946
+ background. Scrolling therefore showed **one pixel of the body** passing between the header rows, across the
947
+ full width. Measured in Chrome, `devicePixelRatio` 2, `scrollTop: 137.5`, body painted `#ff0000`, pixels read
948
+ off the compositor's PNG: 670 red samples out of 888 at each internal boundary and zero inside the rows;
949
+ after the fix, zero everywhere in the header block. A `<thead>` background is not the remedy either — with
950
+ `thead { background: #00ff00 !important }` the green never appears at the boundary, which stays red.
951
+
952
+ The line between two header levels is therefore `box-shadow: inset 0 -1px 0 0 var(--d-border)`, and it lives
953
+ in **two** places on purpose: on the `<tr>`, which is the only element spanning the full table width
954
+ including the phantom column, and — composed through `gridPinnedOffsetStyle` — on the **opaque** cells of
955
+ those rows, because a pinned cell's `bg-d-background` paints over the row's shadow (measured: with the row
956
+ shadow alone the line ran from x 73 instead of x 25). It also closes a question this repo had left open in
957
+ writing: a `box-shadow` on a `<tr>` under `border-collapse` **is** painted.
958
+
959
+ ⚠️ **The pinned block's edge and the header's group line are ONE `box-shadow`, and both are `inset`.**
960
+ The paragraph above settled the VERTICAL axis. R20 measured the HORIZONTAL one, which had never been
961
+ measured in this repo.
962
+
963
+ **Instrument.** Presence *and* absence are read as **pixels off the compositor's PNG**, not eyeballed and
964
+ not inferred: a screenshot is saved to disk, the scale is derived from the file width over
965
+ `window.innerWidth` (2400/1200 = 2.0, `devicePixelRatio` 2), and a **horizontal scan of x ∈ [250, 500]** at
966
+ the row's mid-height reports the exact interval where a saturated probe colour appears — or reports that it
967
+ appears nowhere. `elementFromPoint` is *not* the instrument here: it would prove stacking, not painting.
968
+ Scene: the `Per il gesto a mano` story, wrapper narrowed to 520px, `scrollLeft = 120`, boundary cell's right
969
+ edge at **x = 473**. One factor per row; each probe forced past the shipped inline style with `!important`,
970
+ which the first pass had missed.
971
+
972
+ | Factor (on the last left-pinned cell) | collapsed borders (the grid's regime) | separated borders (control) |
973
+ | --- | --- | --- |
974
+ | vertical `border-right: 6px` | painted at **x=[350..355]** — i.e. `473 − 120`, the table's grid position | painted at **x=[467..472]** — back on the cell |
975
+ | **non-`inset`** `box-shadow` | **painted nowhere in the scan** | painted at **x=[473..478]** |
976
+ | **`inset`** `box-shadow` | painted at **x=[467..472]** | painted at **x=[467..472]** |
977
+ | `::after`, absolute, `right: 0` | painted at **x=[467..472]** | painted at **x=[467..472]** |
978
+ | `::after`, absolute, `right: -6px` | **painted nowhere** — clipped by the cell's `overflow: hidden` | **painted nowhere** — same clip |
979
+ | `::after`, `right: -6px`, `overflow: visible` | painted at **x=[473..478]** — **casts OUTWARD** | painted at **x=[473..478]** |
980
+
981
+ Exactly one factor changes between the two columns, and only two rows move: the border returns to the cell,
982
+ and the non-`inset` shadow appears. So this is not "sticky breaks painting" — it is the **collapsed-border
983
+ regime** deciding, and that is the regime this grid lives in. It is also why the mockup's classes are **not
984
+ portable**: the mockup renders its table with separated borders.
985
+
986
+ **What the measurement supports, stated no wider than that.** Among the **`box-shadow` declarations on the
987
+ cell**, the `inset` one is the only one painted in this regime — that is what is measured, and it is why the
988
+ shipped form is `inset`. It is **not** true that `inset box-shadow` is the only mechanism available: an
989
+ absolutely-positioned **pseudo-element is a second mechanism**, it is regime-independent (identical in both
990
+ columns above), it follows the sticky cell, and with `overflow: visible` it **casts outward over the
991
+ scrolling content** — which is what the mockup does. Where that goes next is **deviation row 38**, below.
992
+
993
+ ⚠️ **Declared deviation from the mockup — this is deviation row 38, and it is OPEN.** The shipped edge
994
+ **fades inward**, inside the boundary cell; the mockup's shadow **throws outward**, over the content that
995
+ scrolls under it. The task's human check reads "the locked block casts a shadow", so today it is satisfied
996
+ in a **weakened sense**. The pseudo-element route reproduces the mockup, and its price is named:
997
+ `overflow: hidden` on the boundary cell has to be lifted, and that is exactly what the text-clipping
998
+ utility sets — so the column's ellipsis would have to move to an inner element.
999
+
1000
+ **The decision is recorded, not pending in the dark: Lorenzo, 2026-09-04 — the shipped form stays `inset`,
1001
+ and the pseudo-element becomes a task of its own.** The follow-up lives in **row 38 of the deviations table
1002
+ in `docs/analysis/datatable-v2/PIANO-COMPLETAMENTO.md`**, which carries the six-row measurement, both
1003
+ evidence PNGs, the pseudo-element's price and the one thing still unmeasured (how an outward-throwing
1004
+ pseudo-element behaves against the stacking of the neighbouring pinned cells and the `<thead>`). Read that
1005
+ row before changing anything here: the difference is known and tracked, not forgotten.
1006
+
1007
+ The three tokens live at the tail of `grid-tokens.ts`:
1008
+
1009
+ | Token | Value | Where |
1010
+ | --- | --- | --- |
1011
+ | `gridPinnedLeftEdgeShadow` | a **list of two layers**: `inset -1px 0 0 0 var(--d-border)` (the crisp hair) and `inset -6px 0 6px -6px var(--d-border)` (the blur) | inner RIGHT edge of the **last** left-pinned cell |
1012
+ | `gridPinnedRightEdgeShadow` | the left list **mapped** through one mirroring expression, so every layer is flipped — never recopied | inner LEFT edge of the **first** right-pinned cell |
1013
+ | `gridHeaderGroupShadow` | `inset 1px 0 0 0 var(--d-border)` | inner LEFT edge of every group-label header cell |
1014
+
1015
+ ⚠️ **The pinned edge is TWO layers, and each one has a job. The blurred one gives the depth; the crisp one
1016
+ gives the separation.** Measured, not chosen by eye: with the blurred layer alone the edge stood off its
1017
+ background by **10 levels out of 255** (245 → 235), while the mockup — measured with the same instrument —
1018
+ stands off by **59** (250 → 191). The cause is *not* the colour: both use the same `--d-border`,
1019
+ `rgb(224,224,224)`. It is that a single layer with blur `6` and spread `-6` never reaches the colour's full
1020
+ strength anywhere, whereas the mockup lays a **full 1px border** down and puts the gradient on top of it.
1021
+ The crisp layer is that border, and it brings the separation to **21** (245 → 224) — measured on the mounted
1022
+ grid, not predicted. **21 is not 59**, and closing that gap needs a colour darker than `--d-border`, which
1023
+ the theme has no token for: the only darker `--d-*` are *text* colours. That is why deviation row 38 stays
1024
+ **open**, and it is Lorenzo's decision of 2026-09-04 to take the 10 → 21 step and stop there.
1025
+
1026
+ ⚠️ **The crisp right-hand layer collides with the group line, by construction.** Mirrored, the crisp layer
1027
+ is `inset 1px 0 0 0 var(--d-border)` — the *same string* as `gridHeaderGroupShadow`. On a cell that is both
1028
+ a group label and the right block's edge the two would paint the same pixel twice, so
1029
+ `gridPinnedOffsetStyle` **de-duplicates identical layers**. The two layers are kept as separate list
1030
+ entries rather than one comma-joined string precisely so that de-duplication never has to re-open a string:
1031
+ a `split(", ")` would hold only while every token uses `var(--d-border)`, which has no commas, and would
1032
+ silently cut a future colour function that does have them in half. The consequence for anyone reading computed
1033
+ styles: on a right-boundary cell the geometry no longer says *which* signal drew that line, so a spec
1034
+ counting where the group line lives must exclude right-boundary cells (recognisable by the still-unique
1035
+ blurred right layer) and say so, rather than counting them wrong.
1036
+
1037
+ The group line is a **hairline** and the pinned edge is a hairline **plus** a blur, and the difference is
1038
+ load-bearing: a pinned block *casts a shadow*, a group separator *draws a line*, and on the one cell that is
1039
+ both — a group label that is also the block's LEFT edge — the three layers stay distinguishable. The
1040
+ composition happens in **`gridPinnedOffsetStyle` and nowhere else**: they contend for a single
1041
+ `box-shadow` declaration, so assigned at two call-sites the second would erase the first silently, with a
1042
+ green `npm run check`.
1043
+
1044
+ The boundary predicate itself — "am I the last on the left, the first on the right?" — lives once, in
1045
+ `gridPinnedSlots`, and travels inside `GridPinnedSlot.edge`. **`gridPinnedOffsetStyle` has seven
1046
+ call-sites**, and all seven paint the edge: **two in the header** (one shared by the placeholder and the
1047
+ leaf, one for the group cell that also asks for the group line), the **data row**, the **group row**, the
1048
+ **filter row**, the **skeleton row** and the **`<tfoot>`**. The filter row and the skeleton row are the two
1049
+ the first pass shipped without a probe; both are deliberate and now asserted in both directions — the
1050
+ filter row sits in the same sticky band as the header and the skeleton row occupies a data row's place, so
1051
+ an edge that stopped at either would read as a broken edge rather than a body-only one. A group header cell takes its
1052
+ **anchor** from the leaf nearest its edge and its **boundary** from the leaf at the other end
1053
+ (`gridPinnedGroupSlot`), because a left-pinned group is anchored by its first leaf and borders the scrolling
1054
+ content with its last.
1055
+
1056
+ ⚠️ **Declared limit — "the checkbox column alone pinned" is not representable, and could not be painted
1057
+ either.** Two independent halves, and both matter:
1058
+
1059
+ - **No input reaches that state.** The select column's pinning is derived, not independent:
1060
+ `pinning = pinnedLeftIds.length > 0` in `parts/grid-table.tsx`. Hide the last pinned data column and the
1061
+ checkbox unpins with it rather than staying at the edge alone. Measured live in `IlConfineMigra`: after
1062
+ hiding both pinned columns, zero cells report `data-grid-pinned` and the select cell's computed `position`
1063
+ is `static`.
1064
+ - **No render path could paint it.** The select cell is rendered with an inline `left: 0` and **never calls
1065
+ `gridPinnedOffsetStyle`** (`grid-row.tsx`, `grid-group-row.tsx`, `grid-filter-row.tsx`, and the skeleton's
1066
+ select cell in `grid-table.tsx`), and in the header `pinnedSlotOf` returns `undefined` on `isSelectCell`.
1067
+ So even if such a state existed, the checkbox has no code path that reads the edge token.
1068
+
1069
+ Together these close the question instead of leaving it open: it is not "not yet wired", it is "not wired,
1070
+ by two separate facts".
1071
+
1072
+ ⚠️ **The group line is on the LEFT inner edge, not the right.** On the right it would collide with
1073
+ `gridPinnedLeftEdgeShadow`, which is exactly the layer it has to compose with on the left block's boundary
1074
+ cell — two layers on the same side of the same cell are an overlap, not a composition.
1075
+
1076
+
1077
+ ⚠️ **The density buttons carry no `aria-label`, on purpose.** Visible text is `S` / `M` / `L`; the descriptive
1078
+ part is appended in an `sr-only` span, so the accessible name is `S Compact rows` — it **contains** the visible
1079
+ text. An `aria-label` would replace it instead, which is a **WCAG 2.5.3 Label in Name (A)** failure: measured
1080
+ in Chrome's a11y tree, the name read `Compact rows` while the button showed `S`, so a voice-control user
1081
+ saying "click S" could not activate it. A spec asserts the containment relation (not the full string, which
1082
+ would be language-dependent) and asserts the absence of `aria-label`, since an `aria-label` would win over
1083
+ the content and make the measurement meaningless.
1084
+
1085
+ ⚠️ **`data-mount-count` needs a stable `id`.** The count lives in `grid-mount-count.ts`, outside the
1086
+ component's lifecycle, because a `useRef` cannot survive the remount it is supposed to detect — it would
1087
+ report `1` both for a correct in-place promotion and for the remount the criterion exists to exclude. The
1088
+ key is the call-site's `id` (then `data-testid`); **with neither, the counter is per-instance and always
1089
+ `1`**, i.e. vacuous. Pass an `id` when you intend to measure it.
1090
+
1091
+ Under `StrictMode` the value stays **`1`**, not `2`: React's simulated double-mount reuses the same component
1092
+ instance, so the caller's `mountCount.current` is no longer `null` and its guard collapses the second
1093
+ registration. A **real** remount — new instance, new ref — counts `2`, which is the difference the criterion
1094
+ has to see. (An earlier version of this note claimed the opposite; corrected after a review read the caller.)
1095
+
1096
+ ⚠️ **Full screen declares itself modal, and `Escape` exits.** While full screen the root carries
1097
+ `role="dialog"`, `aria-modal="true"` and an `aria-label`, and a keydown of `Escape` on the grid leaves full
1098
+ screen (through `applyView`, so it still cannot emit). Without this, tabbing moved focus onto content the
1099
+ grid covers completely — visible to no one, still activatable — which is a **WCAG 2.4.11 Focus Not Obscured
1100
+ (AA)** failure, and the only way out was to hit a 24px button with a pointer.
1101
+
1102
+ **Declared limit:** there is **no `inert` on the ancestors' siblings and no focus trap.** `aria-modal` removes
1103
+ the content underneath from the screen-reader tree but _not_ from the tab order; doing that properly means
1104
+ writing to nodes the grid does not own (its ancestors' siblings in the host app), which this component
1105
+ deliberately does not do. A host that needs a true trap should render the grid inside its own dialog.
1106
+
1107
+ ⚠️ **The sticky header lifts its own stacking context while a resize handle has focus, at
1108
+ `tableZ.stickyHeaderFocusLift` (45) — R21.** The scrollbar lane sits at 40 and covered a focused resize
1109
+ handle completely, which fails WCAG 2.2 SC 2.4.11. The obvious fix — raising the handle's own
1110
+ `focus-visible` z — **cannot work, and this was measured, not assumed**: the `<thead>` is `sticky` with a
1111
+ z-index of its own, so it CREATES a stacking context and confines every descendant inside it. The handle's
1112
+ lift already beats the pinned header cells (30, same context) and can never reach a lane that lives outside
1113
+ the table. So the `<thead>` itself lifts, and only while a handle is focused (`focusin`/`focusout`,
1114
+ recognised by the handle's `data-grid-resize-handle` marker); at rest it returns to 20, because a permanently
1115
+ lifted header would change the stacking of every scene.
1116
+
1117
+ ⚠️ **45 is between 40 and 50 on purpose, and it is inline from the token rather than a class.** It must beat
1118
+ the scrollbar lane (40) and stay below the filter panel (50), so an overlay still covers the header as it
1119
+ should. There is no Tailwind step at 45, and this lane forbids arbitrary values in classes — `check-grid-css`
1120
+ rejects any token containing a bracket, variants included — so the value is applied inline from the token,
1121
+ the same shape the full-screen branch uses and for the same reason.
1122
+
1123
+ ⚠️ **Full screen raises the grid's OWN overlays too, at `tableZ.fullscreenOverlay` (120).** Raising the grid
1124
+ to 110 to cover the old table's body-portaled overlays also raised it above its own portaled menus (a Radix
1125
+ dropdown portaled to `<body>` at z-50). The witness used to be the column menu, whose removal took P4's
1126
+ non-dragging pointer path with it; the property is unchanged and the witness is now the ROW menu, which carries
1127
+ the same `modal={false}` and the same portal — see `MenuDiColonnaSopraLaGrigliaAScehrmoIntero`. It made P4's
1128
+ non-dragging pointer path (WCAG 2.5.7 "widen /
1129
+ narrow") and sorting-from-the-menu unreachable — measured with `document.elementFromPoint` at the open menu's
1130
+ centre: the grid was on top. The menu now takes an inline z from that token while full screen is active.
1131
+
1132
+ The z-index that decides is the one on **Radix's popper wrapper**, not on the menu content: the wrapper is the
1133
+ stacking context. Radix copies the content's computed z-index onto the wrapper at mount, which is why an
1134
+ inline z on the content reaches it — and why raising the content's z _after_ mount does nothing. `DropdownMenuContent`
1135
+ hard-codes its Portal with no `container` passthrough, and that file is shared surface the frozen `DataTable`
1136
+ renders, so this lane raises its own overlay from its own side rather than changing the primitive.
1137
+
1138
+ ⚠️ **The density buttons' pressed state inverts fill and text**, rather than using `bg-d-muted`. In the shipped
1139
+ theme `--d-muted` and `--d-background` resolve to the **same** colour, so the pressed fill measured **1.00:1**
1140
+ against the grid background and which density was active was invisible — only a colour difference between two
1141
+ greys remained (**WCAG 1.4.11** wants 3:1 for a state indicator). `--d-foreground` is by definition the colour
1142
+ readable on `--d-background`, so it is the one pair a theme cannot get wrong: measured 12.46:1, the same figure
1143
+ P4 reached for its focus indicator. `font-medium` adds a non-colour signal (**1.4.1**), and every toolbar
1144
+ control is at least 24×24 (**2.5.8**) with an explicit `min-w-6` — relying on glyph width plus padding
1145
+ measured 23px in one runner and 24px in Chrome, and a target that conforms only under some font metrics does
1146
+ not conform.
1147
+
1148
+ ### Lifted filters, chips, panel and column registry (P7)
1149
+
1150
+ The filter state lives **above** the grid, in `useGridFilters`. That is what makes the deciding criterion
1151
+ true: **the chips work with no `<AppDataGrid>` in the tree.** If the chips lived inside the grid, putting them
1152
+ at the top of the page — where R2 wants them — would mean rendering two grids or passing props backwards. So
1153
+ the hook owns the state, `GridFilterChips` reads it, `GridFilterPanel` writes it, and the grid receives it as
1154
+ an ordinary prop (`filters`, `columnVisibility`). Both are **controlled when passed**: present means they
1155
+ command, absent means the grid keeps its own slice exactly as before P7.
1156
+
1157
+ It is **not a context**, deliberately: a context would solve the same problem while making the chips usable
1158
+ only inside a provider — reintroducing, in another shape, the coupling this phase exists to break.
1159
+
1160
+ **The panel WAS an in-flow section, and from P7 it is the 288px overlay inventory.** The human signature that
1161
+ had chosen the flow was overturned by a measured criterion — «opening the panel moves no row and takes away no
1162
+ column» — and the three reasons written at the time have to be read for what survives of them:
1163
+
1164
+ - «no focus trap» — **still true, and still the choice.** The body does not trap focus; in full screen the
1165
+ cycle is closed by the two sentinels inside `[data-app-data-grid]`. A trap here would be a second cycle
1166
+ nested inside that one, and tabbing past the last control the two handlers would bounce the focus at each
1167
+ other in the same tick. ⚠️ **Consequence for the call-site: render the panel INSIDE the grid root** (the
1168
+ cheapest way is `toolbar.filters.render`, which needs no new prop). Outside it, in full screen the root's
1169
+ `fixed inset-0` covers the panel and the sentinels exclude it from the tab cycle — invisible AND unreachable
1170
+ by keyboard, with no error anywhere. That is why the inventory spec re-runs the whole measurement in full
1171
+ screen instead of stopping at the first pass;
1172
+ - «not portaled, so it never enters the z-scale» — **no longer true**, and it is the price of the criterion. It
1173
+ is still not portaled (so it stays inside the sentinels' cycle), but it paints over, so it needs a `z`. That
1174
+ `z` comes from `gridZClass.overlay` and never from a literal: the step below is `focusLift`, i.e. the focused
1175
+ resize handle, and at equal z-index document order decides — a panel written on that step would end up
1176
+ underneath a handle that has the focus;
1177
+ - «the body can really be unmounted» — **still true.** **Closed, the body is `null`, not `display:none`** — a
1178
+ hidden but mounted panel keeps its inputs and their handlers alive, i.e. an invisible form that still reacts.
1179
+
1180
+ The width is `w-72` and **never** an arbitrary value: `check-grid-css.mjs` rejects square brackets in this lane,
1181
+ because the v3 and v4 bundles do not resolve them the same way and a class the v3 bundle does not emit is a
1182
+ full-width panel in every consumer still on Tailwind 3 — with `npm run check` green. The measurement that
1183
+ carries the criterion is `getBoundingClientRect().width === 288` on the EFFECT, plus
1184
+ `getComputedStyle(panel).position === "absolute"`, plus the two deltas of exactly 0; never a grep on the class,
1185
+ which says what was asked for and not what was rendered.
1186
+
1187
+ ⚠️ **The panel lists the AVAILABLE filters and also keeps the applied rows — declared deviation.** The
1188
+ inventory (`[data-grid-filter-inventory]`) is the answer to the criterion; the rows of the applied filters and
1189
+ the «add» control stay below it, inside the scrolling area. Removing them was measured as costing more than it
1190
+ buys: six assertions of `app-data-grid-filters-lifted.stories.tsx` interrogate
1191
+ `[data-grid-filter-row-value]`, `[data-grid-filter-row-operator]`, `[data-grid-filter-add-column]` and
1192
+ `[data-grid-filter-add]`, so the cut lowers a non-negotiable floor — and the applied row is exactly the surface
1193
+ the phase's human check opens («open the filter for "sector": a list of choices, not a field to type into»).
1194
+
1195
+ ⚠️ **The value of an applied row is no longer a text field for everything.** It is `GridFilterValueEditor`, and
1196
+ the branch is `control.editor` from `gridColumnFilterControl` — one derivation, not a second «type → control» table.
1197
+ `select`/`multiSelect` render a NATIVE `<select>` populated from the column's `options`; `date`/`datetime`
1198
+ render the `DatePicker`; `number` renders `type="number"`. **No branch falls back to a free field**: a list with
1199
+ zero options is an incomplete DECLARATION (`[data-grid-filter-editor-empty]`), not a reason to go back to
1200
+ typing. The `<select>` is native and not `AppSelect` for a measured reason: `AppSelect`'s trigger is a `<div>`
1201
+ with no `role` and no `tabIndex`, so it exposes no `combobox` and no keyboard target — repairing it is its own
1202
+ task and belongs to every consumer, not to this lane.
1203
+
1204
+ ⚠️ **A range is TWO badges, not a new operator.** The vocabulary has no `between`, and adding one would be a red
1205
+ gate (`check-datatable-grid-parity.mjs` compares the two unions in both directions), i.e. a payload eighteen
1206
+ backends cannot read. `getGridFilterRange(filterType)` derives the pair — `ge`/`le` for numbers,
1207
+ `date_after`/`date_before`, `date_time_after`/`date_time_before` — and the composer applies them **in one
1208
+ mutation** through `useGridFilters().addBadges`. Two separate `addBadge` calls would emit **twice**: measured, the counter
1209
+ printed 2 where the criterion asks for 1. ⚠️ **The MECHANISM changed on 2026-09-09 and the consequence did
1210
+ not.** This sentence used to read «because `applyAndEmit` compares the payload inside each updater»; the
1211
+ announcement now leaves the `setState` updater and reads a synchronous mirror (`storedBadgesRef`), because
1212
+ inside the updater React double-invokes it under `StrictMode` and the announcement was doubled with it. Two
1213
+ `addBadge` calls still produce two mutations and therefore two announcements — the reason to use
1214
+ `addBadges` is unchanged.
1215
+
1216
+ ⚠️ **Opening the composer works from «Edit value…» and not from the operator radio group — open defect.** The
1217
+ `DismissableLayer` of the closing `DropdownMenu` swallows the `Popover` that mounts in the same frame; from the
1218
+ non-radio item a single `requestAnimationFrame` is enough (measured working), while the radio path's dismissal
1219
+ window lasts longer than a frame because changing the group's value adds a render before the unmount. Tried in
1220
+ four shapes, `modal={false}` included. The only known remedy for the radio path is a 200ms `setTimeout`, which
1221
+ is outside this lane's «zero setTimeout» convention.
1222
+
1223
+ ⚠️ **This defect is UNASSIGNED, and the previous assignment was wrong.** It used to read «assigned phase: Riga
1224
+ Zero», on the reasoning that the zero row is the surface the defect lives in. **The zero row is now delivered**
1225
+ (`filterMode`, `onAddFilter`, `onUpdateFilter`, and the `tr[data-grid-filter-row]` block of the DOM contract),
1226
+ and it did **not** touch this defect — nor could it: a filter cell of the zero row opens its popover from a
1227
+ plain `<button>`, with no `DropdownMenu` closing in the same frame, so the dismissal window this defect needs
1228
+ does not exist there. The defect stays where it was measured: the composer opened from the operator radio group
1229
+ of the **inventory panel**.
1230
+
1231
+ ⚠️ **And it is not «P9» either — that label is spent twice over in this same document.** «P9» here means **row
1232
+ grouping**, delivered (see «Row grouping (P9)» below, and `groupBy` in the props table); the zero row is «C9»
1233
+ in the phase plan and is named **Riga Zero** everywhere in this document, never numbered. A reader who resolves
1234
+ «P9» to the zero row lands on the wrong phase in both directions, which is exactly how an open defect gets
1235
+ filed against work that is already done.
1236
+
1237
+ **The registry is one place** (also signed) — and since the `⋮` menu was removed it is the **only** place.
1238
+ `columnVisibility` is still the only truth about which columns exist, and the registry panel is the view that
1239
+ shows all of it. ⚠️ **Consequence to know:** that panel is opt-in (`toolbar.columns`, or a consumer-mounted
1240
+ `GridColumnRegistryPanel`), so on a grid that mounts neither there is now **no way, inside the table, to hide a
1241
+ column** — the menu item was that way. Two places to hide a column would have been two sources for one
1242
+ question; one place that the caller may not mount is a different trade, and this is where it is written down.
1243
+
1244
+ ⚠️ **Hiding a column drops its filter too**, and that follows from the decision rather than extending it: a
1245
+ hidden column that kept filtering would leave the registry saying one thing ("this column is gone") and the
1246
+ payload another ("…and I am filtering on it"). The pruning is **derived, not an effect**, so the chip and the
1247
+ payload fragment disappear in the **same commit** — in two commits there is a frame where the payload cites a
1248
+ column that no longer exists, and that frame is one the server can receive. Putting the column back brings the
1249
+ filter back, because hiding is not deleting; an effect that cleaned the state would cost a commit and lose the
1250
+ filter irreversibly.
1251
+
1252
+ ⚠️ **A filter WITHOUT a column is exempt, and that is a `kind` check and not a special case.** Since Fase 8
1253
+ `isBadgeActive` reads the discriminant first — `badge.kind === "custom" || activeColumnIds.has(badge.columnId)`
1254
+ — so a badge that cites no column survives every visibility change. It is one predicate with four call sites
1255
+ (the exposed list, the emission gate, and the before/after lists of `setColumnVisible`), so the exemption holds
1256
+ for the chip, the count, the payload and the emission at once. Before that line a `kind: "custom"` badge was
1257
+ dropped by all four together, which is the worst shape of the defect: the three readers of the count agreed on
1258
+ a figure that was wrong, and no assertion comparing readers with each other could see it.
1259
+
1260
+ ⚠️ **The lifted state emits, and the grid does not emit for it.** Measured in Chrome: the first cut passed the
1261
+ badges to the grid as a prop and _nothing_ emitted — `data-emissions` stayed `0` after applying a filter, so a
1262
+ server-mode consumer would never refetch, and none of the nine specs saw it. A component cannot emit for state
1263
+ it does not own without an effect watching the prop, which is exactly what this lane forbids (an effect that
1264
+ emits is a refetch nobody asked for, at mount and on every parent re-render). So `useGridFilters` takes
1265
+ `onFiltersChange` and calls it **synchronously inside the action** — `addBadge`, `updateBadge`, `removeBadge`,
1266
+ `clearBadges` — which keeps the substance of the invariant, and exposes its own `emissions` counter.
1267
+
1268
+ **Declared deviation** from the phase criterion, which says "`data-emissions` rises by 1": that counter is on
1269
+ the grid, and with lifted filters the grid is no longer who emits for them. The equivalent counter is
1270
+ `emissions` on the lifted state; the spec measures that one, plus the other half of the criterion — that the
1271
+ **grid's** counter does not move when the panel opens or closes.
1272
+
1273
+ ⚠️ **Emission is skipped when the payload does not actually change.** Same exit `applyAndEmit` has in the
1274
+ engine, same reason: changing the operator of a filter that has no value does not change the payload (the
1275
+ compiler drops unusable badges), and without the comparison a consumer would refetch on every dropdown click.
1276
+ The comparison is on `JSON.stringify`, because the **order** of `_and` members is part of the parity contract.
1277
+
1278
+ ⚠️ **The payload comes from the P2 compiler, unchanged.** `buildGridSearchFilter` keeps its three deliberately
1279
+ copied quirks (valueless operators produce `value: true`; validation looks at `list_multi_select` while
1280
+ compilation wraps `list_single_select`; the simple badge drops the empty string, the advanced condition does
1281
+ not). The 20-operator parity story proves compiler == frozen `buildSearchFilter`; a P7 spec proves lifted ==
1282
+ compiler. Together: lifted == frozen. Without that second link a lifted path that "improved" something would
1283
+ keep parity green while the real payload diverged.
1284
+
1285
+ Its shape, which is easy to assert wrongly — it keys **by field name** and collapses the trivial cases:
1286
+
1287
+ | Badges | `computedFilter` |
1288
+ | ----------- | ----------------------------------------------------------- |
1289
+ | none | `{}` |
1290
+ | one | `{ city: { operator, value } }` — **not** wrapped in `_and` |
1291
+ | two or more | `{ _and: [ { city: … }, { score: … } ] }` |
1292
+
1293
+ ⚠️ **Hiding a column removes it from every surface** — header, `<colgroup>` and body cells. Measured with a
1294
+ probe _before_ the fix: `columnVisibility` reached only TanStack, which the grid does not use to render (cells
1295
+ and headers are built from the registry), so unchecking a column changed **nothing** — the registry dropped
1296
+ the filter and left the column. Half of R9, and none of the fifteen specs saw it.
1297
+
1298
+ The pruning happens on the **tree**, before flattening, in one place in the engine. Pruning only the leaves
1299
+ would leave P5's `<thead>` matrix rendering a header for a column that is gone, and `Σ colSpan = leafCount`
1300
+ (P5's criterion 1) would stop holding — a header out of step with the body is the worst defect a table can
1301
+ have, because the data is read under the wrong label. A group left with no leaves disappears: a group `<th>`
1302
+ with `colSpan: 0` is not representable, and one with `colSpan: 1` and nothing under it shifts every column
1303
+ after it. The phantom `<col>` of P4 stays exactly one.
1304
+
1305
+ ⚠️ **The last visible column cannot be hidden — the pruning has a floor.** Hide them one by one and the
1306
+ grid keeps pruning until one column is left; hiding that one returns the **whole** tree instead of an empty
1307
+ one. Without the floor `pruneHiddenGridColumns` returns `[]`, which renders an empty `<colgroup>`, an empty
1308
+ `<thead>` and rows with no cells — and that state is **terminal**: with the header gone there is no `⋮` menu
1309
+ left to open, and `toolbar.columns` is absent by default, so on a grid that opts into no slots the header menu
1310
+ was the only way to hide and there was no inverse anywhere. Only a remount got out of it.
1311
+
1312
+ Only **one** place prunes (`visibleTree` in the engine), and that is where the floor lives — never a second
1313
+ pruning site downstream, because two floors would be two sources for one truth. It returns the whole tree
1314
+ rather than "the last column survives", because _which_ column is last depends on the order the boxes were
1315
+ unchecked, i.e. it would be a state that has to remember its own history. The visibility **map is untouched**:
1316
+ the boxes stay where the user put them, and re-checking any one column resumes real pruning in the same commit.
1317
+ Measured on the live path with four columns, **before** the write-side refusal below existed: unchecking them
1318
+ in turn gave `[city, score, azioni]` → `[score, azioni]` → `[azioni]` → **`[name, city, score, azioni]`** (the
1319
+ floor), then re-checking `city` gave `[city]`. The last of those four gestures is now refused upstream, so the
1320
+ floor is no longer reachable by unchecking — it is the net, not the behaviour.
1321
+
1322
+ ⚠️ **The hide affordance is `disabled`, and the write path refuses too — the all-hidden state is
1323
+ unreachable rather than recovered afterwards.** ⚠️ It used to read «BOTH hide affordances», and the second was
1324
+ the column menu's `[data-grid-column-hide]` item, `disabled` on the same `engine.headers.length > 1` condition;
1325
+ the menu is gone, so the guard that counted **visible leaves** now has one surface instead of two. Two guards
1326
+ remain, in the order a gesture meets them:
1327
+
1328
+ - the panel's `[data-grid-column-registry-toggle]` checkbox is `disabled` on a **visible** column when only one
1329
+ remains visible. Re-checking a **hidden** column is always allowed: the panel is the place a column is put
1330
+ _back_, and disabling that would remove its reason to exist. The panel's `puoNascondere` prop defaults to
1331
+ `true`, so the three stories that mount it standalone — with a state of their own and no grid floor — are
1332
+ unchanged;
1333
+ - `changeColumnVisibility` **returns early** on `!visible && !puoNascondereUnaColonna`, so even a call that
1334
+ bypasses both controls writes nothing. This is a refusal on the write path, not a second pruning site: the
1335
+ map never reaches the all-hidden state, so nothing downstream has to have an opinion about it.
1336
+
1337
+ The floor in `visibleTree` stays as the **safety net**, for the paths that do not go through that guard (a
1338
+ `columnVisibility` prop arriving already all-`false` from a consumer, or a preset restoring one). It also
1339
+ closes a disagreement that would otherwise be live: "which columns are visible" is read from the **tree** by
1340
+ the header/body/`<colgroup>` and from the **map** by `use-grid-filters.ts` (`activeColumnIds`), and with every
1341
+ box unchecked the tree would say "all four" while the map said "none". Unreachable now, because the write
1342
+ refuses.
1343
+
1344
+ ⚠️ **Changing column visibility emits**, when the payload actually changes. Hiding a column prunes its badge,
1345
+ so `computedFilter` goes from `{_and:[{city},{score}]}` to `{score}` — and before the fix that change was
1346
+ silent, so a server-mode consumer kept showing rows filtered by city while the chip was already gone. It was
1347
+ also a pure asymmetry: the same payload change emitted from a chip and stayed quiet from the registry's
1348
+ checkbox. This is a **view action that emits**, and it does not contradict "view does not emit": that rule is
1349
+ about view actions which do not touch the payload (density, width, full screen, opening the panel). The gate is
1350
+ the same as `applyAndEmit`'s — hiding a column that carries no filter emits nothing.
1351
+
1352
+ ⚠️ **Changing a lifted filter resets pagination to page 0.** The reducer's `query/filters` action does that,
1353
+ because a filtered result has a different number of pages — but with lifted filters that action is never
1354
+ dispatched, so the page stayed put: sitting on page 3, change a filter, and the payload still asks for
1355
+ `pageIndex: 3` of a set that may have one page. An empty table with no explanation. The reset compares badge
1356
+ **content** — `JSON.stringify` of the lifted array — and lasts until the next explicit page action.
1357
+ ⚠️ **IT COMPARED IDENTITY UNTIL 2026-09-09, and the audit named this very sentence as the seat of that
1358
+ promise.** Identity was the wrong test because the promise it rested on is not one the grid can enforce:
1359
+ a call-site writing `filters={badges.filter(Boolean)}` inline hands over a NEW array on every render, so
1360
+ the flag rose on every render, the page returned to 0 right after each click on «next», and pagination
1361
+ was nailed to page 1 with nothing to signal it. Comparing content costs a `stringify` of a short array
1362
+ and removes the requirement that the consumer memoize. Nailed by
1363
+ `app-data-grid-fix-motore.stories.tsx :: UnArrayRicostruitoNonInchiodaLaPagina`, which rebuilds the
1364
+ array in the JSX on purpose.
1365
+
1366
+ ⚠️ **The panel does not offer hidden columns.** Offering one means adding a filter that does nothing visible —
1367
+ the badge is born and pruned immediately — i.e. a control that accepts an action and throws it away.
1368
+
1369
+ ⚠️ **The registry panel lists every leaf column, `accessorKey` or not** — corrected in Fase 3. Its `columns`
1370
+ prop was typed `GridFilterColumn[]`, and the only producer of that shape (`toGridFilterColumns`) **drops**
1371
+ columns without an `accessorKey` — i.e. every column from `createGridDisplayColumn` and
1372
+ `createGridActionColumn`. Two silent consequences, both measured on the live path with
1373
+ `[name, azioni, city, score]` where `azioni` is an action column: the order the panel emits did not **name**
1374
+ `azioni`, so in the engine it collapsed to `Number.MAX_SAFE_INTEGER` and slid to the tail — one press of «move
1375
+ later» on `name` rendered `[city, name, score, azioni]`, the action column jumping from position 2 to 4, with
1376
+ `<col>`, `<th>` and `<td>` all agreeing on the wrong table because they all derive from the same tree. And
1377
+ «Hide this column» on such a column was **unrecoverable**, because the checkbox that puts it back was not in
1378
+ the panel. After the fix the same gesture renders `[azioni, name, city, score]`. The panel needs `{ id, label }` plus two OPTIONAL fields it uses for the row icon and the
1379
+ reorder constraint, so its prop is `readonly GridColumnRegistryEntry[]` where the entry is
1380
+ `{ id: string; label: string; columnType?: GridColumnType; pinned?: "left" | "right" }`
1381
+ (`GridColumnRegistryEntry`, a public export). ⚠️ Both extra fields being OPTIONAL is a silent trap: a mount
1382
+ that omits them compiles, passes every gate, and renders identical icons with an inert reorder constraint — a `GridFilterColumn` is still assignable to it, so a consumer that mounts the
1383
+ panel with filter columns keeps compiling.
1384
+
1385
+ ⚠️ **The value field uses `size="sm"`, and its focus ring is the primitive's.** `Input` is a _wrapper_: a
1386
+ `<div>` carrying border, focus ring and height, plus an inner `<input>` that receives `className`. So a height
1387
+ class passed from outside lands on the inner input, not the wrapper — measured, `!h-6` gave a 20px input inside
1388
+ a 36px wrapper, i.e. the field misaligned by 8px from its neighbours and overflowing its row by 4px. The
1389
+ wrapper exposes no class prop, so `size` is the only lever and `sm` is its smallest step (32px). **Declared
1390
+ limit:** the contrast-corrected focus ring cannot be applied to this one control for the same reason — the ring
1391
+ lives on the wrapper. Fixing it would mean touching `input.tsx`, which is shared surface the frozen `DataTable`
1392
+ renders, and would restyle every input in every consumer. That is a task of its own, and it applies to all of
1393
+ them.
1394
+
1395
+ ⚠️ **Badge filters do not filter client-side.** They build the payload a server would receive; the grid keeps
1396
+ filtering locally from `search` only. That is what the phase criterion asks for ("with the section hidden the
1397
+ grid keeps filtering from `search`"), and it is the honest split for a component whose server story is
1398
+ `onQueryChange` — but a consumer rendering a lifted filter over local data will see the rows unchanged until
1399
+ it filters the data itself.
1400
+
1401
+ ⚠️ **A column with no `accessorKey` is not filterable, and is dropped rather than defaulted.**
1402
+ `createGridDisplayColumn` legitimately produces one (an actions or presentation column), and `accessorKey` is
1403
+ the key the fragment enters the payload under — so filtering on it would send a condition on a field the
1404
+ server does not have. Falling back to `column.id` would be worse than the problem: it would fire a query on an
1405
+ invented name, and the place that gets discovered is somebody else's backend.
1406
+
1407
+ ### Filters the application declares — the `GridFilterControl` registry (R15, Fase 8)
1408
+
1409
+ A filter that has **no column** — «only my favourites», «older than N days», a saved segment — used to be
1410
+ unrepresentable: every badge cited a column, and column pruning dropped anything whose `columnId` matched no
1411
+ leaf. From Fase 8 such a filter is a first-class citizen. It is declared once as a **registry entry**, it
1412
+ enters the state as a badge with `kind: "custom"`, and from there it is a filter like any other: it is
1413
+ counted, it is cleared by «Reset all», it goes into `computedFilter`, and hiding a column never touches it.
1414
+
1415
+ ```ts
1416
+ import {
1417
+ AppDataGrid,
1418
+ createGridCustomFilterBadge,
1419
+ useGridFilters,
1420
+ type GridFilterControl,
1421
+ type GridFilterControlRegistry,
1422
+ } from "laif-ds";
1423
+
1424
+ const olderThan: GridFilterControl = {
1425
+ // The KEY of the entry in the registry — a string, not a closed literal:
1426
+ // a closed union here would make the registry extensible only by changing
1427
+ // the library, which is the opposite of what R15 is for.
1428
+ type: "olderThan",
1429
+ // The control that writes the value. THE GRID DOES NOT RENDER IT: it is
1430
+ // rendered by the application, wherever the filter is created.
1431
+ Control: ({ value, onChange, label }) => <MyDaysInput …/>,
1432
+ // MANDATORY. Value -> payload piece. `null` means «not applied».
1433
+ toFragment: (value, ctx) => ({ [ctx.field]: { operator: "ge", value } }),
1434
+ // MANDATORY. Value -> text. The ONLY source of a custom filter's text.
1435
+ describe: (value, i18n) => `Older than ${String(value)} days`,
1436
+ };
1437
+
1438
+ const registry: GridFilterControlRegistry = { olderThan };
1439
+ ```
1440
+
1441
+ **`toFragment` and `describe` are a mandatory PAIR, and the type enforces it.** You register a control **and**
1442
+ its translation, never one without the other: omitting either does not compile. A filter that can produce a
1443
+ payload but cannot say what it is called is exactly the defect this phase measures — a badge saying «12» next
1444
+ to 10 filled cells, and no line on screen saying where the other 2 are. Both are **properties with an arrow
1445
+ type, never methods**: under `strict` the parameters of an arrow property are contravariant and those of a
1446
+ method are bivariant, so the method form would let a wrong signature through. The type is deliberately **not
1447
+ generic on the value**: a `GridFilterControl<string>` would not be assignable to the registry that has to
1448
+ contain it, again by contravariance — narrow the value inside the two functions instead.
1449
+
1450
+ **One constructor, and only one.** `createGridCustomFilterBadge(control, value, i18n, id)` is the single place
1451
+ in the library where `describe` and `toFragment` are ever called:
1452
+
1453
+ ```ts
1454
+ const filters = useGridFilters({
1455
+ columns,
1456
+ initialBadges: [createGridCustomFilterBadge(olderThan, 30, i18n, "b-older")],
1457
+ });
1458
+ ```
1459
+
1460
+ It fills `kind: "custom"`, `customKey` (= `control.type`), `customLabel` (= the ONE call to `describe`),
1461
+ `customFragment` (= the ONE call to `toFragment`, `undefined` when it returned `null`), and the four column
1462
+ fields as INERT placeholders — `columnId` gets the reserved prefix `grid-custom-filter:<type>` (an id no
1463
+ column has, so a header never announces itself «filtered» because of it), `columnLabel` gets the raw registry
1464
+ key on purpose, so a surface that wrongly recomposed «column + operator + value» would show `olderThan` on
1465
+ screen instead of a plausible sentence. **To change the value, rebuild the badge with the SAME `id`** and pass
1466
+ it to `updateBadge`: text and fragment then change together. Recomputing only the fragment would leave the
1467
+ previous value's description on screen.
1468
+
1469
+ **Where a filter without a column is seen.** Four surfaces, one text — `customLabel`, and nothing composed
1470
+ locally:
1471
+
1472
+ | Surface | What it shows |
1473
+ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
1474
+ | the chip (`GridFilterChips`) | `customLabel` as visible text, as the trigger's accessible name and as the menu heading — one string |
1475
+ | the applied panel row (`[data-grid-filter-row-kind="custom"]`) | `customLabel` plus its «×». No operator select, no value editor: there is no vocabulary to offer |
1476
+ | the dashed toolbar group (`[data-grid-toolbar-custom-filters]`) | a VISIBLE caption («2 senza colonna») plus `customLabel` in full and a named `<button>` «Remove filter …» per entry |
1477
+ | the status line (`[data-grid-live-region]`) | a tail on the filters clause: «12 filters active, 2 without a column: …» — the count AND the names |
1478
+
1479
+ The toolbar group needs `onRemoveFilter` **and** lifted `filters` to render (see the prop table): its «×» is a
1480
+ real button, the grid cannot remove a badge from its own slice, and a button that fires a handler and changes
1481
+ nothing is a dead command. The status-line tail appears only when there is at least one filter without a
1482
+ column — «0 without a column» on every grid would be noise.
1483
+
1484
+ **On screen and announced are two different halves.** The status-line tail lives in the single `role="status"`
1485
+ node, which is `sr-only`: it is what a screen reader hears, never what a sighted reader sees. The sentence that
1486
+ says where the missing filters are — «the badge says 12, the panel has 10 filled cells, 2 have no column» — is
1487
+ declared on screen by `[data-grid-toolbar-custom-filters-caption]` plus the full names next to it. A probe that
1488
+ only reads the live region's text cannot tell the two apart; measure the caption's rendered width.
1489
+
1490
+ **Known limit, declared:** the status-line tail enumerates EVERY filter without a column, with no cap. With
1491
+ twelve of them a screen reader re-reads twelve full sentences on every gesture that touches the filters. It is
1492
+ not truncated today because no scene in the lane has more than three, and a truncation branch nothing exercises
1493
+ is a branch that is never run.
1494
+
1495
+ **What the compiler does with it.** A `kind: "custom"` badge leaves `usableBadges` before `badgeToCondition`,
1496
+ so it never goes through the rewrite that keys a condition by `columnAccessorKey` and passes the value through
1497
+ `conditionValue`. Its `customFragment` is collected by `gridCustomFragments` and appended, **byte for byte**,
1498
+ at the END of the conjunction — so a payload with no custom filters is identical to what it was before this
1499
+ phase existed. Emission stays single: it goes through the same `applyAndEmit` as everything else, with no
1500
+ extra branch — and therefore inherits its coalescing, since a custom filter lands as a `query/filters` action
1501
+ like any other.
1502
+
1503
+ **Three i18n keys, all optional** (`GridI18n["filters"]`, English by default): `removeFilterNamed` («Remove
1504
+ filter», the prefix composed with the name for the «×»), `customFilters` («Filters without a column», the
1505
+ accessible name of the dashed `role="group"`), `withoutColumn` («without a column», the unit after the count
1506
+ in the status line).
1507
+
1508
+ **Declared limits.** The library does not render a registry entry's `Control` anywhere — the value of a custom
1509
+ filter is written where the filter is created, not from the panel, and the panel therefore shows a custom
1510
+ filter _named and removable_, not _editable_. And `describe` produces text of uncontrolled length, which is
1511
+ **not truncated** in the dashed group: «in full» is literal, the group wraps, and one very long name widens
1512
+ the toolbar band instead of overflowing it.
1513
+
1514
+ ### Infinite source and skeleton rows (P8)
1515
+
1516
+ `infinite={{ hasMore }}` turns on paging-while-scrolling. Requires `virtualized` to be meaningful.
1517
+
1518
+ ```tsx
1519
+ const [rows, setRows] = useState<Row[]>([]);
1520
+ const [loading, setLoading] = useState(false);
1521
+ const [hasMore, setHasMore] = useState(true);
1522
+
1523
+ <AppDataGrid
1524
+ columns={columns}
1525
+ data={rows}
1526
+ virtualized
1527
+ viewportHeight={520}
1528
+ pageSize={30}
1529
+ loading={loading}
1530
+ infinite={{ hasMore }}
1531
+ onQueryChange={async (state) => {
1532
+ setLoading(true);
1533
+ const page = await api.list({ ...state, page: state.pagination.pageIndex });
1534
+ // pageIndex 0 means a NEW list (sort, filter, search): replace.
1535
+ // Anything above means "the next page of this list": append.
1536
+ setRows((prev) =>
1537
+ state.pagination.pageIndex === 0 ? page.rows : [...prev, ...page.rows],
1538
+ );
1539
+ setHasMore(page.hasMore);
1540
+ setLoading(false);
1541
+ }}
1542
+ />;
1543
+ ```
1544
+
1545
+ **The consumer appends, the grid does not.** The grid holds no copy of `data`. Two lists that must
1546
+ stay equal are two lists that eventually are not.
1547
+
1548
+ **`hasMore` is declared, never inferred.** The obvious inference — "a page came back shorter than
1549
+ `pageSize`, so we are done" — is wrong against any API that can legitimately answer short (a time
1550
+ window, a permission, a cost ceiling), and getting it wrong silently truncates a list that still had
1551
+ rows. With `hasMore: false` the grid stops asking **permanently**; scrolling further does not restart
1552
+ it.
1553
+
1554
+ #### What the phase changed inside
1555
+
1556
+ | Before P8 | From P8 |
1557
+ | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1558
+ | `loading` was an either/or: skeletons **or** data | They **coexist**; the shell picks append vs replace |
1559
+ | The virtualizer counted `data.length` | It counts **what is rendered** — data rows **plus** skeleton rows |
1560
+ | `enabled: virtualized && !loading` — virtualization switched off while loading | `enabled: virtualized`; when there is nothing to virtualize `count` is 0 and says so |
1561
+ | `query/sort` and `query/sort-set` left `pageIndex` where it was | They **reset it to 0**, like search and filters already did. Sorting from page 7 asked for page 7 of a new order — rows unrelated to what you were looking at; under an infinite source it made the emitted payload plainly false |
1562
+
1563
+ That third row is the P3/P4 debt paid off. The old line was correct for the reason written at the
1564
+ time — while loading, the rendered rows were not the data rows, so spacers sized on `data.length`
1565
+ produced a 359,208 px bottom spacer under ten skeletons — but it was a remedy, not the cause. The
1566
+ cause is removed: **the virtualizer counts what is rendered**, so the spacers are exact in both
1567
+ states and a scrollable void is no longer a case to exclude but an unrepresentable state.
1568
+
1569
+ #### The invariants the phase rests on
1570
+
1571
+ - **A skeleton row is exactly as tall as a data row**, at all three densities (32 / 36 / 48). Both
1572
+ read `gridRowHeightClass[density]` — one source, not two. Skeletons enter the virtualizer's count
1573
+ as ordinary rows, so a pixel of difference per row scales with the length of the list.
1574
+ - **A skeleton has one cell per _visible_ leaf column, never a `colSpan`.** `headers` already carries
1575
+ only the unhidden leaves (P5 flattens the tree, P7 prunes it), so the count holds by construction.
1576
+ A `colSpan` here would skew the `headers` chain that P5 derives by walking the header colSpans —
1577
+ and skew it _only while loading_, which is the state nobody checks.
1578
+ - **One request per page, and never more than one in flight.** The loader's guard is the key
1579
+ `querySignature:rowCount`, which contains nothing that requesting itself changes. A boolean
1580
+ "in flight" flag is the wrong shape: the point where you clear it is where the defect hides — and
1581
+ neither is a counter kept inside the reducer, which cannot see the **lifted filters** of P7 (those
1582
+ never pass through it), so a chip change would leave the key still and freeze the list.
1583
+ - **The page asked for is the request count, not a function of the rows.** A page shorter than
1584
+ `pageSize` is legitimate, and both obvious derivations break on it: `floor(rows / pageSize)` re-asks
1585
+ the page that just arrived, and the same on the _filtered_ row model asks for a page decided by how
1586
+ many rows the client filter kept. On mount the count is seeded from the data already in hand, so a
1587
+ consumer that server-renders or caches the first page is not made to re-fetch it.
1588
+ - **A page in flight is announced.** `aria-busy` on the root plus a polite live region, because the
1589
+ skeletons are `aria-hidden` and without it a screen-reader user has no way to know a page is coming
1590
+ or has arrived (WCAG 4.1.3 Status Messages, AA).
1591
+ - **The end of the source is a state, not a loop.** `hasMore: false` exits immediately, forever.
1592
+ - **One fetch on mount, `StrictMode` included.** There is no dedicated first-page path: mount is
1593
+ simply the case where the list is zero tall, so the distance to the bottom is zero and the single
1594
+ trigger fires once. React refs survive StrictMode's double effect invocation, so the second pass
1595
+ finds the same key and exits.
1596
+
1597
+ #### Declared limits
1598
+
1599
+ - **`aria-rowcount` is `-1` while `hasMore` is true.** ARIA requires it when the total row count is
1600
+ unknown, which is what an infinite source is. It goes back to the row count once the source is
1601
+ exhausted, and outside infinite mode it never changes.
1602
+ - **A consumer that answers with zero new rows while keeping `hasMore: true` stops the grid.** The
1603
+ guard key does not move, so nothing more is requested. Between the two ways of mishandling a
1604
+ consumer that contradicts itself, this is the one that does not drain a battery.
1605
+ - **The trigger is scroll position only.** There is no keyboard path to request the next page
1606
+ without scrolling. This is the one accessibility gap the phase leaves open.
1607
+ - **⚠️ A custom `emptyComponent` is shown but not announced; the default message is announced
1608
+ instead.** **From Fase 5 the mechanism is reconfirmed for all THREE outcomes without data, and it
1609
+ is unchanged.** There is exactly ONE `[role="status"]` in the grid in the empty, error and loading
1610
+ states alike — measured per grid, not per canvas; the visible nodes of all three carry **no**
1611
+ `role`; and what is announced is always a STRING chosen by a single ternary whose branch order is
1612
+ loading → error → empty (error FIRST of the two, because a failed fetch also has zero rows):
1613
+ `i18n.notFoundMessage` for the empty outcome, `i18n.table.errorMessage` for the error one — never
1614
+ the error surface itself, which contains a «Retry» button a screen reader would otherwise read
1615
+ twice. The empty state is announced through the permanent `[data-grid-live-region]`, which is
1616
+ mounted empty and filled when the state arrives — a live region created together with its text has
1617
+ no mutation to announce, so screen readers stay silent, which is the defect this arrangement fixed.
1618
+ What goes into that region is always `i18n.notFoundMessage`, never `emptyComponent`, **and the
1619
+ reason is that they are different kinds of thing**: `emptyComponent` is an arbitrary `ReactNode` —
1620
+ an illustration, a fragment, a button — while a live region announces _text_. Putting a node tree
1621
+ in there yields an unpredictable announcement or none at all, and any interactive element inside it
1622
+ would be read twice, once in the region and once on the page.
1623
+
1624
+ The accepted consequence: pass a custom `emptyComponent` and a sighted user reads your wording while
1625
+ a screen-reader user hears "No results found". Both are true statements about the same state, but
1626
+ they are not the same sentence. If you need them to match, change `i18n.notFoundMessage` alongside
1627
+ your `emptyComponent`; there is deliberately no separate announcement prop, because a second way to
1628
+ say the same thing is a second thing to keep in sync.
1629
+
1630
+ - **⚠️ Without `getRowId`, row identity is positional and the selection does not survive a
1631
+ re-sort.** TanStack's default row id is the row's index in `data`, and the grid does not override
1632
+ it unless the consumer passes `getRowId`. In infinite mode every re-sort and every filter change
1633
+ goes through `restart`: the list is rebuilt, and a selection held as `Record<rowId, boolean>` keeps
1634
+ pointing at the same indices — which now hold different records. Nothing looks wrong: the ticks are
1635
+ where ticks should be, on the wrong rows. This is a **data** defect, not a visual one, which is why
1636
+ it is written down rather than left to be discovered. Pass `getRowId` and the selection follows the
1637
+ records. Not passing it keeps the old behaviour, deliberately: making stable identity the default
1638
+ would change what every existing call-site's selection means.
1639
+ - **⚠️ `infinite` is ignored when `paginated` is on.** The two are representable together and they
1640
+ break together, because they write the same number: `query/load-more` advances
1641
+ `pagination.pageIndex`, which in paginated mode is exactly what the page control renders and what
1642
+ `getPaginationRowModel` slices the client-side rows by. Traced with `paginated pageSize={10}
1643
+ infinite={{hasMore:true}}` over ten rows: the content (360px) is shorter than the viewport (480px),
1644
+ so the distance to the bottom is ≤ 0 at mount, a load-more fires, `pageIndex` becomes 1, the control
1645
+ jumps to page 2 on its own and `getRowModel()` returns `data.slice(10, 20)` — **an empty table with
1646
+ ten rows in hand**, with nobody having touched anything.
1647
+
1648
+ The loader is therefore disabled whenever `paginated` is on (`enabled: infinite !== undefined &&
1649
+ !paginated`). Narrowing `AppDataGridProps` so the pair could not be written was the rejected
1650
+ alternative: it would reach the call-sites and the codemod. The accepted cost is that `infinite`
1651
+ does nothing next to `paginated` — **which is why it is written down here rather than left silent.
1652
+ A prop that says nothing is worse than a prop that is not there.** Pick one: paginate, or scroll.
1653
+
1654
+ - **`infinite` does not require `virtualized` in the type**, and the two are orthogonal, so the
1655
+ combination `infinite` without `virtualized` is representable. It is made _safe_ rather than
1656
+ forbidden: the scroll container takes its `viewportHeight` whenever either is on, because without a
1657
+ bounded height the page scrolls instead of the container and the loader would chain one request per
1658
+ page until the source ran dry, with nobody having scrolled.
1659
+ _(Historical: this is the rule as P8 declared it. P9 widened it — the viewport is bounded whenever
1660
+ `viewportHeight` is passed explicitly too. See the `viewportHeight` row in the props table.)_
1661
+
1662
+ ### Riga Zero — the filters inside the columns (C9)
1663
+
1664
+ `filterMode="row"` moves the filters from the overlay inventory into the table: a **fourth `<tr>` of
1665
+ `<thead>`**, one control under each column. `"panel"` stays the default, and the switch between the two lives
1666
+ in the **caller**, never in the grid.
1667
+
1668
+ ⚠️ **Telling the zero row apart from the header row is done by a HORIZONTAL rule, and the vertical marks are
1669
+ deliberately weak.** Both halves come from a measurement against ag-grid, taken on 2026-09-10 after the owner
1670
+ reported that the two rows could not be distinguished on the real app:
1671
+
1672
+ | | ag-grid (Quartz dark) | AppDataGrid |
1673
+ | --- | --- | --- |
1674
+ | horizontal rule header ↔ zero row | `border-top: 1px rgba(255,255,255,.15)` on the filter cell | `gridHeaderRowSeparatorShadow` on the LAST header row |
1675
+ | vertical column mark, header row | `2 × 14.4px` tick — **30%** of the 48px row | full-height 1px rule inside the resize handle |
1676
+ | vertical column mark, zero row | **none at all** | `2 × 12px` tick — 33% of the 36px row |
1677
+ | row background | identical on both | identical on both |
1678
+ | row height | 48 / 48, **identical** | 32 / 36 |
1679
+
1680
+ ⚠️ **The horizontal rule is the predicate being corrected, not an exception added to it.** `grid-header.tsx`
1681
+ decides the rule with "does it have a row below it", and used to deny it to the last level because *"there the
1682
+ separation is done by the `<thead>`'s shadow"* — a premise that is **false** with the zero row mounted, since
1683
+ `gridStickyHeaderShadow` sits under the `<thead>`, i.e. under the FILTERS. So the zero row now counts as a row
1684
+ below (`filterRowVisible` on `GridHeaderProps`), and the two signals mark two different boundaries rather than
1685
+ stacking into 2px. The flag is **not** derived from `filterRow !== undefined`: that node can be present and
1686
+ render `null` when the toggle is closed (R13), and deriving it from the node would paint a rule separating the
1687
+ header from itself.
1688
+
1689
+ ⚠️ **The zero row's column mark is a POSITIONED NODE (`[data-grid-filter-cell-tick]`), not a `box-shadow`, and
1690
+ three forms were tried in one day — read all three or the next pass redoes them.** (1) A `box-shadow`
1691
+ (`inset 1px`) on every cell past the first: because an `inset` shadow covers the box's entire side, the mark
1692
+ came out as tall as the cell and **welded** the zero row to the headers — the opposite of the defect it was
1693
+ fixing. (2) A short node centred at a third of the row height: the delivered form. (3) No mark at all, on the
1694
+ measurement that between two bordered controls (borders at x 446 and 470) a third mark at 458 makes three
1695
+ verticals inside 24px — which is also what ag-grid does, its filter row having none. The owner then asked for
1696
+ the mark back: the measurement described a real crowding, the preference is theirs, and form (2) wins. The mark
1697
+ is centred (anchored to the top it would start from the horizontal rule and the two would read as a corner), it
1698
+ sits on the cell's LEFT side so cell `i` marks the boundary `i-1 | i`, and ⚠️ **the FIRST cell carries none** —
1699
+ to its left there is no boundary, only the table's edge or the selection column, whose header
1700
+ (`GridSelectAllHeader`) carries no resize mark. ⚠️ **On a right-pinned block's edge it is not emitted either:**
1701
+ that side already carries the pinned boundary (crisp plus blur).
1702
+
1703
+ ⚠️ **The control is `h-7` (28px) in a 36px row, and the 4px of air on each side are the point, not a rounding.**
1704
+ With a 32px control there were only 2px, and there is a horizontal rule immediately above (the one this section
1705
+ opens with) and the `<thead>`'s shadow immediately below: the four lines fell as two pairs 2px apart — measured
1706
+ at y 253/255 and 287/289 — which reads as one crooked doubled line rather than two boundaries. The control was
1707
+ shrunk rather than the row raised because `gridFilterRowHeight` **is** `designTokens.table.rowHeight`, is
1708
+ exported, and feeds `gridViewportHeight`'s `hasFilterRow` addend: raising the row means splitting a new token
1709
+ out and moving the viewport contract, while this is one class.
1710
+
1711
+ ⚠️ **The header label carries the FULL tone, and the `<th>` does not.** Measured on the app: the leaf label and
1712
+ a filter's resting text were the *same* colour at the *same* size (`rgb(173,173,173)`, 10px), separated only by
1713
+ weight (600 vs 400) — the two rows literally had one voice. The label span therefore takes `text-d-foreground`
1714
+ while the `<th>` keeps `text-d-muted-foreground`, because the sort glyph INHERITS from the `<th>`: raising the
1715
+ tone there would light up the "sortable, not sorted" affordance on every column, which is the same class of
1716
+ defect `SortGlyph` already declares for the accent tint.
1717
+
1718
+ ⚠️ **A cell whose column carries a filter draws its control with an ACCENT border, so the row says at a glance
1719
+ which columns are constrained.** The predicate is `badge !== undefined`, **not** "has a value", and the
1720
+ difference is not academic: an operator of arity `"none"` (`eq_null`, `checked`) filters without a value, and a
1721
+ badge whose value was emptied by hand stays alive until it is removed — with the wrong predicate the cell would
1722
+ claim "no filter" on a column the table is constraining. ⚠️ **It agrees with the header's funnel by
1723
+ construction, not by convention:** `filteredColumnIds` is `new Set(engine.state.query.filters.map(b =>
1724
+ b.columnId))` and the `badges` this row receives ARE `engine.state.query.filters` — one list, passed from one
1725
+ place. Deriving the same truth twice is the failure mode `filteredColumnIds`' own docblock describes: two
1726
+ surfaces that drift apart with nobody noticing. The story
1727
+ `La casella della Riga Zero porta il bordo d'accento dove, e solo dove, c'è un filtro` asserts the two SETS are
1728
+ equal, not their sizes, and seeds a valueless badge so that the `hasValue` predicate would fail it. ⚠️ **Only
1729
+ the colour changes, never the width:** a border that thickened on the filtered column would shift that cell's
1730
+ content by a pixel while its neighbours stayed put, which reads as misalignment rather than as a signal. The
1731
+ declared cost: an emptied badge no longer filters (`usableBadges` drops it) yet still lights up here — exactly
1732
+ as the funnel does, and disagreeing with the funnel to fix that case would be the worse defect.
1733
+
1734
+ ⚠️ **Exactly ONE clear affordance per editor branch — never two, never zero — and which one it is differs by
1735
+ branch.** Our `[data-grid-filter-clear]` renders on `string`, `number`, `boolean` and `list_single_select`; it
1736
+ does **not** render on `date`/`datetime` (the `DatePicker` carries its own `clearable`) nor on
1737
+ `list_multi_select`. The multi-select is the branch that changed on 2026-09-11, on the owner's report that the
1738
+ cell showed two identical ✕: in `app-select.tsx` the multi clear sits inside `{multiple && …}` with **no**
1739
+ condition, so it is always there, while the single one is behind `singleClearable`, which this call-site
1740
+ deliberately does not pass (see limits 1 and 2 in the editor's header). Measured on the live app: the two sat at
1741
+ x 1126 and x 1155, and ours further **overlapped the chevron** at 1159–1175.
1742
+
1743
+ ⚠️ **The two were not duplicates, and the cost of dropping ours is declared.** The primitive's clear calls
1744
+ `handleClear` — it empties the SELECTION, leaving the badge alive with no value, which is the state `onClear`
1745
+ exists to avoid (`applyFilterRowPatch` has no removal branch and `gridActiveFilterCount` keeps counting it, so
1746
+ the accent border stays lit on a column that no longer filters). Ours calls `onClear`, which REMOVES the filter.
1747
+ So on a multi-select column the cell can now empty but not remove; removal stays on the chips and in the
1748
+ inventory. The primitive's clear also has `tabIndex -1` (measured), so on that branch the gesture is
1749
+ pointer-only — limit 2, previously declared without a referent, now has one. The story `SETTE I TIPI` asserts
1750
+ the whole table, plus the property that matters: no branch carries two.
1751
+
1752
+ **How to mount it.** Lift the filters with `useGridFilters`, then hand the SAME three functions to both
1753
+ surfaces:
1754
+
1755
+ ```tsx
1756
+ const filters = useGridFilters({
1757
+ columns: toGridFilterColumns(flattenGridColumns(columns)),
1758
+ });
1759
+
1760
+ // R13 — the toggle starts OPEN, so the budget starts with the addend ON.
1761
+ // Seeding this to `false` gives a viewport short by `gridFilterRowHeight` px
1762
+ // from mount, and nothing corrects it: the first announcement only arrives on
1763
+ // the first click.
1764
+ const [filterRowOpen, setFilterRowOpen] = useState(true);
1765
+
1766
+ <AppDataGrid
1767
+ columns={columns}
1768
+ data={rows}
1769
+ filterMode="row"
1770
+ filters={filters.badges}
1771
+ columnVisibility={filters.columnVisibility}
1772
+ onAddFilter={filters.addBadge}
1773
+ onUpdateFilter={filters.updateBadge}
1774
+ onRemoveFilter={filters.removeBadge}
1775
+ // R13 — the toggle UNMOUNTS the row, so the budget follows it.
1776
+ onFilterRowOpenChange={setFilterRowOpen}
1777
+ viewportHeight={gridViewportHeight({
1778
+ rowCount: 10,
1779
+ density: "comfortable",
1780
+ hasFilterRow: filterRowOpen,
1781
+ })}
1782
+ />;
1783
+ ```
1784
+
1785
+ **The row is a SIBLING, not a matrix row.** `buildGridHeaderMatrix` builds `rows` with exactly
1786
+ `gridHeaderDepth(nodes)` entries and writes the leaves at `rows[depth - 1]`; a fourth row inside it would need
1787
+ a new signature, a new length and a new index in the one module that owns the `Σ colSpan === leafCount`
1788
+ invariant. It would also light the between-levels line on the LEAF row (`level < matrix.rows.length - 1`),
1789
+ i.e. a second separator next to the `<thead>`'s own shadow, which already separates the header from the body.
1790
+ (Until 2026-09-09 that line was a `border-b`, and this paragraph called it "invisible under
1791
+ `border-collapse`, with `getComputedStyle` reporting `1px` either way" — that was kinder than the truth: the
1792
+ border was not merely invisible, it opened a 1px hole that showed the scrolling body. See the sticky-header
1793
+ section above.) The zero row is therefore rendered after `<GridHeader />` and inside the `<thead>`, and
1794
+ `grid-header-matrix.ts` is byte-identical.
1795
+
1796
+ **Every cell sits in its own `<col>` by construction**, on three conditions the implementation keeps: exactly
1797
+ `(checkable ? 1 : 0) + headers.length` cells in the order of `headers`, no cell for the phantom column, and no
1798
+ `colSpan`. The pinned offset is `gridPinnedOffsetStyle(pinnedSlots[columnId])` — the SAME CSS variable declared
1799
+ once on the `<table>`, not a recomputation.
1800
+
1801
+ **Nothing declares a `top`.** The vertical stickiness comes from the `<thead>` as a BLOCK, so
1802
+ `getComputedStyle(tr).position` is not `sticky` and no cell carries a `top`. Only the PINNED cells are sticky,
1803
+ and only horizontally — the same regime as the three rows above.
1804
+
1805
+ **The toggle UNMOUNTS the row — R13, changed on 2026-09-04.** Until then it hid the CONTROLS and never the
1806
+ row, so the row kept its space and the first body row did not move by a pixel. Now `GridFilterRow` returns
1807
+ `null` while the toggle is closed: no `<tr>`, no `<td>`, no control, and no state attribute left to read —
1808
+ «the row is hidden» is measured as the ABSENCE of `tr[data-grid-filter-row]`. ⚠️ **The vertical jump is
1809
+ ACCEPTED, not a defect to repair:** `<thead>` grows and shrinks by `gridFilterRowHeight` px while the frame
1810
+ stays put, so the same pixel under the pointer changes command. The residue it replaces — a 36px row with
1811
+ nothing in it, plus a chip band standing in for it — cost more, and the button's name promises to remove the
1812
+ row. Reserve the space with `hasFilterRow: filterMode === "row" && open`, reading `open` from
1813
+ `onFilterRowOpenChange`.
1814
+
1815
+ **Nothing takes the row's place.** The band of chips that used to summarise the applied constraints while the
1816
+ row was hidden (`[data-grid-filter-row-band]`) has been REMOVED: it does not exist in any state of the toggle,
1817
+ and the toolbar toggle is the only way back — rendering it only while the row is open would leave the row
1818
+ unmounted for good.
1819
+
1820
+ **The constraints scrolled out of the horizontal viewport are NOT named, and reaching them is scrolling —
1821
+ `adg-r14`, 2026-09-04.** The «N filters out of view» button that used to name them in the toolbar has been
1822
+ removed together with its geometry measurement, its reveal arithmetic and its two i18n keys. This is a
1823
+ WITHDRAWAL and not an oversight: criterion 7 of Phase 9 is revoked in both halves (R13 took the chip band, this
1824
+ one takes the indicator), and the reason is toolbar width — the button cost 110px on a row that is already
1825
+ over budget. Deviation 37 of `docs/analysis/datatable-v2/PIANO-COMPLETAMENTO.md` carries the revocation with
1826
+ its measurements. The mockup does have such an indicator, but as an overlay pinned to the right edge of the
1827
+ filter row rather than a toolbar control; deciding whether to build it there belongs to whoever owns the
1828
+ vertical-space budget, not to this document.
1829
+
1830
+ **Both roads produce the same state, by construction and not by convention.** A cell creates its badge with
1831
+ `createGridFilterBadge`, the one and only constructor, and then applies the patch; `onAddFilter`/`onUpdateFilter`
1832
+ are the same two functions the inventory panel receives. The operators a cell offers come from
1833
+ `gridColumnFilterControl`, the same single derivation.
1834
+
1835
+ **One threshold, measured.** Under `gridFilterRowShortPlaceholderMaxWidth` (112, strict) the resting text
1836
+ takes the SHORT form of the placeholder — an exception PER TYPE, today only `number`, so a narrow text column
1837
+ keeps the full form — while the accessible name names the operator IN FULL in both branches.
1838
+
1839
+ **No glyph marks the truncation, at any width.** The text span carries `truncate`, so the ellipsis is drawn by
1840
+ the browser (`text-overflow: ellipsis`); a glyph beside it would print a SECOND one right after the clipped
1841
+ tail, which is why the former truncation icon was removed rather than replaced. The three channels that announce the
1842
+ cut — the always-present `data-filter-truncated`, the `title`, and the `sr-only` node — carry the whole value at
1843
+ any width, and none of the three is chromatic. Truncation is measured on the EFFECT
1844
+ (`scrollWidth > clientWidth`), never estimated from a character width.
1845
+
1846
+ ### Row grouping (P9)
1847
+
1848
+ `groupBy` groups rows by one or more columns, in order. **Client only.**
1849
+
1850
+ ```tsx
1851
+ const columns = [
1852
+ createGridStringColumn<Row>({ accessorKey: "country", header: "Country" }),
1853
+ createGridStringColumn<Row>({ accessorKey: "city", header: "City" }),
1854
+ createGridNumberColumn<Row>({
1855
+ accessorKey: "score",
1856
+ header: "Score",
1857
+ // Receives EVERY row under the group, at any depth — a first-level total has
1858
+ // to see the sub-groups' rows too, not just the ones directly attached.
1859
+ aggregatedCell: (rows) => rows.reduce((a, r) => a + r.score, 0),
1860
+ }),
1861
+ ];
1862
+
1863
+ <AppDataGrid columns={columns} data={rows} groupBy={["country", "city"]} />;
1864
+ ```
1865
+
1866
+ **Client only, and the compiler says so — not the docs.** `AppDataGridProps<TData>` is a union of
1867
+ `GridClientProps` (has `groupBy`, `onQueryChange?: never`) and `GridServerProps` (has
1868
+ `onQueryChange`, `groupBy?: never`). Passing both does not compile. The property is verified by
1869
+ `node scripts/check-grouping-server-unrepresentable.mjs`, which runs `tsc` over
1870
+ `type-probes/grouping-server.probe.tsx` and **requires it to fail** — a probe file that compiled
1871
+ would be the news. Widen the type and that gate goes red.
1872
+
1873
+ Why there is no server-side grouping: a group has to count its own rows and hand them to
1874
+ `aggregatedCell`. With a server source the grid holds **one page**, so "12 rows" and a column total
1875
+ would be the count of what happened to arrive — a number that looks exactly like a total and is not
1876
+ one. The place to aggregate a paged source is the server, and what it sends back are not rows to
1877
+ group: they are already groups.
1878
+
1879
+ #### The invariants the phase rests on
1880
+
1881
+ - **No body cell that carries data spans more than one column** — group rows included, and from
1882
+ Fase 5 with **two named exceptions**. A group row has exactly one cell per visible leaf column, and
1883
+ the cells with nothing to say are **empty**, which is different information from "this column is
1884
+ not in the group". The usual way to write a group row — one cell as wide as the table — is the
1885
+ forbidden one: the `id`/`headers` chain from P5 is derived by walking the header spans, so a body
1886
+ cell declaring its own would skew that derivation for every row below, silently, because the table
1887
+ keeps rendering. Watched on the DOM by `NessunaCellaDiCorpoSiEstende`, which measures
1888
+ `cell.colSpan` rather than the source text — and those specs stay green because they run on scenes
1889
+ that HAVE rows, where neither exception is rendered.
1890
+
1891
+ **The two exceptions, and why they do not skew the chain.** Fase 5 admits a horizontal extension on
1892
+ exactly two nodes, both in `parts/grid-table.tsx`: the **status cell** (`[data-grid-status-cell]` /
1893
+ `[data-grid-empty]`), which is the only cell of its row and exists only when the body has NO data
1894
+ rows — so there is no row below it whose chain could be skewed — and the **footer label cell**
1895
+ (`[data-grid-footer-label]`), reserved and **not used today**: the `<tfoot>` renders one `<td>` per
1896
+ column, label included, because an extended label would cost every pinned column inside the
1897
+ extension its own footer cell and with it the left/right comparison against the body. The perimeter
1898
+ is held mechanically by `scripts/check-grid-colspan-perimeter.mjs`, which works on the AST (the
1899
+ token also appears in a comment and in an error string, where a grep would count it), admits the two
1900
+ markers only in that file, requires at least ONE admitted extension in total — zero would make the
1901
+ gate vacuous — counts the `<thead>` extensions separately as the R12 MECHANISM and goes red if they
1902
+ drop to zero, and asserts that the token appears ZERO times in `parts/grid-row.tsx`, where P9's
1903
+ criterion is a grep and the term is absent by design.
1904
+
1905
+ - **Group rows are exactly as tall as data rows.** That keeps the total height a product of
1906
+ constants, which is what P3's verdict requires (never `measureElement`). A level reads from the
1907
+ **indent** and the text weight, not from the height.
1908
+ - **A group's key is its path**, joined with U+001F — not the value, or `["country","city"]` would
1909
+ merge the French Nice with the Spanish one and opening either would open both.
1910
+ - **Expansion state records the exceptions**: the map holds `false` for collapsed groups and nothing
1911
+ else, so a group nobody touched is **open**. The other way round, a freshly mounted grid would
1912
+ show no data at all.
1913
+ - **Expanding is a view action.** `view/group-expansion` has no path to the emitter, so "expanding
1914
+ does not refetch" is unrepresentable rather than merely unimplemented.
1915
+
1916
+ #### Scroll compensation, and its declared limit
1917
+
1918
+ Opening a group that sits **above the viewport** inserts rows above what you are reading, so
1919
+ everything slides down. The grid raises `scrollTop` by exactly the height that appeared, in a layout
1920
+ effect, before the browser paints.
1921
+
1922
+ How many rows appear is **not** the group's `leafCount`: it depends on which descendants are
1923
+ themselves open, and each keeps its own state. It is measured by flattening with the next state and
1924
+ subtracting the lengths — exact by construction rather than by a formula to keep in sync.
1925
+
1926
+ ⚠️ **Compensation only applies when the expanded group is above the viewport**, and that is a limit,
1927
+ not an oversight: if the group is **visible**, the new rows appear between it and whatever you are
1928
+ reading further down, and there is no `scrollTop` that holds both still — keeping the lower row fixed
1929
+ would push the just-opened group off the screen, which is the opposite of what the gesture asked for.
1930
+
1931
+ ⚠️ **It needs a BOUNDED viewport — and from Fase 6 a grouped grid always has one.**
1932
+ Compensation corrects the scroll container's `scrollTop`, so it does nothing at all when the grid has
1933
+ no bounded height. **This paragraph used to describe a hole and now describes the fix**: P9 wrote that
1934
+ `<AppDataGrid columns={columns} data={rows} groupBy={["country"]} />` — a legitimate and minimal
1935
+ call-site — grew with its content, let **the page** scroll, kept `viewport.scrollTop` at `0`, and
1936
+ therefore expanded and collapsed with no compensation whatsoever. Fase 6 added `groupBy` as the fourth
1937
+ term of `viewportIsBounded` (`app-data-grid.tsx`), so that call-site now takes the default
1938
+ `viewportHeight` of `480`, scrolls its own container, and compensates.
1939
+
1940
+ The reason came from a different criterion and is worth writing down, because it is what makes the
1941
+ change more than a convenience: "changing density never shows FEWER rows" is only measurable **inside a
1942
+ frame**. A grid that grows with its content has no frame, so that promise is vacuously true on it.
1943
+
1944
+ ⚠️ **The second reason this paragraph used to give has been RETIRED, and it is named here rather than
1945
+ quietly dropped.** It read: _and the same is true of "the selection bar recomposes the height instead
1946
+ of pushing the content down"_. On **2026-09-03** the bar moved **above** the scroll area, to the
1947
+ mockup's order, so the table band **does** get pushed down — by exactly one frame addend
1948
+ (`gridRowHeight[density] + gridFrameGap`). What survives, and is what criterion 7 actually measures, is
1949
+ that the **viewport does not shrink** and no data row is lost: the FRAME grows, the window does not
1950
+ narrow. `viewportIsBounded` keeps `groupBy` for the **first** reason above, which is untouched — so the
1951
+ retirement is not a veto on that change.
1952
+
1953
+ ⚠️ **What this changes for existing grouped call-sites**: one that passed `groupBy` and no
1954
+ `viewportHeight` moves from "as tall as its content, the page scrolls" to "480px, the container
1955
+ scrolls" — with a sticky header and Radix's scrollbar lanes appearing. Pass an explicit
1956
+ `viewportHeight` to choose a different frame. Compensating the page instead of the container remains a
1957
+ different feature, not a bug fix.
1958
+
1959
+ #### Declared limits
1960
+
1961
+ - **Closing a group you are reading _inside_ lands you on that group's row.** Compensation has two
1962
+ named modes, because these are two different situations for the reader and not two branches of one
1963
+ `if`. `sopra` — the group row is above the viewport — is not enough on its own: the group's
1964
+ _descendants_ can cross the viewport, so closing may remove the very rows you were looking at. The
1965
+ discriminator is arithmetic, from the row count and never from a measurement: closing removes
1966
+ `k = −delta` rows occupying indices `indice+1 … indice+k`, and you are reading _inside_ when
1967
+ `primaVisibile ≤ indice + k`.
1968
+ - `tieni-ferma-la-vista` — everything that disappears is above what you are reading. The row under
1969
+ your eyes still exists afterwards, so it is held still: target `scrollTopPrima + delta × rowHeight`.
1970
+ - `atterra-sul-gruppo` — you were reading inside. The rows under your eyes are gone, so "hold the
1971
+ observed row still" has no referent and the arithmetic that attempts it runs below zero (measured:
1972
+ target −1296, browser clamped to 0, i.e. back to the top of the list). Instead the target is
1973
+ `indice × rowHeight`: you land on the group row, the object you just acted on. Measured on the
1974
+ two-level dataset, closing a mid-list group from inside: old target 684, browser clamp 0, actual
1975
+ **1440** — the group's own row, flush under the sticky header.
1976
+
1977
+ Both modes are pinned by specs, and each spec fails under the other mode's fault.
1978
+
1979
+ The target can never overshoot in the other direction. The browser keeps
1980
+ `scrollTopPrima ≤ bottomBefore`, and closing `k` rows lowers the bottom by exactly `k × rowHeight`,
1981
+ so `target = scrollTopPrima − k·h ≤ bottomBefore − k·h = bottomAfter`. Measured across four
1982
+ scenarios; `target > bottomAfter` did not occur in any.
1983
+
1984
+ **This used to be a defect and is no longer one.** The compensation was **relative**
1985
+ (`scrollTop += delta × rowHeight`) inside a layout effect, which runs after React has already
1986
+ mutated the DOM — and the browser clamps `scrollTop` at the instant of that mutation. Opening never
1987
+ showed it (the list only grows, nothing to clamp); closing shrank the list first, so the `+=`
1988
+ subtracted the delta from an already-clamped value, a second time. Measured on a 40-group dataset
1989
+ at `scrollTop` 900: expected 900 — reachable, the shortened list bottoms out at 992 — actual
1990
+ **344**, 556px adrift, over fifteen rows. No layout hook can observe an untruncated `scrollTop`, so
1991
+ no relative version of that code was correct; the absolute target is immune by construction.
1992
+
1993
+ - **One height for every row.** Group rows could legitimately have their own — the criterion allows
1994
+ it, provided the value is exact per index — but that would cost a variable-pitch virtualizer to buy
1995
+ a few pixels of breathing room.
1996
+ - **Groups are ordered by first appearance**, not alphabetically: the rows arrive already sorted by
1997
+ whoever clicked a header, and reordering the groups independently would ignore that sort.
1998
+ - **Hiding the grouping column does not dismantle the groups.** Grouping is a property of the
1999
+ _data_, not of what is rendered, so the values are read from the original row rather than through
2000
+ the (already pruned) column registry — and the label, count and disclosure control move to the
2001
+ first visible column. Hiding a column is a view choice; it must not take away the only way to
2002
+ navigate the groups that same view still shows.
2003
+ - **A `groupBy` entry naming a column that does not exist is dropped**, not rendered as an anonymous
2004
+ group. A typo used to produce one label-less, count-less, control-less row that swallowed every
2005
+ data row underneath it — a state the user could not get out of.
2006
+ - **`paginated` and `groupBy` are mutually exclusive in the type**, for the same reason server
2007
+ grouping is: see the `paginated` row in the props table.
2008
+ - **`viewportHeight` now applies whenever it is passed explicitly**, not only under `virtualized` /
2009
+ `infinite`. Three special cases for the same property were an list that kept growing; the honest
2010
+ rule is that a call-site asking for a bounded viewport gets one.
2011
+
2012
+ ### Page selection: the bar, the status line and the group tri-state (Fase 6)
2013
+
2014
+ **The selection is page-only, and that is a signed decision, not a limitation waiting to be lifted.**
2015
+ There is no "select all 128 rows" link and no promotion to result scope: `GridSelectionSlots["mode"]`
2016
+ is a union with a single member so that adding one would be a compile error. The header checkbox names
2017
+ that scope in **both** bags — `selectAll` is «Select all rows on this page» / «Seleziona tutte le righe
2018
+ della pagina» — because a checkbox that promises more than `toggleAllRows` performs is the
2019
+ "128 records deleted believing them 10" failure.
2020
+
2021
+ **The bar does not exist while nothing is ticked.** Not "renders with height 0": the frame is a
2022
+ `flex flex-col gap-2`, so an always-rendered wrapper costs 8px of gap even when it measures zero —
2023
+ one data row, for ever. It is a **sibling of the scroll area**, in flow, outside the `<table>`: inside
2024
+ the viewport it would eat a row without the height formula knowing, and `position: absolute` would
2025
+ paint over the content instead of recomposing it. **With a bounded frame** the frame grows and the
2026
+ viewport does not shrink — see the declared limit below, which is where that stops being true.
2027
+
2028
+ ⚠️ **AND IT SITS ABOVE THE SCROLL AREA, NOT UNDER IT — changed on 2026-09-03.** The order is
2029
+ toolbar → filter band → **selection bar** → table band, which is the mockup's
2030
+ (`AppDataGrid.mockup.stories.tsx` renders `CompleteSelectionBar` between `CompleteFilterSummary` and
2031
+ the table band). The two failure modes named just above are both **internal** to the viewport, so the
2032
+ signed reason of 2026-09-02 establishes that the bar must live **outside** the viewport — it never
2033
+ established that it must live **under** it; the two questions had been fused into one. Above or below
2034
+ is only the line the tag is written on: there is no `order-*`, no `flex-col-reverse`, no `position`,
2035
+ no `mt-auto`, and `gridFrameHeight` is blind to order because one more flow child is one more gap
2036
+ either way.
2037
+
2038
+ ⚠️ **THE PRICE, AND IT IS A PROMISE WITHDRAWN.** "The selection bar recomposes the height instead of
2039
+ pushing the content down" is **no longer true in its second half**: with the bar above, the table band
2040
+ **translates down** by exactly `gridRowHeight[density] + gridFrameGap`. Measured in the
2041
+ `BarraSopraLaBandaTabella` scene at `comfortable` density: the bar's `top` is 125 and the `<table>`'s
2042
+ is 181, a 56px gap that is 48 + 8. What is kept is the half criterion 7 measures — `viewport.clientHeight`
2043
+ unchanged and no visible data row lost — and `BarraCompareERicompone` now asserts the **translation to
2044
+ the pixel** instead of the first row's immobility, which is a strictly stronger assertion: immobility
2045
+ was green both when the bar recomposed and when it painted over the content from below.
2046
+
2047
+ ⚠️ **The mockup's `role="status"` did NOT come along.** `CompleteSelectionBar` is
2048
+ `role="status" aria-live="polite" aria-atomic="true"`; the bar is `role="group"` + `aria-labelledby`,
2049
+ and that stays. "The mockup becomes the baseline" applies to the **position**, not to the role: a
2050
+ second live region would announce twice, not more, and `UnaSolaRegioneLive` counts
2051
+ `[role="status"],[role="alert"],[aria-live]` per grid precisely to catch it.
2052
+
2053
+ **Two height formulas, because they measure two different boxes.** ⚠️ **Both are internal to the
2054
+ library — they are not exported from the package**, so a consumer cannot call them; they are described
2055
+ here to explain how the grid sizes itself, not as an API to import.
2056
+ `gridViewportHeight({rowCount, density, hasFooter, hasFilterRow, headerLevels})` returns the
2057
+ **scroll box** — that is the number the `viewportHeight` prop wants.
2058
+ `gridFrameHeight({...the same five, plus hasSelectionBar})` returns the viewport **plus the in-flow
2059
+ bands beside it**, `gridFrameGap` included, because when the bar appears the frame gains one flow
2060
+ child and therefore one more gap. ⚠️ **«The same five» is load-bearing and used to be a lie in the
2061
+ code**: `GridFrameHeightInput` INHERITS the viewport's fields, but the function destructured only
2062
+ three of them and forwarded only those — so whoever passed `hasFilterRow` (and, later,
2063
+ `headerLevels`) to the frame believed they had lit the addend and had not, with no error anywhere.
2064
+ `adg-m1` repaired it in the same commit that added `headerLevels`, for exactly this reason: an
2065
+ inherited field that is silently dropped is the same defect twice.
2066
+
2067
+ ⚠️ **From `adg-m3` there is a THIRD option, and for a stretched layout it is the right one: pass nothing.**
2068
+ An unbounded viewport now carries `min-h-0 grow`, so inside a parent that HAS a definite height the scroll
2069
+ area takes the leftover by itself and no formula has to predict it — which is the case the two formulas
2070
+ could never serve well, because the caller would have to know the frame's pixels before layout. The formulas
2071
+ stay the answer for the opposite case: a grid that must DECLARE its own height (`virtualized`, `infinite`,
2072
+ `groupBy`, or any bounded box), where `viewportHeight` is not optional.
2073
+
2074
+ ⚠️ **THE SIGNATURE ABOVE USED TO OMIT `hasFilterRow` AND `headerLevels`, AND THAT OMISSION WAS THE
2075
+ MOST DANGEROUS LINE IN THIS FILE.** The remedy for a short viewport lives **entirely in the caller's
2076
+ hand**: the component never calls `gridViewportHeight` — `viewportHeight` is a prop that whoever
2077
+ mounts the grid computes. So anyone writing the next call site from that truncated signature
2078
+ **reproduces the 64px defect**, and no gate stops them: `scripts/check-grid-props-documented.mjs`
2079
+ reads `app-data-grid.types.ts`, while `GridViewportHeightInput` lives in `grid-tokens.ts`. The
2080
+ defect's mode of birth was silence, and a truncated signature here is how it comes back. Verified
2081
+ against the source destructuring, not against a report of it.
2082
+
2083
+ ⚠️ **`headerLevels` is derived, never a literal:** `gridHeaderDepth(columns)` — the same function
2084
+ `buildGridHeaderMatrix` uses to decide how many `<tr>` to render, so the number of levels the
2085
+ formula reserves and the number the header renders cannot drift apart. It is optional and defaults
2086
+ to `1`. **It does NOT cover the Zero Row**: that row is a fourth `<tr>` but its height is a separate
2087
+ addend, `hasFilterRow`, because it is `gridFilterRowHeight` and not `headerHeight`. Passing
2088
+ `headerLevels: 4` to account for it would reserve the wrong number of pixels.
2089
+
2090
+ ⚠️ **`gridFrameHeight` is not an absolute budget.** It excludes the root's `p-2` and `border`, the
2091
+ toolbar, and the paginator. Measured: in the `BarraCompareERicompone` scene the root renders **604px**
2092
+ while the function returns **522** — the missing 82 are border 1×2 + padding 8×2 + toolbar 56 + one gap.
2093
+ Reserving 522px in a fixed-height panel would clip the grid. Use it for the **delta** the band costs,
2094
+ not as the component's height.
2095
+
2096
+ ⚠️ **They used to be one function, and the single signature was the defect.**
2097
+ `gridViewportHeight` accepted `hasSelectionBar` and in that case stopped returning a viewport — the
2098
+ name promised the window and the value was something else, while the call sites passed the result
2099
+ straight to the `viewportHeight` prop. Anyone lighting the flag would have **shortened** the scroll box
2100
+ believing they were enlarging the frame. It was also 8px short, since the gap was never counted. The
2101
+ split makes the mistake **fail to compile**, for literals _and_ for already-typed values:
2102
+ `hasSelectionBar` lives on `GridFrameHeightInput` and is pinned to `?: never` on
2103
+ `GridViewportHeightInput`, so passing the wider type to the narrower function is an error rather than a
2104
+ silently dropped field.
2105
+
2106
+ ⚠️ **Declared limit — in fullscreen the band is paid by the viewport, and one data row can go.**
2107
+ This is a **product decision taken on 2026-09-02**, not an open defect, and **moving the bar above on
2108
+ 2026-09-03 does not touch it**. With a bounded frame the root is as tall as its content, so the bar
2109
+ makes the **frame** grow and the visible data row COUNT stays put — that is where criterion 7 was
2110
+ written, and it holds there (the table band does translate down; the window does not narrow). In fullscreen the root is `fixed inset-0`:
2111
+ the frame **is** the window and cannot grow by a pixel, so the band is paid by the only elastic child,
2112
+ the scroll area (`min-h-0 flex-1`). Measured: `viewport.clientHeight` **820 → 776** and visible data
2113
+ rows **22 → 21** at `default` density (36px band + 8px gap).
2114
+
2115
+ ⚠️ **`adg-m3` opens a THIRD case, and it is DERIVED from the flex rules rather than measured in a browser:**
2116
+ outside fullscreen, a grid with no `viewportHeight` whose PARENT constrains its height (the consumer's
2117
+ `className="flex min-h-0 flex-1"`) now has an elastic scroll area too (`min-h-0 grow`), so there the band is
2118
+ paid by the viewport exactly as in fullscreen instead of making the frame grow. In a scene whose parent has
2119
+ no definite height — every story of the lane that omits the prop — `flex-basis: auto` keeps the root growing
2120
+ with its content, so the sentence above and criterion 7 hold unchanged. Nobody has re-measured the row count
2121
+ in the constrained case; it is stated as a consequence of the classes, not as a number.
2122
+
2123
+ The two rejected alternatives, each for its own reason. **Overlaying** the bar in fullscreen loses no
2124
+ row from the count but **covers** the last one while the selection is active — criterion 7 would stay
2125
+ green while saying something false — and it is a second geometry of the bar to keep aligned with the
2126
+ first. **Always reserving** the band moves nothing, but contradicts criterion 1 ("at zero selection it
2127
+ occupies **no** height") and leaves an empty band at rest for the grid's whole life.
2128
+
2129
+ The number is pinned by the `BarraASchermoIntero` spec, so it cannot get worse in silence: it asserts
2130
+ that in fullscreen the frame does **not** grow while the viewport shrinks by **exactly** the band plus
2131
+ the gap, and that unticking returns it to the pixel. The spec reads the delta from the tokens instead
2132
+ of hard-coding `776`, which would be green or red depending on the host's window.
2133
+
2134
+ **The bar computes nothing.** Its count is `engine.selectedPageRowIds.length` — the ticked ids the
2135
+ current row model still knows — and its totals are `engine.selectionCells`, i.e.
2136
+ `aggregateCells(selectedPageRowIds)`, which reaches `applyGridAggregate` through the same
2137
+ `cellsForRows` as the `<tfoot>`. With the whole page ticked, bar and footer read the same characters by
2138
+ construction rather than while two formulas agree.
2139
+
2140
+ **Where "0 rows selected" is said.** Nowhere on screen — the bar is absent — so it is said by the
2141
+ **single live region**, which from this phase composes **four clauses in one string**: sort priority
2142
+ (from the same `sortIndex`/`sortCount` that name the header buttons), the row count (from
2143
+ `gridResultScope.totalLabel`, the single source — never the bare `total`, which would print `1284`
2144
+ where the paginator shows `1.284`), active filters, and the selection. One string and one text node,
2145
+ with `aria-atomic="true"`: four `<span>`s would pass four `toContain` while a screen reader read the
2146
+ sentence in pieces. The bar carries `role="group"` + `aria-labelledby` — **never a second
2147
+ `role="status"`**.
2148
+
2149
+ ⚠️ **A defect this phase found and fixed while wiring that string.** The live region's
2150
+ "more rows loaded" branch was guarded only by `requestCount > 1`, and `requestCount` is **seeded at
2151
+ mount** with `Math.ceil(data.length / pageSize)` — 13 for 128 rows on pages of 10. ⚠️ **WHICH `pageSize`,
2152
+ made explicit on 2026-09-09**: the LIVE one, `initialState?.pagination?.pageSize ?? pageSize`, the same
2153
+ expression the state slice uses. Until then the seed divided by the `pageSize` **prop** alone while the
2154
+ slice used the live value, so a grid passing `initialState.pagination.pageSize` different from the prop
2155
+ seeded a counter on the wrong divisor and the first emitted `pageIndex` skipped or repeated pages. Nailed
2156
+ by `app-data-grid-fix-motore.stories.tsx :: IlSemeDelContatoreUsaLaTagliaViva`. So any grid with more
2157
+ than one page of data announced «More rows loaded» at rest, to someone who had loaded nothing. The
2158
+ branch is now guarded by `infinite !== undefined` as well, the same guard already declared for
2159
+ appended skeletons.
2160
+
2161
+ **The group row's checkbox works on a collapsed group**, which is the whole point: the leaves of a
2162
+ closed group are not in `bodyRows` and have no DOM node, so nothing can be derived from the rendered
2163
+ rows. `GridGroupRowView.leafIds` is filled on the tree **above** the flattener's
2164
+ `if (!expanded) continue`, descending into `children` **before** reading `leaves` (which only exists at
2165
+ the last level of `groupBy`), and indexed by `path` — never by label, which collides between namesakes
2166
+ under different parents. The tick then goes through **`engine.toggleRows(leafIds, selected)`**, one
2167
+ mutation: N `toggleRow` calls in the same tick each copy the map from the closure, so only one
2168
+ survives, and a 50-row group would end up selected as 1 while the header checkbox still showed
2169
+ `indeterminate` — green for the wrong reason.
2170
+
2171
+ **New i18n keys** (all optional in `GridI18n`, all required in `defaultGridI18n`, so the per-leaf
2172
+ fallback always exists): `selection.selectedOne` / `selectedMany` / `outOf` / `onPage` / `dismiss` /
2173
+ `deselectAll`, `status.sortedBy` / `filterActiveOne` / `filterActiveMany`, and
2174
+ `grouping.selectGroup`. `outOf` is **not** `pagination.of`: «Rows 1-10 **of** 128» and «2 rows selected
2175
+ **of** 10» are one word in English and two in Italian («di» / «su»). Unit and participle live in a
2176
+ single key for the same reason. One changed value: `selectAll` (see above).
2177
+
2178
+ ### Selected row: tint and left bar (R19)
2179
+
2180
+ A selected row is painted with an **accent tint** plus a **vertical bar on the row's left edge**. Both come
2181
+ from tokens in `grid-tokens.ts` — `gridSelectedRowTint`, `gridSelectedRowBarShadow`,
2182
+ `gridSelectedRowBarWidth` — and both reach the DOM as **inline styles**, never as utility classes.
2183
+
2184
+ ⚠️ **The defect this replaced was a single colour.** Hover and selection both resolved to the same muted
2185
+ surface — measured `rgb(245, 245, 245)` in the light theme — so a row under the pointer was indistinguishable
2186
+ from a checked one. A state and a transit painted alike; the checkbox was the only witness of selection.
2187
+
2188
+ ⚠️ **The tint is a mix, not an opacity.** «Low-opacity accent» describes the look, not the implementation: a
2189
+ semi-transparent background on a **pinned** cell would let the columns scrolling underneath show through.
2190
+ `gridSelectedRowTint` is `color-mix(in srgb, var(--d-primary) 24%, var(--d-background))` — the same appearance
2191
+ with an opaque colour. Consumers who re-theme `--d-primary` or `--d-background` re-theme the tint with it.
2192
+
2193
+ ⚠️ **The tint is on every cell, not only on the `<tr>`.** A background declared on the row alone shows through
2194
+ the transparent cells and **not** through the pinned ones, which are opaque by necessity: the selected row
2195
+ came out tinted only in the scrolling columns, cut exactly on the pinning boundary. With the tint on every
2196
+ cell, the computed `backgroundColor` of the checkbox cell, of a pinned cell and of a scrolling cell of the
2197
+ same row is the **same value**.
2198
+
2199
+ ⚠️ **The bar is a `box-shadow` layer, and it is composed — never assigned on its own.** Under
2200
+ `border-collapse: collapse` a declared border belongs to the *table's* border grid, so on a sticky pinned cell
2201
+ it would scroll away with the body; and a pseudo-element would need new utility classes, i.e. a value in the
2202
+ scales of two Tailwind majors. The layer is composed by `gridPinnedOffsetStyle` together with the pinned-edge
2203
+ shadow of R20 and the header group separator: with a one-column pinned block and no checkbox, the row's first
2204
+ cell **is** the block's boundary, and two `box-shadow` assignments would silently overwrite each other.
2205
+
2206
+ ⚠️ **The bar sits on the row's left edge, and never on the same strip as a boundary.** On a scrolled grid it
2207
+ stays at the viewport's visible left edge, because it rides the first pinned cell.
2208
+
2209
+ ⚠️ **Where the two signals collide, the boundary wins and the bar is not emitted — a declared refusal.**
2210
+ `gridPinnedRightEdgeShadow` is derived from the left one by dropping the minus sign, so **both** its layers
2211
+ have a **positive** offset, i.e. they paint on the box's **left** side — the same side as the bar. When a row
2212
+ has no checkbox column and its **first** declared column is `pinned: "right"`, that first cell is both the
2213
+ start of the row and the right block's boundary: the bar's 3 opaque px covered the boundary's 1px hairline
2214
+ entirely, and which signal you saw was decided by the order of a `push`. `gridPinnedOffsetStyle` now emits the
2215
+ bar **only when no other layer occupies the box's left side** (the right-block boundary, or the header group
2216
+ separator). The boundary keeps the pixel because it states a fact about the *table* — where content starts
2217
+ scrolling — while selection stays readable regardless: the tint is on every cell and does not depend on the
2218
+ bar. Measured by `BarrettaSenzaSpuntePinnataADestra`; the non-colliding case, where the same cell is both the
2219
+ row's first and the **left** block's boundary, keeps both signals and is measured by its twin.
2220
+
2221
+ ⚠️ **Both themes are measured, and the dark one is the half that can fail.** `--d-primary` is `#64c2d1` in
2222
+ both; the backgrounds diverge. Measured at 24%:
2223
+
2224
+ | theme | rest background | hover | selected tint | max channel gap vs hover | chroma (B−R) |
2225
+ | ----- | --------------- | ----- | ------------- | ------------------------ | ------------ |
2226
+ | light | `rgb(245,245,245)` | `rgb(245,245,245)` | `rgb(210,233,236)` | 35 | 26 |
2227
+ | dark | `rgb(20,20,20)` | `rgb(46,46,46)` | `rgb(39,62,65)` | 19 | 26 |
2228
+
2229
+ In the dark theme the lightness gap against hover is small; what keeps the tint readable there is **chroma** —
2230
+ it is blue-ish where hover is grey. At 12% the dark tint would have been `rgb(30,41,43)`, i.e. **darker** than
2231
+ hover with 13 points of chroma, while looking fine in the light theme: that is the light-only calibration the
2232
+ criterion exists to reject.
2233
+
2234
+ ⚠️ **A pre-existing, unrelated finding measured while doing this.** In the light theme `--d-muted` and
2235
+ `--d-background` both resolve to `#f5f5f5` (`surface--secondary` and `surface--background` point at the same
2236
+ `neutral--125` step), so the row's **hover is invisible in the light theme** — before R19 and independently of
2237
+ it. `TintaSelezionataDiversaDallHover` asserts that equality, so the day the two tokens diverge the story goes
2238
+ red and the reader finds the reason there.
2239
+
2240
+ ⚠️ **`:hover` cannot be engaged in the spec runner.** `userEvent.hover` synthesises pointer events; the
2241
+ `:hover` pseudo-class depends on the real mouse position, which a synthetic event does not move — measured:
2242
+ after `userEvent.hover(row)` the row still reads `rgba(0, 0, 0, 0)`. The stories therefore read the hover
2243
+ **colour** from a probe carrying the row's own `hover:` utility with the variant stripped, derived from the
2244
+ row's class list rather than written by hand.
2245
+
2246
+ ### Row header, table name, bypass links (Fase 2, fetta E)
2247
+
2248
+ Three things the grid declares about itself, none of which is visible on a still screenshot.
2249
+
2250
+ **One `<th scope="row">` per row.** Declare it on the column that identifies the row
2251
+ (`rowHeader: true`). Without it, a screen reader reading `199.200` on a 14-column table announces the
2252
+ **column** (“Importo”) and not the **row** — it loses the only datum that makes the figure usable.
2253
+
2254
+ - **At most one column may declare it.** A second one throws from the column registry: two
2255
+ `<th scope="row">` in one row make it announce itself with two different names, and nothing else
2256
+ would report it.
2257
+ - **Opt-in, never derived.** “The first column” fails twice: it may be the column that _groups_ (its
2258
+ cell is then muted, so the row header would carry no name) and it may be hidden by
2259
+ `columnVisibility`.
2260
+ - **The cell count per row does not change.** The identity column changes _element_, not position —
2261
+ `<colgroup>`, the leaf `<th>`s and the row keep corresponding.
2262
+ - **The author style of `th` is reset.** `th` renders `text-align: center; font-weight: 700`, so the
2263
+ column would visibly change on the switch from `<td>`. The lane resets the weight with `font-normal`;
2264
+ the alignment is already covered by `gridAlignClass`, the single derivation, which always emits
2265
+ `text-left` or `text-right`.
2266
+ - **Declared limits.** If the row-header column is **hidden** the row has no header (the grid does not
2267
+ elect another one). If that column is also a `groupBy` column the cell is muted, and the grid renders
2268
+ a `<td>`: an empty `<th scope="row">` is a row header with no name.
2269
+
2270
+ **`aria-colcount`** is the number of `<col>` **minus the filler**. The filler
2271
+ (`[data-grid-col-filler]`) exists to absorb the slack of `table-layout: fixed` and has **no cells**, so
2272
+ declaring it would announce a column that no row contains. There is deliberately **no `aria-colindex`**
2273
+ on the cells, unlike the `aria-rowcount`/`aria-rowindex` pair: rows are virtualized, so a row must say
2274
+ where it is; columns never are, so every declared cell is in the DOM and the browser counts its position
2275
+ itself. `aria-rowcount` is unchanged — its two readings are declared in
2276
+ `docs/analysis/datatable-v2/PIANO-COMPLETAMENTO.md` § «`aria-rowcount` — due letture».
2277
+
2278
+ **A name on the `<table>`**, from `i18n.table.label`. Without it, for someone navigating by regions this
2279
+ is “table” and nothing else — and with two tables on the page (the coexistence the migration needs),
2280
+ two indistinguishable entries in the same list.
2281
+
2282
+ **Two bypass links** (WCAG 2.4.1). Between the search box and the first data row the grid puts the
2283
+ density controls, the full-screen toggle and then three header levels in which every leaf carries a sort
2284
+ button, a menu trigger and a focusable resize handle — dozens of tab stops with no way past them. The
2285
+ two links are the way past: the first goes to the rows, the second to the header (the first is needed
2286
+ more often, so it comes first). They live **outside the `<thead>`** — the criterion is “no tab stop
2287
+ added to the header” — and their targets are the `<thead>` and the `<tbody>`, both `tabindex="-1"`:
2288
+ programmatically focusable, never a tab stop. The ids come from a per-instance `useId()`, **sanitised**,
2289
+ because `useId()` produces characters that must be percent-encoded in a URL fragment and an `href="#…"`
2290
+ would not find its target back.
2291
+
2292
+ ### Truncation is recoverable where it truncates, and only there (Fase 2, fetta E)
2293
+
2294
+ When the grid renders a cell whose content is **a string**, it wraps it in a detector:
2295
+
2296
+ - if the text **overflows** its column, the cell gets a focusable target (`[data-grid-cell-expand]`),
2297
+ a tooltip, and the full value in an `sr-only` node;
2298
+ - if it **fits**, the cell gets a bare span (`[data-grid-cell-text]`) — **no target, no tooltip, no tab
2299
+ stop added**. This half is what gives the other one a meaning: a tooltip on every text cell costs one
2300
+ tab stop per rendered cell.
2301
+
2302
+ **The rule is about the rendered content, not the column config.** A `cell:` returning a string
2303
+ (`euro(value)`) is covered; a `cell:` returning a `<Badge>` or a progress bar brings its own box model
2304
+ and the grid does not touch it. Wrapping arbitrary nodes would change the layout of markup the grid did
2305
+ not write.
2306
+
2307
+ **A row stays pasteable.** Selecting a row and pasting it must produce **one** spreadsheet row. That is a
2308
+ property of the _rendered box model_, measured, not of the class list:
2309
+
2310
+ - every box the grid adds inside a cell is **inline-level** (`inline-block`, `inline-flex`); a
2311
+ block-level box makes the selection serializer emit a line break, and a 8-field record becomes 8
2312
+ misaligned rows (measured: **7 line breaks**);
2313
+ - the `sr-only` node carries **`select-none`**; without it the full value appears **twice** in the paste
2314
+ (measured), because `sr-only` content is still serialized;
2315
+ - the visible text is `aria-hidden` so the accessible name is the value **once**, not twice.
2316
+
2317
+ ⚠️ **There is deliberately no static gate for this.** The rendered `display` of a cell is not derivable
2318
+ from a grep over Tailwind classes, so a script would certify itself. It is measured on the DOM, in
2319
+ `app-data-grid-casi-limite.stories.tsx`.
2320
+
2321
+ ⚠️ **Declared limit — the guarantee is per data row, not per selection.** A **group row** (P9) renders
2322
+ its grouping value inside a `flex` box, which is block-level, so selecting a group row emits line breaks
2323
+ (measured in Chromium: **4** for one group row). It was left as it is, because it does not touch the
2324
+ guarantee: selecting one **data** row while grouping is active still yields **0** line breaks (measured),
2325
+ and a selection that spans several rows already emits one break per `<tr>` by definition — 20 data rows
2326
+ plus 2 group rows measured 27 breaks against the ~22 that the row count alone accounts for. So a group
2327
+ row is not pasteable as a single record either way. "Copy a row, get a record" is a promise about a data
2328
+ row.
2329
+
2330
+ ⚠️ **`TruncatedCell` is not usable here, and this is measured.** It renders a `<button>` wrapping a
2331
+ `<div class="flex min-w-0 flex-1">` that none of its props can override (`wrapperClassName` → the
2332
+ button, `className` → the inner text div, the flex box between them → nothing). Substituted into **one**
2333
+ cell, the pasted record went from **0 line breaks / 12 tabs to 2 / 11**. It also enables its trigger
2334
+ whenever the cell has any content, i.e. one tab stop per rendered cell — the opposite of this criterion.
2335
+
2336
+ ### Row header and row menu (Fase 2, fetta E)
2337
+
2338
+ `createGridRowMenuColumn({ id, header, pinned, width, rowMenu })` puts the `…` menu on a column, and
2339
+ **the grid renders it** — the caller only declares the items:
2340
+
2341
+ ```tsx
2342
+ createGridRowMenuColumn<Commessa>({
2343
+ id: "azioni",
2344
+ header: "Azioni",
2345
+ pinned: "right",
2346
+ width: 100,
2347
+ rowMenu: (row) => [
2348
+ { id: "apri", label: `Apri ${row.codice}`, onSelect: () => open(row.id) },
2349
+ {
2350
+ id: "duplica",
2351
+ label: "Duplica",
2352
+ onSelect: () => dup(row.id),
2353
+ disabled: row.chiusa,
2354
+ },
2355
+ {
2356
+ id: "elimina",
2357
+ label: "Elimina",
2358
+ destructive: true,
2359
+ onSelect: () => del(row.id),
2360
+ },
2361
+ ],
2362
+ });
2363
+ ```
2364
+
2365
+ The items are **data, not JSX**, and that is what lets the grid guarantee the four properties that go
2366
+ wrong silently:
2367
+
2368
+ 1. **`modal={false}`.** A modal dropdown puts `aria-hidden` on the rest of the page and locks body
2369
+ scroll: with both tables mounted, opening a row menu would hide the old `DataTable` from assistive
2370
+ tech. (Measured: with `modal`, the grid itself ends up inside an `aria-hidden` subtree.)
2371
+ 2. **`align="end"`.** The menu column is pinned right, so a panel aligned to the trigger's left edge
2372
+ leaves the viewport. Measured with `align="start"`: the panel's right edge sat **109px** away from the
2373
+ trigger's, against a budget of 8px.
2374
+ 3. **The z-index at full screen.** `DropdownMenuContent` self-portals to `<body>` at `z-50` while a
2375
+ full-screen grid sits at 110, so without the inline raise from `gridZ.fullscreenOverlay` the menu is
2376
+ painted _under_ the grid.
2377
+ 4. **`data-variant="destructive"`.** Only `DropdownMenuItem` honours it — `CheckboxItem`, `RadioItem`,
2378
+ `SubTrigger` and `Label` have no such channel.
2379
+
2380
+ None of the four is verifiable on a `ReactNode` handed over by the caller.
2381
+
2382
+ **The trigger's accessible name is composed with the row's identity** (`Row actions: PAT-01`), taken from
2383
+ the `rowHeader` column, falling back to the row id. With thirty rendered rows, thirty buttons all called
2384
+ “Actions” are thirty identical entries in a screen reader's controls list: it says how many commands
2385
+ exist and not what they act on.
2386
+
2387
+ `rowMenu` is a **function of the row** because the items depend on the row — a closed job cannot be
2388
+ duplicated. Returning an empty list renders no trigger: a button that does nothing is worse than no
2389
+ button.
2390
+
2391
+ ### The `table` i18n section (Fase 2, fetta E)
2392
+
2393
+ Optional, like every section added after P1, with the fallback from `defaultGridI18n` (English, as every
2394
+ default in this library):
2395
+
2396
+ | Key | Default | Where it shows |
2397
+ | -------------- | ------------------------------ | -------------------------------------------------------------- |
2398
+ | `label` | `"Data grid"` | `aria-label` on the `<table>` |
2399
+ | `skipToRows` | `"Skip to the data rows"` | The first bypass link |
2400
+ | `skipToHeader` | `"Skip to the column headers"` | The second bypass link |
2401
+ | `expandCell` | `"Full text"` | Composed with the value in the truncated cell's `sr-only` node |
2402
+ | `rowMenu` | `"Row actions"` | Composed with the row's identity on the `…` trigger |
2403
+
2404
+ ⚠️ **`i18n` replaces the bag, it does not merge into it.** Passing only `{ table: {...} }` kills the grid
2405
+ on the first render (`i18n.sorting.sort` is undefined) — the non-optional sections have no per-section
2406
+ fallback. Spread the default: `{ ...defaultGridI18n, table: {...} }`.
2407
+
2408
+ ### Saved views, and the migration surface (P10)
2409
+
2410
+ `preset` applies a saved view **in one tick**; `onPresetApplied` lets the consumer realign the pieces
2411
+ P7 lifted above the grid.
2412
+
2413
+ ```tsx
2414
+ const filters = useGridFilters({ columns: toGridFilterColumns(columns) });
2415
+ const [preset, setPreset] = useState<GridViewPreset>();
2416
+ const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
2417
+ const [columnOrder, setColumnOrder] = useState<string[]>([]);
2418
+
2419
+ <AppDataGrid
2420
+ columns={columns}
2421
+ data={rows}
2422
+ preset={preset}
2423
+ // The two capture callbacks: notify-only, restored through `preset.view`.
2424
+ onColumnWidthsChange={(next) => setColumnWidths({ ...next })}
2425
+ onColumnOrderChange={setColumnOrder}
2426
+ // The IN-GRID registry is the only reorder the grid performs — and it renders
2427
+ // only with `toolbar.columns`, no custom `render`, AND a writable visibility.
2428
+ toolbar={{ columns: {} }}
2429
+ filters={filters.badges}
2430
+ columnVisibility={filters.columnVisibility}
2431
+ // ⚠️ REQUIRED HERE, and not only for the checkbox: without it
2432
+ // `canWriteVisibility` is false, the columns trigger is not rendered, and
2433
+ // `onColumnOrderChange` becomes a prop that can never fire.
2434
+ onColumnVisibilityChange={(next) => {
2435
+ for (const [id, visible] of Object.entries(next))
2436
+ if (filters.columnVisibility[id] !== visible)
2437
+ filters.setColumnVisible(id, visible);
2438
+ }}
2439
+ onPresetApplied={(p) => {
2440
+ filters.restore(p.query.filters, p.view.columnVisibility);
2441
+ // ⚠️ RE-SEEDING IS MANDATORY, not courtesy: `view/preset` replaces both
2442
+ // slices wholesale and no capture callback announces it, so without these
2443
+ // two lines the next save would undo the restore in silence.
2444
+ setColumnWidths({ ...p.view.columnWidths });
2445
+ setColumnOrder([...p.view.columnOrder]);
2446
+ }}
2447
+ onQueryChange={fetchPage}
2448
+ />;
2449
+ ```
2450
+
2451
+ **The example used to model a combination that cannot work**, and it is worth naming because it was
2452
+ printed here: it passed `columnVisibility` **without** `onColumnVisibilityChange`, which switches the
2453
+ columns trigger off — so the in-grid registry did not exist, and with it the only reorder the grid
2454
+ performs. Copying that shape and adding `onColumnOrderChange` would give you a prop that never fires.
2455
+
2456
+ **Every field of `GridViewPresetView` now has a capture channel.** `density` through `onDensityChange`
2457
+ (whose only caller is preset application since `adg-c12` — to capture the user's own density you mount
2458
+ `GridDensityControl` yourself and own the state), `columnVisibility` through `onColumnVisibilityChange`,
2459
+ and `columnWidths` / `columnOrder` through the two callbacks above. All four assign into
2460
+ `GridViewPresetView` with no mapping and no cast. The **query** half is not fixed: `onQueryChange` exists
2461
+ only on the server branch (on the client branch it is `never`), and its `GridServerState` shape does not
2462
+ line up with `GridViewPresetQuery` — `computedSorting` carries parallel `sort_by` / `sort_order` arrays
2463
+ where the preset wants `GridSortDescriptor[]`. That mapping is still by hand. Proof of the view
2464
+ round-trip: the stories under «CRITERIO 6» in `CI-CD/AppDataGrid/Preset di vista (P10)`, which count the
2465
+ invocations rather than looking at the table — a callback fired once per `pointermove` of a thirty-step
2466
+ drag and one fired at gesture end render the same table.
2467
+
2468
+ ⚠️ **A malformed stored view is rejected at the door, not halfway through a render — and the grid
2469
+ itself now consults the door.** `readGridViewPreset` validates, and the list is exhaustive because a list
2470
+ that is not is worse than no list: `version`; `id` and `name`; `query` and inside it `search` (a string),
2471
+ `pageSize` (an integer ≥ 1), `sorting` (an array whose every element is `{id: string, desc: boolean}`, with
2472
+ no repeated id), `filters` (an array whose every element is a `GridBadgeFilter` field by field — including
2473
+ `operator` against the vocabulary of **its own `filterType`** and not merely against the 20 names,
2474
+ `filterType`/`columnType` against their closed unions, and every numeric `value` FINITE — with no
2475
+ repeated id); `view` and inside it `density` (a key of `gridRowHeight`), `columnWidths` (a
2476
+ record, every value finite), `columnVisibility` (a record, every value a boolean), `columnOrder` (an array
2477
+ of strings, no duplicates). Anything else throws `GridViewPresetVersionError`, whose **`reason`** names the
2478
+ offending field path (`"query.filters[2]"`, `"view.columnWidths.code"`); `reason` is `undefined` only on the
2479
+ version path.
2480
+
2481
+ **The committed version (`f5dc84cc`) validated FOUR things and nothing inside them** — `isRecord(query)`,
2482
+ `Array.isArray` on the two arrays, `isRecord(view)` — which was true at field granularity and false at
2483
+ element granularity. (An uncommitted intermediate round checked eight fields; an earlier draft of this
2484
+ paragraph said «eight» and quoted a sentence the repository never contained.) Measured: `query.sorting: [null]` threw
2485
+ `TypeError … (reading 'id')` in `toGridServerState` **and** inside the headers `useMemo`, i.e. in render;
2486
+ `query.filters: [null]` threw `TypeError … (reading 'kind')` inside the `querySignature` `useMemo`; a
2487
+ `filterType` outside the vocabulary reached the library's only explicit `throw`
2488
+ (`getGridFilterTypeOptions`) straight from `GridFilterChips`, in render. Those are blank screens, not
2489
+ half-applied views.
2490
+
2491
+ ⚠️ **`found === expected` does NOT mean the payload is healthy.** On a shape refusal `found` is the version
2492
+ that really was there — `1`, because that is true — so `if (e.found !== e.expected) migrate(); else
2493
+ treatAsHealthy();` takes the wrong branch. `reason` is the only discriminator, and the message says in words
2494
+ which of the two happened.
2495
+
2496
+ ⚠️ **The door is stricter than the TYPE**, and a consumer who discovers that from a refusal discovered it too
2497
+ late. `pageSize` is `number` in the type and an integer ≥ 1 here (`0` makes `gridResultScope` report 128
2498
+ pages over 128 rows while TanStack slices zero; `Infinity` raised `RangeError: Invalid array length` and a
2499
+ fat integer killed the process with `heap out of memory`, because the skeleton count IS the live page size).
2500
+ `id` and `name` are `string` in the type and unchecked by any compiler once they come from `JSON.parse` —
2501
+ and this function returns `raw as GridViewPreset`, so tolerating an absent `name` would hand back
2502
+ `name: undefined` typed `string`, while `id` is the store's `Map` key, i.e. a view that saves and never
2503
+ reopens. The elements of `sorting`/`filters` and the values of `columnWidths`/`columnVisibility` were typed
2504
+ and never verified. A badge `value` must be FINITE, not merely a number: measured, `value: NaN` went out as
2505
+ `{"importo":{"operator":"gt","value":null}}` — «greater than null» instead of the saved filter. And an
2506
+ `operator` must belong to **its own column's** vocabulary: `{columnType: "string", operator: "checked"}`
2507
+ passed the 20 names, printed the raw id `"checked"` in the chip, rendered no value editor at all
2508
+ (`getGridFilterValueArity` is `"none"` for it) and shipped a boolean operator on a text column. Every
2509
+ operator the grid itself can capture comes from `getGridFilterTypeOptions`, so the door still accepts
2510
+ everything the grid produces. **Consequence:** a preset built with the typed API but carrying `pageSize: 0`
2511
+ type-checks and does not apply; a preset read out of a JSONB column through `node-postgres` comes back with
2512
+ widths as **strings** (`{code: "260"}`) and loses the whole view — coerce the numbers before the door.
2513
+
2514
+ ⚠️ **It refuses, it does not sanitise**, and the cost is real: ONE width written as a string loses the whole
2515
+ view. That is the price of keeping `onPresetApplied` a signal you can trust — «applied» or «refused», never
2516
+ «applied three quarters». Returning a cleaned COPY would change what the function's name means, would leave
2517
+ the consumer's store dirty forever, and a silent drop is indistinguishable, for the consumer, from a healthy
2518
+ payload.
2519
+
2520
+ ⚠️ **Six declared limits.** (1) The INSIDE of `customFragment` is not validated: the door requires a record
2521
+ and stops. Measured, `{"colonna_inesistente":{"operator":"DROP","value":{"$ne":null}}}` leaves in
2522
+ `computedFilter` byte for byte — a preset carries an **arbitrary filter expression** to the consumer's
2523
+ backend. Not a crash: a different request that succeeds. Validating a recursive shape whose vocabulary
2524
+ belongs to somebody else's backend is the one place where «a second schema to keep aligned with the first»
2525
+ is still a true argument, so **treat a preset as privileged input** if you accept one from a user. (2) A
2526
+ `columnId`, or a `columnOrder` id, that names no column passes — the door is a pure function that never sees
2527
+ the columns. Compare the ids with your own columns after reading if you care. (3) A `kind: "custom"` badge
2528
+ with no `customLabel` passes, because the field is optional in the type — and `customLabel` is the only
2529
+ source of a custom badge's text, so it restores as a nameless chip. (4) `query.search` has no maximum
2530
+ length. (5) A `columnVisibility` that hides EVERY column passes, for the same reason as (2): the door never
2531
+ sees the columns. `view/preset` writes the map wholesale, bypassing the `changeColumnVisibility` guard — the
2532
+ table itself holds (the `visibleTree` floor renders the whole tree), but `useGridFilters` reads the MAP, so
2533
+ `filters.restore(p.query.filters, p.view.columnVisibility)` leaves `activeColumnIds` empty and every column
2534
+ badge is pruned from the chips, the count and the payload while the table shows unfiltered rows. The missing
2535
+ floor is on the map and belongs where a preset is APPLIED. (6) On the LOWER bound of `pageSize` the
2536
+ round-trip is not guaranteed: the door demands an integer ≥ 1, but the reducer seeds the size from the prop
2537
+ with no clamp and `readPreset` copies it verbatim, so `<AppDataGrid pageSize={0}>` captures a view the door
2538
+ then refuses forever. Validate `pageSize` with the door's own predicate (`Number.isInteger && >= 1`) — **not**
2539
+ against `GRID_PAGE_SIZE_OPTIONS`, which only closes the SELECTOR: a grid mounted at `pageSize: 20` is legal
2540
+ and a consumer that drops 20 as «not in the scale» resets the page size on every reload.
2541
+
2542
+ ⚠️ **And the other typed-only doors are NOT covered, which is the claim this paragraph refuses to
2543
+ overstate.** After this change there is no known path **through `preset` or `readGridViewPreset`** for a
2544
+ payload to produce a blank screen instead of the declared error. `filters`, `columnVisibility`,
2545
+ `initialState` and `useGridFilters().restore()` are the same kind of compile-time-only contract the `preset`
2546
+ prop used to be: `filters={JSON.parse(…)}` with `[null]` in it reaches `badge.kind` in the same
2547
+ `querySignature` `useMemo`, with no preset anywhere. If you restore badges from storage, run them through
2548
+ your own check.
2549
+
2550
+ **Autosave is still the wrong default, and the reason is now only in the query half.** The two capture
2551
+ callbacks do **not** fire on preset application, precisely so that a restore cannot trigger a save. But
2552
+ `onQueryChange` _does_ fire on preset application — that is the «restoring emits exactly once» property
2553
+ below — so a consumer that saves on every change would still write back what it just read. Save on an
2554
+ explicit user action.
2555
+
2556
+ **Restoring emits exactly once, not four times.** A preset carries sorting, filter, widths, hidden
2557
+ columns and density; applying them one at a time is the natural way to write it, raises
2558
+ `data-emissions` to 4, and asks the server for the same list four times — three of them for a
2559
+ configuration the user never saw. Watching the table, the two implementations are indistinguishable.
2560
+
2561
+ **And the emitted payload comes from the action, not from the lifted props.** `query/preset` carries
2562
+ the filters and the sorting with it, which is the defect class P7 already paid for once (chips whose
2563
+ filters never reached the payload, because the ref had not seen them yet) excluded here by the shape
2564
+ of the action rather than by attention.
2565
+
2566
+ **What a preset does not save**, each for its own reason: `pageIndex` (returning to page 7 only makes
2567
+ sense if the list is yesterday's, which it rarely is — `pageSize` is configuration and _is_ saved);
2568
+ the grid's internal bookkeeping; `fullscreen` (asked for now, not a preference); and which groups
2569
+ were open (the keys are paths of _values_, which fresh data no longer has).
2570
+
2571
+ **The persistence port is an interface**, and the library ships exactly one implementation —
2572
+ `createInMemoryGridViewStore`. A view that dies on reload and a view that lives on a server and
2573
+ follows the user between two machines are two different products, and choosing between them belongs
2574
+ to the application. Shipping a `localStorage` one would mean this library decides the key, the quota
2575
+ policy, what to do when the browser blocks storage, and what happens when two grids on a page share
2576
+ a key — four product decisions every consumer would inherit without asking.
2577
+
2578
+ **The contract is versioned, and an unknown version is a declared error** — never a half-applied
2579
+ state. Reading the fields you recognise and ignoring the rest leaves a table configured halfway: the
2580
+ sort comes back, the filter does not, and the user is looking at data they believe is filtered.
2581
+
2582
+ #### The migration surface
2583
+
2584
+ P10 also closed the question the plan left open since Fase 0. The codemod's `AUTO` count — files a
2585
+ consumer could migrate by renaming — sat at **38 of 297**, and the report named the blockers: six
2586
+ column builders the lane did not have (`createDateColumn`, `createBooleanColumn`,
2587
+ `createMultiSelectColumn`, …), the server-mode trio (`serverMode`, `serverConfig`, `initialState`),
2588
+ and `createInitialState`.
2589
+
2590
+ They are now on the grid, **name for name and shape for shape**, which is what keeps the codemod a
2591
+ rename instead of a rewrite. Measured after: **145 of 297 files AUTO**, and all six boilerplate
2592
+ patterns of the corpus have AUTO call-sites, up from two.
2593
+
2594
+ `scripts/codemod/apply.mjs` performs the transformation. It refuses to write to any directory that
2595
+ does not carry a `.codemod-throwaway` marker, and there is no flag to force it — a flag to force it
2596
+ is how a guard becomes a note.
2597
+
2598
+ ⚠️ **A rename is not always enough, and the report says which call-sites those are.** Two of the six
2599
+ boilerplate files hand-write column objects instead of using the builders (`accessorFn`, a `meta`
2600
+ block, a `cell` taking an untyped `info`). Those literals satisfied TanStack's `ColumnDef` and do not
2601
+ satisfy `GridColumnDef` — deliberately, because a grid column is built with a builder. They are the
2602
+ `MANUALE` share the report already counts, and they need a person.