Skip to content

DataTable — live demo

This is the real @rozie-ui/data-table-vue package running on this page (VitePress is itself a Vue app). Click a header to sort (shift-click to add a secondary sort), type in the search box to filter, page through the rows, tick the checkboxes to select, drag a column edge to resize, or open the Columns menu to hide one — then watch the two-way bound state below update. The same DataTable component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit. It's built on @tanstack/table-core with no per-framework adapter and a tokenised skin that ships inside the component.

Batteries included — the drop-in components

DataTable is headless by default: it owns state, filtering, grouping, sorting, and editing logic, but hands you the slots and lets you render the chrome. That's the right default for a design system — but it isn't the fastest way to get a good-looking table on screen. So the package also ships opt-in, same-package drop-in componentsFilterText, FilterSelect, FilterNumberRange, GroupBar, EditorText, EditorSelect, DetailPanel — that fill the headless slots so you get filtering, grouping, inline editing, and detail rows with zero custom code. This is the shadcn-style "here's what you get out of the box" moment.

They're tree-shakeable named exports from the same @rozie-ui/data-table-vue package — not a separate -defaults package, not a heavier "batteries" build. Import only the drop-ins you use; the rest never enters your bundle. Each one is presentational only (no engine, no extra deps) and styles itself automatically from the descendant selectors already shipped in themes/base.css.

The table below is one <DataTable> on a small team dataset with every drop-in wired in: a drag-to-group GroupBar, per-column filter widgets (text / faceted select / numeric range), expandable detail rows, and custom cell editors.

Every widget above is a named export you opt into — bind a slice, drop the component into its slot, done. They're starters, not a wall: fork DetailPanel into a bespoke panel, swap FilterSelect for your own faceted control, keep the headless built-ins where you want them. See the API reference for every slot scope and prop, and the <Column> reference.

Headless by default — the raw building blocks

The same package, now with no drop-ins — just the headless <DataTable> and a single #cell slot you render yourself. This is what you reach for when you want full control of the chrome.

Each v-model:<slice> is a two-way bind — the readout updates the instant you change the state, and a consumer write flows back in. The four slices bound here (sorting, globalFilter, rowSelection, pagination) are four of the twelve independent state slices; bind a slice only when you want to own it. The header buttons drive the imperative handle (toggleAllRows, clearSelection, clearSorting) grabbed through Vue's ref. A single #cell slot on <DataTable>, dispatched by columnId, renders the Status badge; every other column falls through to the plain accessor value (the fast path). See the API reference for every prop, slice, event, slot, and handle verb, plus the <Column> API, theming, and accessibility reference.

Row windowing (virtualization)

The same real @rozie-ui/data-table-vue package, now over 50,000 rows with virtual + maxHeight="400px". Only the visible slice renders inside the bounded scroll container — scroll the table below and watch the row count stay tiny while the scrollbar spans the full 50,000-row height. Row windowing is GA and tested to 100,000 rows by a DOM/behavioral VR matrix; the default virtual="false" is byte-identical to a non-virtual table.

Set virtual to opt in; bound maxHeight (or the --rozie-data-table-max-height CSS custom property — the prop wins, the token is the fallback) sizes the scroll container, and estimateRowHeight seeds the row estimate before measureElement refines actual heights. Windowing runs over the full filtered + sorted (pre-pagination) model and suppresses the client pagination chrome. See the comparison page for the published support boundary (and the orthogonal pieces — column virtualization + dynamic auto-measure — that remain deferred).

What ships for each framework

You author the component once as a .rozie file (the parent DataTable.rozie plus the declarative Column.rozie child):

html
<!--
  DataTable.rozie — the CORE @rozie-ui/data-table parent component (Phase 48 core
  wave). A headless, accessible, cross-framework data table built on a SINGLE inline
  @tanstack/table-core bridge (NO per-framework adapter — the whole point of the
  family) marrying table-core's pull-based state machine to all six reactivity
  systems. table-core owns NO DOM (it is a pure `createTable → setOptions →
  getRowModel` state machine), so this is the controlled-state HALF of the rete
  FlowCanvas bridge with none of the DOM-mutation half.

    <DataTable :data="rows" r-model:sorting="$data.sorting" sticky-header>
      <Column field="name" header="Name" sortable />
      <Column field="email" header="Email" />
      <Column field="status" header="Status" sortable>
        <template #cell="{ value }"><StatusBadge :status="value" /></template>
      </Column>
    </DataTable>

  COLUMN DECLARATION — TWO coexisting forms (req-2), id-keyed LWW union:
    - `:columns` config array  → lower precedence
    - `<Column>` children      → override by id (last-write-wins)
  Both resolve to the SAME internal ColumnDef set via columnDefs() below.

  CELL / HEADER RENDERING (req-3) — the Wave-0 probe locked
  `plain-scoped-slot-in-keyed-r-for`: the <td>/<th> hosts are FRAMEWORK-OWNED by the
  keyed r-for (NOT engine-created like rete), so a per-column render template is a PLAIN
  SCOPED SLOT rendered DIRECTLY inside the keyed <td>/<th> — NO portal, NO projection,
  NO host-span, NO rAF defer. The slot moves with the keyed DOM (so a reorder carries
  the cell content) and sits under normal component-scoped CSS (so a styled cell is
  styled on every target, Lit included). Because slots are statically positioned in the
  DECLARING component's template, a per-<Column>-child template CANNOT plain-render into
  the parent's <td> across the component boundary (that only works via $portals, the rete
  NodeType mechanism D-A deletes). So the single #cell / #colHeader scoped slot is
  declared on DATATABLE and DISPATCHES by columnId: the consumer writes ONE
  `<template #cell="{ columnId, value, row, column }">` and switches on columnId (the
  Wave-0 DataTableProbe design). NOTE the header slot is named `#colHeader` (NOT
  `#header`) — a `#header` slot lowers to a Svelte snippet prop named `header` that
  COLLIDES with the pervasive `const header`/loop-var `header` in the script (Svelte 5
  unifies snippets+props into one scope) → the whole component fails to mount (empty
  body). NEW collision class: slot-name == a common local-const name on Svelte.
  <Column> children carry only metadata (no templates).
  A column the slot does not render (the slot's fallback / a config-array column) shows
  the PLAIN accessor value (the fast path — `{{ value }}` slot fallback).

  STATE (req-4..req-11 — all NINE slices now wired: sorting, globalFilter, columnFilters,
  pagination, rowSelection, columnVisibility, columnSizing, columnOrder, columnPinning).
  currentState() assembles the live state object and each slice gets its OWN STATIC-KEY
  write funnel (A4: a generic dynamic-key funnel
  `$data[k]=…`/`$model[k]=…` is ROZ106 on all six — never one parametric indexed
  funnel). onSortingChange → writeSorting(next) emits a FRESH sorting array + fires
  `sort-change` REGARDLESS of binding (echo-guarded).

  STICKY HEADER (req-12): a pure-CSS `position: sticky; top: 0` on the header row,
  token-driven and gated by the `stickyHeader` prop.

  Multi-model Angular CVA: with the NINE model:true slices now wired (req-14), ROZ125
  fires as a WARNING — not an error — on the Angular target and NO ControlValueAccessor
  is auto-emitted (the multi-model condition disables it; do NOT add an
  `angular:{cva:false}` source workaround — the condition already disables CVA). The
  nine slices' two-way r-models all still work via the standard per-prop
  `valueChange.emit(...)` outputs.

  ACCESSIBILITY (req-13, D-01): `role="table"`/`role="rowgroup"`/`role="row"`/
  `role="columnheader"`/`role="cell"`; `aria-sort` (string-safe 'ascending'|'descending'|
  'none') on sortable headers; EVERY interactive control is a NATIVE focusable element
  (sort buttons, select-all + per-row checkboxes, pagination prev/next + page-size
  select, global + per-column filter inputs, the column-visibility `<details>` toggle,
  per-header pin buttons, and edge resize handles) with an accessible name — no
  div-with-click-only control. The keyboard/focus surface is the table-oriented default;
  the reserved `interactionMode='grid'` seam (D-02) stays INERT in v1 (full APG grid
  arrow-key cell navigation is a future additive layer, not baked into the core).

  NINE-SLICE SURFACE COMPLETE: sorting, globalFilter, columnFilters, pagination,
  rowSelection, columnVisibility, columnSizing, columnOrder, columnPinning — each an
  INDEPENDENT optional two-way r-model with its own $data.<slice>Default uncontrolled
  fallback, its own STATIC-KEY write funnel, and its own change event firing REGARDLESS
  of binding (the uncontrolled fallback works when the consumer does not bind the model).

  ZERO emitter/core change.
-->
<rozie name="DataTable" inherit-attrs="false" inherit-listeners="false">

<!--
  D-05/D-07 (LOAD-BEARING): the STABLE package-style specifier ending in `.rozie`.
  Under Option A (published-package composition) @rozie/core's resolver
  (resolveManifestProducer) resolves this specifier to the PUBLISHED per-target
  `@rozie-ui/popover-<target>` package's compiled manifest at compile time — the
  IDENTICAL mechanism command-palette uses for `@rozie-ui/combobox/Combobox.rozie`
  after P75. There is NO vendored sibling and NO in-memory remap. Do NOT hand-write
  the relative `./Popover.rozie`. Popover composes into every per-column ⋯ menu
  below (phase 72, D-01/D-05); the composed `@rozie-ui/popover-<target>` package is
  a runtime peerDependency of each leaf and is NOT publicly re-exported — consumers
  use `@rozie-ui/popover-<target>` directly.
-->
<components>{ Popover: '@rozie-ui/popover/Popover.rozie' }</components>

<props>
{
  // The row data (required). Stable reference per Rozie's setup-once model — re-feed
  // it directly into table-core, never map/clone it in the watcher (Pitfall 3
  // infinite re-render). `required: true` is the sole optionality determinant (a
  // `default:` would be incoherent → ROZ014); the bridge guards `$props.data || []`
  // defensively anyway.
  //
  // Phase 51 (req-4): `data` is now the 10TH two-way slice — a committed cell edit
  // writes a FRESH array back through the `writeData` funnel (consumer binds ONE
  // `r-model:data`; component owns state, Dan's principle). required + model is
  // coherent (no default). The uncontrolled fallback is $data.dataDefault (so editing
  // still works when the consumer passes a one-way `:data`). A1/A2: the 10th two-way
  // slice keeps Angular CVA disabled (already disabled at 9 — ROZ125 warns, never an
  // error); no CVA regression.
  data: {
    type: Array,
    required: true,
    model: true,
    docs: {
      description:
        'The row data — `model: true`, so a committed cell/row edit writes a **fresh** array back through `r-model:data` (uncontrolled fallback `dataDefault`). A stable reference per Rozie\'s setup-once model — fed directly into table-core (never map/cloned in the watcher).',
      example: '<DataTable r-model:data="rows" :columns="cols" />',
    },
  },

  // Config-array column fallback (lower precedence than <Column> children). Each:
  // { id?, field, header?, sortable?, filterable?, pinned?, width? }. Columns may be
  // declared via THIS array OR via <Column> children OR both (id-keyed LWW union).
  columns: {
    type: Array,
    default: () => [],
    docs: {
      description:
        'Config-array column fallback (lower precedence than `<Column>` children). Each entry: `{ id?, field, header?, sortable?, filterable?, pinned?, width? }`. Columns may come from this array, from `<Column>` children, or both (id-keyed last-write-wins union).',
    },
  },

  // Row-selection mode chrome seam: 'none' | 'single' | 'multiple'. Reserved here;
  // the rowSelection state slice is wired in a later wave. Named distinctly from any
  // future `rowSelection` model prop (no ROZ127 / no slot-name clash).
  selectionMode: {
    type: String,
    default: 'none',
    docs: {
      description:
        "Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.",
    },
  },

  // ── State slices wired so far ────────────────────────────────────────────────
  // sorting (SortingState = [{ id, desc }]). model:true → six native two-way
  // expansions; uncontrolled fallback is $data.sortingDefault. Each slice has its own
  // model prop, $data.<slice>Default fallback, and a STATIC-KEY write funnel (A4:
  // never a generic indexed funnel — $data[k]=… is ROZ106 on all six).
  sorting: {
    type: Array,
    default: () => [],
    model: true,
    docs: {
      description:
        '`SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.',
    },
  },

  // globalFilter (string) — feeds getFilteredRowModel(); narrows ALL columns.
  // Uncontrolled fallback $data.globalFilterDefault. onGlobalFilterChange funnels a
  // fresh value through writeGlobalFilter → filter-change (regardless of binding).
  globalFilter: {
    type: String,
    default: '',
    model: true,
    docs: {
      description:
        'The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.',
    },
  },

  // columnFilters (ColumnFiltersState = [{ id, value }]) — per-column narrowing.
  // Each <Column> may opt in via its `filterable` flag (read from the resolved
  // ColumnDef). Whole-array-replace on write (never in-place). filter-change fires.
  columnFilters: {
    type: Array,
    default: () => [],
    model: true,
    docs: {
      description:
        '`ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column\'s `filterable`). Two-way: whole-array replace on write, fires `filter-change`.',
    },
  },

  // pagination ({ pageIndex, pageSize }) — feeds getPaginationRowModel(); prev/next +
  // page-size chrome below. onPaginationChange funnels a fresh object → page-change.
  pagination: {
    type: Object,
    default: () => ({ pageIndex: 0, pageSize: 10 }),
    model: true,
    docs: {
      description:
        '`{ pageIndex, pageSize }`. Defaults to `{ pageIndex: 0, pageSize: 10 }`; feeds the prev/next + page-size chrome (and `getPaginationRowModel()`). Two-way: funnels a fresh object through `page-change`.',
    },
  },

  // Server-side hook (req-6): when true, sets manualPagination/manualFiltering/
  // manualSorting on the table instance — table-core then trusts the consumer-supplied
  // rows verbatim and only emits the change events (the consumer fetches the page).
  manual: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Server-side hook: sets `manualPagination` / `manualFiltering` / `manualSorting` so table-core trusts the consumer-supplied rows and only emits the change events (the consumer fetches each page).',
    },
  },

  // Server-side pagination page-count sources (#2). Under `manual`, table-core does NOT
  // hold the full dataset, so it cannot derive a page count from `data.length` — without
  // one, getPageCount() is -1 and getCanNextPage() is permanently false (the consumer can
  // never leave page 0). Pass EITHER the total server row count (rowCount) OR an explicit
  // total page count (pageCount); table-core handles precedence — explicit pageCount wins,
  // else it derives ⌈rowCount / pageSize⌉, else it auto-computes from the loaded data.
  // Both default null (→ undefined at the option seam) so the client-pagination path is
  // byte-unchanged. NOTE: `pageCount` here is the PUBLIC prop; the "Page X of Y" chrome
  // reader was renamed to `displayPageCount` to avoid a same-name collision on Svelte
  // (destructured props) / Angular / Lit (class fields).
  rowCount: {
    type: Number,
    default: null,
    docs: {
      description:
        'Total server-side row count for `manual` pagination; lets the table compute page count when it doesn\'t hold the full dataset.',
    },
  },
  pageCount: {
    type: Number,
    default: null,
    docs: {
      description:
        'Explicit total page count for `manual` pagination; overrides rowCount-derived count.',
    },
  },

  // ── Expandable rows (phase 50 reqs 1-3, D-04) ────────────────────────────────
  // expandable: opt-in gate. Default false → byte-identical-OFF (req-10): no expander
  // column injects and getExpandedRowModel is inert (empty `expanded` + no getSubRows →
  // getRowModel().rows unchanged). When true, a LEADING chevron expander column auto-
  // injects (after the select column) and every row can expand (the #detail seam) unless
  // getSubRows is supplied (then only rows with children expand). Bare `<DataTable
  // expandable>` only coerces "" → true on Vue+Lit, so consumers bind `:expandable="true"`.
  expandable: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Opt-in **expandable rows**. When `true`, a leading chevron expander column auto-injects (after the select column) and `getExpandedRowModel` activates; default `false` is byte-identical-off. Every row can expand to reveal a `#detail` panel unless `getSubRows` is supplied (then only rows with children expand). Bind `:expandable="true"` (a bare attr only coerces on Vue+Lit).',
    },
  },

  // expanded (ExpandedState = { [rowId]: true } | true) — req-1/3. model:true → two-way.
  // `true` is the expand-ALL literal (Pitfall 2 — pass through verbatim, never Object.keys
  // without a `=== true` guard). Multi-expand by default (multiple rows open at once).
  // onExpandedChange funnels a FRESH value → expanded-change (regardless of binding).
  // Uncontrolled fallback $data.expandedDefault. The 11th two-way slice — Angular CVA stays
  // disabled (already disabled at ≥2 models, ROZ125 warns, never errors); no CVA regression.
  // Default NULL (not `{}`): the `$props.expanded != null ? … : <uncontrolled>` idiom in
  // currentState() needs an UNBOUND expanded to read as null so the uncontrolled fallback
  // ($data.expandedDefault) AND the grouping auto-expand default (req-4) are reachable. A
  // non-null `{}` default would make $props.expanded always non-null → the first ternary
  // branch always wins → the auto-expand (and uncontrolled expand) path is dead code (the
  // post-mount grouping subtrees stayed collapsed). Bound consumers are unaffected (the bound
  // value is non-null). Uncontrolled expand now correctly funnels through $data.expandedDefault.
  expanded: {
    type: [Object, Boolean],
    default: null,
    model: true,
    docs: {
      description:
        '`ExpandedState` — `{ [rowId]: true }`, or the `true` literal after `expandAll` (declared `type: [Object, Boolean]`). Multi-expand (multiple rows open at once). Surfaces through `expand-change`; uncontrolled fallback (`$data.expandedDefault`) when unbound — the default is `null` so the uncontrolled fallback AND the grouping auto-expand default are reachable (a non-null default would short-circuit them). When grouping is active and `expanded` is untouched, group subtrees auto-expand.',
    },
  },

  // getSubRows (TABLE option, NOT a per-Column field — RESEARCH anti-pattern): an accessor
  // `(originalRow, index) => TData[] | undefined` that returns a row's child rows. When
  // supplied (with expandable), table-core flattens the hierarchy into getRowModel().rows
  // and the expand seam reveals depth-indented child rows off the SAME D-04 branch (no
  // nested r-for — Pitfall 1). Null (default) → the #detail scoped slot is the expand mode.
  getSubRows: {
    type: Function,
    default: null,
    docs: {
      description:
        'Table-level child-row accessor `(originalRow, index) => TData[] | undefined` that drives nested sub-rows. When supplied (with `expandable`), table-core flattens the hierarchy and the expand seam reveals depth-indented child rows. Null → the `#detail` scoped slot is the expand mode.',
    },
  },

  // ── Grouping (phase 50 reqs 4-7, D-01/D-05/D-06) ─────────────────────────────
  // groupable: opt-in gate for the HEADLESS #groupBar host region. Default false →
  // byte-identical-OFF (req-10): the #groupBar host <div> never renders and no group
  // chrome appears. getGroupedRowModel is supplied UNCONDITIONALLY (inert when
  // `grouping` is empty — mirrors getExpandedRowModel), so grouping is driven purely by
  // the `grouping` model slice; `groupable` only gates the consumer-facing group-bar
  // surface (no built-in drag — D-02 REVISED).
  groupable: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Opt-in gate for the **headless `#groupBar`** host region. Default `false` is byte-identical-off. `getGroupedRowModel` is wired unconditionally (inert when `grouping` is empty), so grouping is driven by the `grouping` model; this flag only gates the consumer-facing group-bar surface (the component ships **no** built-in drag UI).',
    },
  },

  // grouping (GroupingState = string[]) — reqs 4/7. model:true → two-way. An ORDERED
  // list of column ids (multi-column → nested groups, e.g. ['region','category']).
  // onGroupingChange funnels a FRESH array → `group-change` (NOT `grouping-change`: the
  // model:true `grouping` prop auto-generates an `onGroupingChange` callback on the
  // React/Solid flat Props interface, and a `grouping-change` event would camelCase to the
  // SAME identifier → duplicate identifier TS2300 — the model-prop==emit-name collision
  // 50-02 hit with expanded/expanded-change → expand-change. The house convention stems the
  // event off a DISTINCT name: sorting→sort-change, rowSelection→selection-change,
  // grouping→group-change).
  // Default NULL (not `[]`), mirroring the `expanded` slice (WR-04, 50 review): the
  // `$props.grouping != null ? … : $data.groupingDefault` idiom in currentState() /
  // groupingActiveDefault() needs an UNBOUND grouping to read as null so the uncontrolled
  // fallback ($data.groupingDefault, default []) is reachable and the grouping auto-expand
  // default (req-4) can activate when a consumer applies grouping WITHOUT binding
  // r-model:grouping. A non-null `[]` default would make $props.grouping always non-null →
  // the first ternary branch always wins → the uncontrolled fallback is dead code. Bound
  // consumers are unaffected (the bound value is a non-null array); all reads go through the
  // null-guarded currentState().grouping / groupingActiveDefault() (`|| []`), so table-core
  // still receives an array. The 12th two-way slice — Angular CVA stays disabled (already
  // disabled at ≥2 models, ROZ125 warns, never errors); no CVA regression.
  grouping: {
    type: Array,
    default: null,
    model: true,
    docs: {
      description:
        '`GroupingState` — an ordered `string[]` of column ids (multi-column → nested groups, e.g. `[\'region\',\'category\']`). An empty/unbound list is ungrouped (byte-identical-off). Group-header rows are collapsible (they ride the expand model). Surfaces through `group-change`; uncontrolled fallback (`$data.groupingDefault`, default `[]`) when unbound — the default is `null` (mirroring `expanded`) so the uncontrolled fallback is reachable and the grouping auto-expand default can activate when a consumer applies grouping without binding `r-model:grouping` (a non-null `[]` default would short-circuit it). All reads are null-guarded, so table-core still receives an array.',
    },
  },

  // rowSelection (RowSelectionState = { [rowId]: true }) — req-7. model:true → two-way.
  // selectionMode drives the chrome: 'none' injects no column; 'single' keeps ≤1
  // (enableMultiRowSelection:false); 'multiple' auto-injects a LEADING checkbox column
  // (D-04) with a select-all header (indeterminate when partial, D-06: filtered rows).
  // selection-change fires regardless of binding. Checkbox-only toggle (D-05) — the
  // row body does NOT select.
  rowSelection: {
    type: Object,
    default: () => ({}),
    model: true,
    docs: {
      description:
        '`RowSelectionState` — `{ [rowId]: true }`. Checkbox-only toggle (the row body does not select). Driven by the `selectionMode` chrome. Two-way: fires `selection-change` regardless of binding.',
    },
  },

  // ── Column-management slices (feature wave b, req-8/9/10/11) ──────────────────
  // columnVisibility (VisibilityState = { [colId]: boolean }) — req-8. model:true →
  // two-way. Hidden columns drop AUTOMATICALLY from header + body (the body iterates
  // row.getVisibleCells() and the header groups expose only visible leaf columns).
  // onColumnVisibilityChange funnels a FRESH object → visibility-change (regardless of
  // binding). Uncontrolled fallback $data.columnVisibilityDefault.
  columnVisibility: {
    type: Object,
    default: () => ({}),
    model: true,
    docs: {
      description:
        '`VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.',
    },
  },

  // columnSizing (ColumnSizingState = { [colId]: number }) — req-9. model:true →
  // two-way. A pointer-drag resize handle on resizable headers (table-core's
  // header.getResizeHandler() under columnResizeMode:'onChange') writes a FRESH sizing
  // object; the <th> applies header.getSize(). onColumnSizingChange → resize-change
  // regardless of binding. Behavioral assertion = width delta. Fallback
  // $data.columnSizingDefault.
  columnSizing: {
    type: Object,
    default: () => ({}),
    model: true,
    docs: {
      description:
        '`ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: \'onChange\'`). Two-way: fires `resize-change`.',
    },
  },

  // columnOrder (ColumnOrderState = string[]) — req-10. model:true → two-way. A header
  // drag writes a FRESH order array (immutable — never an in-place splice → silent on
  // React/Solid/Angular/Lit). onColumnOrderChange → reorder-change regardless of
  // binding. Fallback $data.columnOrderDefault.
  columnOrder: {
    type: Array,
    default: () => [],
    model: true,
    docs: {
      description:
        '`ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.',
    },
  },

  // columnPinning (ColumnPinningState = { left: string[], right: string[] }) — req-11.
  // model:true → two-way. A per-header pin control writes columnPinning; pinned columns
  // get position:sticky + computed left/right offsets (column.getStart('left') /
  // getAfter('right')) so they stay during horizontal scroll. onColumnPinningChange →
  // pin-change regardless of binding. Fallback $data.columnPinningDefault.
  columnPinning: {
    type: Object,
    default: () => ({ left: [], right: [] }),
    model: true,
    docs: {
      description:
        '`ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.',
    },
  },

  // Pure-CSS sticky header gate (req-12). When true the <thead> sticks to the top of
  // the scroll container.
  stickyHeader: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.',
    },
  },

  // Interaction mode (D-02): 'table' (default, row-oriented) | 'grid' (full WAI-ARIA
  // grid cell navigation). Both values are GA — grid cell-nav has been fully
  // implemented and battery-tested since Phase 63.
  interactionMode: {
    type: String,
    default: 'table',
    docs: {
      description:
        "`'table'` (default, row-oriented, byte-behaviorally identical to a plain accessible table) | `'grid'` (GA since Phase 63) — lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)**: `role=\"grid\"`, a roving single tab-stop, 2-D APG arrow-key cell navigation, range selection, and clipboard support.",
    },
  },

  // Single-click-to-edit (grid pointer §4, 260708-ni6): opt-in, default false
  // (negative-opt-out convention). Only consulted while interactionMode='grid'. When true, a
  // plain (non-shift) click on an EDITABLE grid cell opens its editor immediately instead of
  // merely activating it — the data-entry-heavy spreadsheet convention. Default false preserves
  // click-to-activate; shift+click (range) and non-editable cells are unaffected either way.
  singleClickEdit: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Grid mode only. When `true`, a plain click on an **editable** cell opens its editor immediately (single-click-to-edit) instead of just activating the cell. Default `false` keeps click-to-activate (double-click opens the editor). Shift+click (range selection) and clicks on non-editable cells are unaffected.',
    },
  },

  // ── Grid-wide undo/redo (260709-8ct, D-2/D-4) ─────────────────────────────────────
  // undoable: opt-in gate, default false (negative-opt-out). Off = zero behavior change,
  // zero memory — no snapshot recording, no Ctrl+Z/Y key interception.
  undoable: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Grid mode. When `true`, every committed data mutation (cell/row edit, paste, fill, cut, clear) becomes one undo step: Ctrl/Cmd+Z undoes, Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z redoes. Default `false` records no history and Ctrl+Z/Y are inert.',
    },
  },
  // undoLimit: bounded history depth — the memory lever for large editable grids (memory
  // scales linearly with retained snapshots; see the design doc's Memory analysis).
  undoLimit: {
    type: Number,
    default: 100,
    docs: {
      description:
        'The maximum number of undo steps retained (oldest evicted past this depth). Only consulted when `undoable` is `true`.',
    },
  },

  // ── Vertical row windowing (phase 53, req-1/2/3) ──────────────────────────────────
  // virtual: opt-in gate. Default false → byte-identical to the pre-phase output (req-1):
  // every virtual-core runtime reference sits behind this guard and the <tbody> windowed
  // structure is an r-if branch whose r-else lowers character-for-character to today.
  virtual: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Opt-in vertical **row windowing**. When `true`, only the visible slice of rows renders inside a bounded `rdt-scroll` container (with leading/trailing spacer rows preserving total scroll height), windowing over the full filtered + sorted (pre-pagination) model and suppressing the client pagination chrome. Default `false` is byte-identical to a non-virtual table.',
    },
  },

  // estimateRowHeight (px, D-11): seeds virtual-core's estimateSize before measureElement
  // refines actual heights. Default 40. Only consulted when virtual is on.
  estimateRowHeight: {
    type: Number,
    default: 40,
    docs: {
      description:
        'Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.',
    },
  },

  // maxHeight: a CSS STRING (D-06, NOT a Number) bounding the rdt-scroll container — applied
  // inline AND mirrored to --rozie-data-table-max-height (prop wins; token is the fallback for
  // the token-only sizing case). Empty default → the container falls back to the token rule.
  maxHeight: {
    type: String,
    default: '',
    docs: {
      description:
        'A CSS length string bounding the `rdt-scroll` container when `virtual` is on (e.g. `\'400px\'`). Mirrored to the `--rozie-data-table-max-height` custom property; the prop wins, the token is the fallback.',
    },
  },
}
</props>

<data>
{
  // Uncontrolled fallback for the `data` slice (Phase 51 req-4) — used when the
  // consumer passes a one-way `:data` rather than `r-model:data`. A committed edit
  // whole-array-replaces this (never in-place mutation → silent on
  // React/Solid/Angular/Lit). Seeded from $props.data in $onMount.
  dataDefault: [],

  // Uncontrolled fallback for the sorting slice (used when the consumer did NOT bind
  // r-model:sorting). Whole-array-replace on write (never in-place push → silent on
  // React/Solid/Angular/Lit).
  sortingDefault: [],

  // Uncontrolled fallbacks for the filter / pagination / selection slices (used when
  // the consumer did NOT bind the matching r-model). Each is whole-value-replace on
  // write (never in-place mutation → silent on React/Solid/Angular/Lit).
  globalFilterDefault: '',
  columnFiltersDefault: [],
  paginationDefault: { pageIndex: 0, pageSize: 10 },
  rowSelectionDefault: {},

  // Uncontrolled fallback for the expanded slice (phase 50 req-1/3) — used when the
  // consumer did NOT bind r-model:expanded. ExpandedState ({ [rowId]: true } | true).
  // Whole-value-replace on write (never in-place mutation → silent on
  // React/Solid/Angular/Lit).
  expandedDefault: {},

  // Uncontrolled fallback for the grouping slice (phase 50 reqs 4-7) — used when the
  // consumer did NOT bind r-model:grouping. GroupingState (ordered string[] of column ids).
  // Whole-array-replace on write (never in-place push → silent on React/Solid/Angular/Lit).
  // [] = ungrouped (byte-identical-off, req-10).
  groupingDefault: [],

  // Uncontrolled fallbacks for the column-management slices (used when the consumer
  // did NOT bind the matching r-model). Each is whole-value-replace on write (never
  // in-place mutation → silent on React/Solid/Angular/Lit).
  columnVisibilityDefault: {},
  columnSizingDefault: {},
  columnOrderDefault: [],
  columnPinningDefault: { left: [], right: [] },

  // Transient resize-gesture state (NOT a two-way model slice). table-core reads
  // getState().columnSizingInfo during a resize drag; an absent key throws in
  // getIsResizing()/getResizeHandler(). Seed table-core's default shape; the
  // onColumnSizingInfoChange callback writes a FRESH object during the drag.
  columnSizingInfo: { startOffset: null, startSize: null, deltaOffset: null, deltaPercentage: null, isResizingColumn: false, columnSizingStart: [] },

  // The id-keyed <Column> registry. WHOLE-OBJECT-REPLACE on every mutation (never
  // in-place $data.colReg[id] = …). T-48-PP: a column id is consumer-controlled, so
  // columnDefs() builds the resolved map prototype-safe (Object.create(null) +
  // __proto__/constructor guard).
  colReg: {},

  // The current rendered rows (table.getRowModel().rows), refreshed by the re-feed
  // watcher. Drives the <tbody> keyed r-for. A FRESH array each refresh so the
  // template re-renders.
  rows: [],

  // The current header groups (table.getHeaderGroups()), refreshed alongside rows.
  headerGroups: [],

  // bumps each refreshRowModel() — lets the post-render cell-mount watcher react.
  rowModelVer: 0,

  // Vertical windowing (phase 53) reactive trigger — a SEPARATE counter from rowModelVer so
  // scroll churn (the virtualizer onChange push) re-renders the windowed slice WITHOUT
  // re-pulling the table-core row model. Bumped in the virtualizer onChange; read (subscribed)
  // by windowedRows()/padTop()/padBottom(). Mirrors the rowModelVer imperative→reactive idiom.
  windowVer: 0,

  // ── Grid interaction-mode active-cell state (phase 49, REQ-1/2/6) ──────────────────
  // The active cell is tracked as an INDEX PAIR over the current visible model — NEVER a
  // stored DOM node (req-6). On every focus move it is re-resolved to an element through
  // the single focusActiveCell() seam. These are NOT model:true slices (D-01 — the count
  // stays 9) and are inert while interactionMode='table'.
  // activeRow: body-row index over $data.rows (D-04 entry = first body row, 0).
  activeRow: 0,
  // activeColIndex: position in the row's VISIBLE cell list (uniform header + body).
  // IN-02: col 0 is the FIRST VISIBLE column — which is the auto-injected leading select
  // column when selectionMode is 'single'/'multiple' (D-04), NOT necessarily the first
  // DATA column. The grid demo uses selectionMode='none' (no select column injected) so
  // there col 0 happens to be the first data column; do not generalise that to the
  // selection-enabled case.
  activeColIndex: 0,
  // activeIsHeader: the active cell is in the header row (reachable via ArrowUp). When
  // true the rowKey resolves to the literal '__header'.
  activeIsHeader: false,
  // activeHeaderLevel: WHICH header-row level the active header cell is on (B12 — grouped
  // multi-level headers). The group index into $data.headerGroups (0 = the topmost parent
  // row, headerGroups.length-1 = the leaf row adjacent to the body). Only meaningful while
  // activeIsHeader is true; a flat grid has a single level (0), so the flat/table path is
  // unchanged. Pairs with activeColIndex (the index within THAT level's headers).
  activeHeaderLevel: 0,
  // NB: the B6 empty-grid header-fallback flag is NOT a $data field — it is the plain
  // component-scope `let gridEmptyFallback` (declared after the gridKeydownHandlers import).
  // clampActiveCell is reached via the mount-time refreshRowModel closure, so a $data read there
  // is async-stale on React (it skipped the empty→non-empty recovery); a plain `let` reads fresh.
  // activeInControl: navigation (false) vs interaction (true) mode (D-07). Plan 03 drives
  // the Enter/F2-to-enter / Escape-to-exit transition; this field reserves the state.
  activeInControl: false,

  // ── Editable-cell state (phase 51 req-1/3/5) ───────────────────────────────────────
  // The cell currently in edit, as an INDEX PAIR over the visible model (NEVER a stored
  // DOM node — survives recycling, T-49 invariant). editingRow is a body-row index over
  // $data.rows (the activeRow index space); editingCol is the position in the row's
  // visible cell list (the activeColIndex space). Both -1 = no cell editing (the
  // byte-identical-off baseline: the editor branch r-if is always false → no editor DOM).
  editingRow: -1,
  editingCol: -1,
  // The in-progress (uncommitted) editor value — bound to the editor input, written on
  // input/seeded on beginEdit (D-05: printable seeds the char, F2/Enter seeds the value).
  draftValue: null,
  // The current validation error message (req-5/D-01) — empty when valid. Drives the
  // aria-live region + the editing <td>'s :aria-invalid (wired in Task 3).
  invalidMsg: '',
  // Bumped on EVERY editing-state transition (begin/commit/cancel). isEditing() reads it
  // so the per-cell display↔editor branch re-evaluates on Svelte/Solid fine-grained
  // reactivity even when the editing state is mutated from a foreign slot-callback scope
  // (the slot's commit/cancel) — the same imperative→reactive `tick()`/rowModelVer idiom
  // the windowed slice + cell loop use. Without it, a commit fired from the #editor slot's
  // `commit` prop left the editor mounted on Svelte (the {#each} branch didn't re-derive).
  editVer: 0,
  // Editor-owns-focus contract (quick 260711-i5m): the column id of the editor that should
  // CURRENTLY hold focus, reactively — editorAutofocusFor(colId, rowIndex) derives the
  // `autofocus` #editor scope prop from it. Set by beginEdit/beginRowEdit/commitRow's B22
  // reject/rowEditTab; cleared by endEdit/endRowEdit. null = no focus target (nothing editing,
  // or the byte-identical-off baseline).
  editFocusColId: null,

  // ── Full-row edit state (phase 51 req-6 / D-06) ────────────────────────────────────
  // editingRowIndex: the row in FULL-ROW edit, as a body-row index in the SAME index space
  // as editingRow / activeRow (rowIndexOf(row) — the visible-model index for the non-virtual
  // body; the wr.vi.index full-model index under windowing, Plan 51-04). null = no row in
  // edit (the byte-identical-off baseline: isEditing's row branch is always false → no row
  // editors). Mutually exclusive with the single-cell editingRow/editingCol pair (beginRowEdit
  // clears it; beginEdit clears editingRowIndex) so isEditing never resolves both modes at once.
  editingRowIndex: null,
  // rowDraft: the in-progress (uncommitted) per-cell drafts for the row in edit, an object
  // KEYED BY columnId — { [columnId]: draftValue }. Seeded from each editable column's current
  // value on beginRowEdit; each open editor binds + writes its own key (NEVER the shared
  // single-cell draftValue, which only one editor can own). commitRow applies every key to the
  // row object in ONE fresh-array replace; cancelRow drops it (revert — never written). {} = none.
  rowDraft: {},

  // ── Cell-range selection state (phase 51 req-7 / D-07) ──────────────────────────────
  // The rectangular cell range is tracked as TWO index pairs over the FULL visible model —
  // NEVER a stored DOM node (the activeRow/activeColIndex invariant, T-49: survives
  // recycling; the highlight re-resolves to cells every render via the pure inRange() math).
  // rangeAnchor = the fixed corner (seeded from the active cell when a range begins);
  // rangeFocus = the moving corner (extended by Shift+Arrow / Shift+Click). The selected
  // rectangle is the min/max box of the two. BOTH null = no range (the byte-identical-off
  // baseline: inRange() always false → no range markup). This is ONE-WAY (D-07): exposed via
  // the getSelectedRange verb + the range-change event, NEVER a model:true slice — the
  // model:true count stays at 10, leaving the Angular multi-model-CVA condition untouched.
  // It is a SEPARATE visual layer from the rowSelection slice (the two never corrupt each
  // other — toggling a row checkbox leaves the range intact and vice versa).
  rangeAnchor: null,
  rangeFocus: null,

  // ── Clipboard paste announce (phase 51 req-8 / D-03) ────────────────────────────────
  // The polite aria-live announcement after a TSV paste / drag-fill — "N of M cells pasted"
  // (D-03). SEPARATE from the validation invalidMsg region (different semantics). '' when no
  // paste has happened (the byte-identical-off baseline: the announce region r-if is false).
  pasteAnnounce: '',

  // ── Sort/filter live announcement (#14) ─────────────────────────────────────────────
  // The polite aria-live message announced when the consumer changes sorting OR filtering
  // (columnFilters / globalFilter) — e.g. "Sorted by Name, ascending" / "Sorting cleared" /
  // "42 results". Set by the sort/filter $watch below (lazy → NO spurious mount announce).
  // SEPARATE from invalidMsg (validation) and pasteAnnounce (clipboard). '' = nothing to
  // announce (the byte-identical-off baseline: the announce region r-if is false).
  liveAnnounce: '',
}
</data>

<script lang="ts">
import {
  createTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getExpandedRowModel,
  getGroupedRowModel,
  // Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
  // (mirrors the expand/group models) — inert until a consumer READS a column facet via the
  // getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
  // getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
  // box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
  // ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
  getFacetedRowModel,
  // Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
  // are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
  // (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
  // `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
  // resolve to the in-scope helper, NOT this table-core factory import (the collision that made
  // the verb return the factory fn instead of the keys array — roundout facet block).
  getFacetedUniqueValues as makeFacetedUniqueValues,
  getFacetedMinMaxValues as makeFacetedMinMaxValues,
} from '@tanstack/table-core'
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import {
  Virtualizer,
  elementScroll,
  observeElementRect,
  observeElementOffset,
  measureElement,
} from '@tanstack/virtual-core'

// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
let table = null

// ── Vertical row windowing instance state (phase 53) ──────────────────────────────────
// Mutable top-level instances (the `let table` precedent — React hoists to useRef; do NOT
// const). NULL until $onMount, and ONLY constructed when $props.virtual. virtualizerCleanup
// holds the _didMount() teardown for $onUnmount; gridScrollEl is the captured .rdt-scroll div
// the virtualizer observes.
let virtualizer = null
let virtualizerCleanup = null
let gridScrollEl = null
// CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
// rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
// pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
// callbacks were what broke the Solid scroll-then-focus seam (D-12 focusActiveCell →
// scrollToIndex → double-rAF focus): a stray remeasure firing inside that focus deferral
// disrupted the focus landing. The sweep ALSO bails while virtual-core is mid-scroll
// (virtualizer.isScrolling), so a measure can't run during scrollToIndex; the next settled
// onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
// once motion settles between scroll steps) is unaffected.
let remeasurePending = false

// ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
// Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
// via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
// later change is a one-line edit.
const GRID_PAGE_STEP = 10
// The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
// $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
// shadow-safe because the query runs from INSIDE the component's own scope (the listbox
// querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
// computed/template binding (ROZ123).
let gridRoot = null

// Echo-guard: while WE are writing a slice back, the re-feed watcher must not re-enter
// the funnel. A counter (not a boolean) so nested writes are safe.
let programmatic = 0

// Focus-intent epoch (#9) — a monotonic counter bumped at every focus-INTENT entry point
// (focusActiveCell / focusCell+focusAbsCellWhenReady arm / a genuine active-cell-moving
// focusin in syncActiveFromEvent). The two async focus-recovery polls (focusWhenReady for the
// virtual off-window scroll, focusAbsCellWhenReady for the paginated page-switch) CAPTURE this
// value at arm time (AFTER their own bump) and abort at the top of each iteration if it has since
// changed — so a LATER user nav (ArrowKey / click) supersedes a stale poll instead of the poll
// yanking focus back frames later. A naive guardMoved "abort if focus moved" check is WRONG here:
// both polls arm while focus still sits on the OLD/being-left cell BY DESIGN (scroll-to /
// page-switch), so an epoch — bumped only by a NEWER intent — is the correct abort signal.
let focusIntentEpoch = 0

// ── Grid-wide undo/redo (260709-8ct) — history STATE lives in top-level `let` (mirroring
// `programmatic` above), NOT $data: recording a snapshot on every keystroke must not trigger
// a reactive re-render. React hoists each to useRef. undoStack/redoStack hold `data` array
// REFERENCES (never deep copies — see undoHistory.rzts's header comment on the shared-row
// invariant). restoringHistory suppresses re-recording while an undo()/redo() replay is
// in flight.
//
// The external-swap history reset keys on data ORIGIN, not a timing window. Every internal
// writeback stamps its fresh `data` array with a durable, non-enumerable marker under
// DATA_WRITE_TOKEN_KEY (see writeData in writeFunnels.rzts); the reset (maybeClearHistoryOnExternal
// Swap, below) clears history ONLY when a newly-supplied `$props.data` carries no marker — it did
// not come from us, so it is a genuine external dataset swap. Presence of the marker ⟺ "descends
// from one of our writes", and it survives EVERYTHING that defeated the four flag/timer variants:
//   1. A raw-reference latch (`lastWrittenData === currentData()`) — Vue `reactive()` / Svelte 5
//      `$state` / Solid store re-wrap a written array in a NEW Proxy on its way back through props,
//      so `===` never holds. (A non-enumerable own PROPERTY, by contrast, is forwarded through
//      every target's reactive Proxy via `Reflect.get` — readable through the wrap.)
//   2. A single-consume boolean — the re-feed watch fires MULTIPLE times per write; the first pass
//      consumed the flag, a later pass wrongly cleared.
//   3. A content signature (`JSON.stringify`) — the watch can fire with a TRANSIENTLY STALE
//      `currentData()` mid-settle (Solid/Lit), a real-but-older value → false mismatch.
//   4. A deferred settle-window flag (rAF, then a 96ms macrotask) — a slow re-feed on a LARGE
//      controlled table OUTRAN the window (#8); no fixed timeout can be correct (re-feed latency
//      scales with dataset size).
// A STRING key (not a JS Symbol) is deliberate: it is stable BY VALUE on all six targets with ZERO
// caching, whereas a `Symbol()` needs a per-instance memo to hold one identity — and Lit lowers
// `$computed(() => Symbol())` to a plain getter that RE-MINTS the Symbol on every read, so writeData
// and the reset would stamp/read DIFFERENT symbols and the marker would never match. Non-enumerable
// → invisible to JSON.stringify / spread / Object.keys (the consumer's data stays clean); namespaced
// so a consumer array never collides.
const DATA_WRITE_TOKEN_KEY = '__rozieDataWriteToken'
let undoStack: unknown[] = []
let redoStack: unknown[] = []
let restoringHistory: boolean = false

// Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
// has not bound `expanded` and has not yet toggled any group, group-header rows default to
// EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
// roundout-VR leaf-visible baseline). The FIRST group/row toggle sets this true (in
// writeExpanded), after which the user's expanded state wins. Stays false (untouched) on the
// non-grouping path → byte-identical-off (the `expanded` slice resolves to $data.expandedDefault
// exactly as before, both for the plain table AND the expandable-rows feature).
let expandedTouched = false

import { groupingActiveDefault, currentState, currentData } from './stateAssembly.rzts'

import { isSafeKey, wrapAggregationFn, columnDefs, SELECT_COL_ID, EXPANDER_COL_ID, selectionEnabled, tableColumns } from './columnBuilders.rzts'

import { writeSorting, applyUpdater, writeExpanded, writeGrouping, writeGlobalFilter, writeColumnFilters, writePagination, writeRowSelection, writeColumnVisibility, writeColumnSizing, writeColumnOrder, writeColumnPinning, writeData, columnFilterValue, setColumnFilter } from './writeFunnels.rzts'

// 260709-8ct (grid-wide undo/redo) — the ONLY ES import for the undo engine (undoHistory.rzts
// itself has ZERO imports, referencing writeData/currentData/$props/$emit/the four history
// lets above all BARE — the same inlined-partial pattern writeFunnels.rzts uses for
// programmatic/$data/$model; a cross-import between writeFunnels and undoHistory would
// create an ES cycle and/or a TDZ on the inlined lets).
import { recordSnapshot, canUndo, canRedo, clearHistory, emitHistoryChange, emitHistoryChangeIfEdged, undo, redo } from './undoHistory.rzts'

// Re-read the row model + header groups into $data (fresh arrays → the template
// re-renders). A plain fn (NOT a $computed — getRowModel() must be pulled AFTER a
// setOptions re-feed, imperatively). Defined inside $onMount so it captures the live
// `table`.
let refreshRowModel = null

import { onSortingChangeCb, onExpandedChangeCb, onGroupingChangeCb, onGlobalFilterChangeCb, onColumnFiltersChangeCb, onPaginationChangeCb, onRowSelectionChangeCb, onColumnVisibilityChangeCb, onColumnSizingChangeCb, onColumnOrderChangeCb, onColumnPinningChangeCb, onColumnSizingInfoChangeCb } from './stateChangeCallbacks.rzts'

import { windowSource, scheduleRemeasure, pinnedEditIndex, pinnedMeasurement, remeasureWindow } from './virtualization.rzts'
import { virtualItemKey, virtualizerOptions, windowedRows, padTop, padBottom, pmIndexInWindow, rowIsOutsideWindow } from '@rozie-ui/headless-core/windowing.rzts'

// ── Sort/filter live-announcement (#14) ─────────────────────────────────────────────
// A polite aria-live announcement whenever the consumer changes sorting or filtering, so a
// screen-reader user hears that the rows were reordered / narrowed (which is otherwise silent).
// announceState holds the last-seen references so the lazy watch below can tell WHICH slice
// changed (sort vs filter) and pick the message. It is a top-level mutable const → stabilized
// once per instance on all six targets (React useMemo-wraps a mutable instance; the others run
// setup once), so it PERSISTS across renders — unlike a top-level `let`, which React resets per
// render. Seeded from the initial state in $onMount so the first (post-mount) change compares
// against the true starting values, not a null sentinel.
// Typed as `unknown` members: these hold opaque last-seen references compared only by
// identity (!==) below, never read in a typed context — the annotation keeps the null seed
// from narrowing the members to `null` (which would reject the real reassignments under
// strictNullChecks in the emitted leaves).
const announceState: { sorting: unknown; columnFilters: unknown; globalFilter: unknown } = { sorting: null, columnFilters: null, globalFilter: null }
// Effective (controlled-or-uncontrolled) reads of the sort/filter slices: the bound prop when
// the consumer bound the matching r-model, else the uncontrolled $data default (mirrors currentState()).
const effectiveSorting = () => ($props.sorting != null ? $props.sorting : $data.sortingDefault)
const effectiveColumnFilters = () => ($props.columnFilters != null ? $props.columnFilters : $data.columnFiltersDefault)
const effectiveGlobalFilter = () => ($props.globalFilter != null ? $props.globalFilter : $data.globalFilterDefault)
// Build the polite message for a sort/filter change and advance announceState. Sort takes
// precedence when the sorting reference changed; otherwise a filter changed → the post-filter
// result count (the FILTERED total via totalRowCount(), NOT the page slice). Returns '' when
// neither actually changed (a no-op watch tick — do not re-announce).
const buildSortFilterAnnounce = () => {
  const nextSorting = effectiveSorting()
  const nextColumnFilters = effectiveColumnFilters()
  const nextGlobalFilter = effectiveGlobalFilter()
  const sortChanged = nextSorting !== announceState.sorting
  const filterChanged = nextColumnFilters !== announceState.columnFilters || nextGlobalFilter !== announceState.globalFilter
  announceState.sorting = nextSorting
  announceState.columnFilters = nextColumnFilters
  announceState.globalFilter = nextGlobalFilter
  if (sortChanged) {
    const active = (nextSorting && nextSorting.length) ? nextSorting[0] : null
    if (!active) return 'Sorting cleared'
    const rawLabel = headerLabel(active.id)
    const label = (typeof rawLabel === 'string' && rawLabel) ? rawLabel : active.id
    return 'Sorted by ' + label + ', ' + (active.desc ? 'descending' : 'ascending')
  }
  if (filterChanged) {
    return totalRowCount() + ' results'
  }
  return ''
}

$onMount(() => {
  // Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
  // edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
  // a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
  // the bound prop when controlled, this fallback otherwise.
  $data.dataDefault = $props.data || []
  // Build the table instance HERE so the closures below capture the live `table`.
  table = createTable({
    // Plain value (NOT a `get data()` getter): an object-literal getter rebinds
    // `this` to the options object, and the Angular/Lit emitters resolve $props via
    // `this.data` — so `get data() { return $props.data }` lowers to `this.data`
    // re-entering the getter → infinite recursion (max call stack). `data` is re-fed
    // on every change by the watch's setOptions below, exactly like columns/state, so
    // the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
    // currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
    // (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
    data: currentData(),
    columns: tableColumns(),
    state: currentState(),
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    // Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
    // (mirrors the other models) — inert when `expanded` is empty + no getSubRows
    // (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
    // ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
    // (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
    // default `!!subRows.length` rule applies (only parents with children expand).
    getExpandedRowModel: getExpandedRowModel(),
    getSubRows: ($props.getSubRows || undefined) as any,
    getRowCanExpand: ($props.expandable === true && $props.getSubRows == null) ? (() => true) : undefined,
    onExpandedChange: onExpandedChangeCb,
    // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
    // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
    // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
    // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
    // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
    // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
    // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
    // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
    // are unaffected (they fire regardless of this flag).
    autoResetExpanded: false,
    // Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
    // UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
    // (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
    // table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
    // members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
    // nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
    // (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
    getGroupedRowModel: getGroupedRowModel(),
    onGroupingChange: onGroupingChangeCb,
    // Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
    // UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
    // column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
    // The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: makeFacetedUniqueValues(),
    getFacetedMinMaxValues: makeFacetedMinMaxValues(),
    // Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
    // rows verbatim (no client-side filter/sort/paginate) and only emits the change
    // events so the consumer can fetch the next page/filtered slice.
    manualPagination: $props.manual === true,
    manualFiltering: $props.manual === true,
    manualSorting: $props.manual === true,
    // Server-side page-count sources (#2): pass the consumer-supplied total row count and/or
    // explicit page count so table-core can compute getPageCount() under `manual` (where it
    // does not hold the full dataset). undefined when unset → table-core auto-derives from the
    // loaded data (client-pagination path byte-unchanged). Precedence is table-core's: explicit
    // pageCount wins, else ⌈rowCount / pageSize⌉, else auto. With a real count getCanNextPage()
    // becomes true, so a server-pagination consumer can leave page 0.
    rowCount: $props.rowCount ?? undefined,
    pageCount: $props.pageCount ?? undefined,
    // Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
    // (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
    // default, D-06 — NOT overridden).
    enableRowSelection: $props.selectionMode !== 'none',
    enableMultiRowSelection: $props.selectionMode === 'multiple',
    // PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
    // no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
    // $watch so React reads fresh currentState (the stale-closure fix, F6).
    onSortingChange: onSortingChangeCb,
    onGlobalFilterChange: onGlobalFilterChangeCb,
    onColumnFiltersChange: onColumnFiltersChangeCb,
    onPaginationChange: onPaginationChangeCb,
    onRowSelectionChange: onRowSelectionChangeCb,
    onColumnVisibilityChange: onColumnVisibilityChangeCb,
    onColumnSizingChange: onColumnSizingChangeCb,
    onColumnOrderChange: onColumnOrderChangeCb,
    onColumnPinningChange: onColumnPinningChangeCb,
    onColumnSizingInfoChange: onColumnSizingInfoChangeCb,
    // Resize mode: 'onChange' so the bound columnSizing model updates live during the
    // drag (the behavioral width-delta assertion observes the in-progress width). Column
    // resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
    columnResizeMode: 'onChange',
    enableColumnResizing: true,
    renderFallbackValue: null,
    // table-core's RESOLVED options type (TableOptionsResolved) requires a global
    // onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
    // callbacks above, so the global hook is a no-op. Present so the createTable() argument
    // satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
    onStateChange: () => {},
  })

  refreshRowModel = () => {
    if (!table) return
    // Capture fresh locals; never write a $data key then re-read it in the same fn
    // (ROZ138 / React stale-read — setState is async on React, the closure binds the
    // PRE-write value).
    // windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
    // pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
    const nextRows = windowSource().slice()
    const nextGroups = table.getHeaderGroups().slice()
    $data.rows = nextRows
    $data.headerGroups = nextGroups
    $data.rowModelVer = $data.rowModelVer + 1
    // Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
    // into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
    // NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
    // setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
    if ($props.virtual && virtualizer) {
      virtualizer.setOptions(virtualizerOptions())
      virtualizer._willUpdate()
    }
    // D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
    // clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
    // no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
    // B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
    // async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
    // the range corners on React too — never re-reading the pre-change model.
    const nextRowCount = nextRows.length
    const nextColCount = nextRows.length
      ? nextRows[0].getVisibleCells().length
      : (nextGroups.length ? ((nextGroups[nextGroups.length - 1].headers || []).length) : 0)
    clampActiveCell(nextRowCount, nextColCount)
    // #4: clamp a pageIndex that now points PAST the last page. When the consumer holds
    // pagination.pageIndex (controlled) and shrinks the data (filter / replace) so there are
    // fewer pages, the body renders blank ("Page 6 of 3" with Next disabled). Read table-core's
    // LIVE post-re-derive state: getPageCount() is the fresh count (now correct under `manual`
    // too, #2) and getState().pagination is the just-fed state. Funnel the correction through
    // writePagination (the single-emit + two-way-model funnel) so the consumer's controlled
    // pagination prop converges to the last valid page (page-change carries { pageIndex, pageSize }).
    //   • pc > 0 skips the manual-WITHOUT-count case (getPageCount() === -1) — never clamp toward
    //     an unknown total.
    //   • LOOP-GUARD: emit ONLY when the clamped index actually differs. After the consumer echoes
    //     the clamp back through the pagination prop, the re-feed re-enters here with
    //     pageIndex === pc - 1, so `pageIndex > pc - 1` is false → no re-emit; a consumer that
    //     ignores the event triggers no further re-feed, so it stays a single emit either way.
    //   • No fight with table-core's autoResetPageIndex: that reset only fires on table-core's OWN
    //     setX mutations, which this fully-controlled-state architecture never calls (filters/data
    //     flow through setOptions), so reading the live state here can only fire on a genuine
    //     overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
    //     writing paginationDefault, with no regression since table-core does not auto-clamp here).
    const pgState = table.getState().pagination
    const pc = table.getPageCount()
    if (pc > 0 && pgState.pageIndex > pc - 1) {
      writePagination({ pageIndex: pc - 1, pageSize: pgState.pageSize })
    }
    // B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
    // filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
    // indices), so resolve the committed row's NEW index by identity HERE (never from the React-
    // stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
    // gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
    // a no-relocation commit resolves the same index → byte-behaviorally identical to before.
    if (pendingEditFollow && isGrid()) {
      const follow = pendingEditFollow
      pendingEditFollow = null
      const followIdx = indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId)
      if (followIdx >= 0) focusCellWhenReady(followIdx, follow.col)
    }
    // keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
    // selection state (bound :indeterminate is inert on 5/6 targets). The box persists
    // across selection changes; a microtask defer covers React's post-render DOM patch.
    syncIndeterminate()
    if (typeof queueMicrotask !== 'undefined') queueMicrotask(syncIndeterminate)
    else Promise.resolve().then(syncIndeterminate)
  }

  // initial pull
  refreshRowModel()

  // ── Grid mode: capture the table root ──────────────────────────────────────────────
  // $el is the component root; the <table class="rozie-data-table"> is the grid root the
  // cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
  // (post-mount) so it is non-null and ROZ123-clean.
  gridRoot = $el ? $el.querySelector('.rozie-data-table') : null
  // WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
  // page load AND was non-deterministic on React/Solid (the entry cell may not be
  // committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
  // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
  // mount"); the consumer drives focus by Tabbing/clicking in, never the component.

  // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
  // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
  // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
  // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
  // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
  if ($props.virtual) {
    gridScrollEl = $el ? $el.querySelector('.rdt-scroll') : null
    virtualizer = new Virtualizer(virtualizerOptions())
    virtualizerCleanup = virtualizer._didMount()
    // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
    // evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
    // reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
    // non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
    // SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
    // only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
    // never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
    // re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
    // pulls getVirtualItems().
    $data.windowVer = $data.windowVer + 1
    // After the first window commits (next frame), refine heights + fire the dev-mode warns
    // ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
    // code and these warns can never fire there (req-1 byte-identical-off preserved).
    const afterFirstFrame = () => {
      // D-10: measure the rendered rows.
      remeasureWindow()
      // D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
      // bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
      // process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
      const h = gridScrollEl ? gridScrollEl.clientHeight : 0
      if (!h) {
        console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height')
      }
      // D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
      // CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
      // virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
      // ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
      // detected as a pagination that DIFFERS from that default (a consumer who set a real page
      // size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
      // virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
      const pg = $props.pagination
      const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10)
      if ($props.manual !== true && pgConfigured) {
        console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.')
      }
    }
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame))
    else setTimeout(afterFirstFrame, 0)
  }

  // #14: seed the sort/filter announce baseline from the initial (post-mount) state so the LAZY
  // watch's first fire — a real user sort/filter — compares against the true starting values and
  // is classified correctly (a null sentinel would misread the first filter change as a sort change).
  announceState.sorting = effectiveSorting()
  announceState.columnFilters = effectiveColumnFilters()
  announceState.globalFilter = effectiveGlobalFilter()
})

// Tear down the virtualizer's scroll-element ResizeObserver on unmount (the embla destroy
// precedent). No-op when virtual was off (cleanup stays null).
$onUnmount(() => {
  if (virtualizerCleanup) virtualizerCleanup()
  // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
  teardownFillDrag()
  // §6 (260709-3qt): remove any live drag-select document listeners on a mid-drag unmount.
  teardownRangeDrag()
})

// Reactive re-feed: when the bound sorting slice OR data length OR the column registry
// changes, push fresh options into table-core and re-pull the row model. Watch the
// bound references / a derived primitive — never a freshly-built array (Pitfall 3).
// Lazy by default ($onMount did the first pull). EXTENSION: add the other bound slices
// to this getter array as they are wired.
$watch(
  () => [
    $props.sorting,
    $props.globalFilter,
    $props.columnFilters,
    $props.pagination,
    // Server-side page-count sources (#2): re-feed when the consumer's rowCount/pageCount
    // changes at runtime (e.g. a server response updates the total) so getPageCount() and the
    // Next button availability track the new total.
    $props.rowCount,
    $props.pageCount,
    $props.rowSelection,
    $props.expanded,
    $props.expandable,
    $props.grouping,
    $props.groupable,
    $props.columnVisibility,
    $props.columnSizing,
    $props.columnOrder,
    $props.columnPinning,
    $props.selectionMode,
    ($props.data || []).length,
    // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
    // even when the fresh array is the SAME length (a single-cell edit replaces one row
    // object → new array ref, identical length → the .length key alone would miss it). The
    // controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
    // writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
    $props.data,
    $data.dataDefault,
    // Column CONFIG prop (the `:columns` array form) — the sibling column source to
    // $data.colReg (the `<Column>` children). Watch it so a runtime `:columns` swap re-feeds:
    // columnDefs()/tableColumns() build the UNION of both, and reFeed re-passes columns.
    // (Consumers memoize the array as with $props.data/$props.sorting; the uncontrolled
    // <Column>-children path leaves $props.columns undefined — a stable no-op getter.)
    $props.columns,
    $data.colReg,
  ],
  () => { reFeed(); maybeClearHistoryOnExternalSwap() },
)

// #14: sort/filter live announcement. Registered AFTER the re-feed watch above so it runs
// LATER in the same reactive flush — the filtered "N results" count read (totalRowCount() →
// getFilteredRowModel) then reflects the NEW filter state that reFeed() just pushed into
// table-core, not the pre-change model. Lazy (like the re-feed watch) → no spurious mount
// announce. Watches BOTH the controlled props AND the uncontrolled default slices so it fires
// in either binding mode; buildSortFilterAnnounce classifies sort-vs-filter and returns '' on a
// no-op tick (only a real change writes $data.liveAnnounce). Pagination/selection are NOT watched
// here (out of scope — they must not announce).
$watch(
  () => [
    $props.sorting,
    $props.columnFilters,
    $props.globalFilter,
    $data.sortingDefault,
    $data.columnFiltersDefault,
    $data.globalFilterDefault,
  ],
  () => {
    const msg = buildSortFilterAnnounce()
    if (msg) $data.liveAnnounce = msg
  },
)

// Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
// re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
const reFeed = () => {
  if (!table) return
  // NOTE: the external-swap history reset does NOT live here. reFeed() fires on EVERY watched
  // change — including our OWN synchronous internal `$data.dataDefault` write — so a clear keyed
  // on a `currentData()` read here would (on fine-grained targets) fire mid-round-trip against a
  // TRANSIENTLY-STALE `$props.data` and wrongly wipe a just-recorded edit's history. The reset is
  // keyed on the `$props.data` REFERENCE actually changing instead — see the $onUpdate backstop
  // below (`maybeClearHistoryOnExternalSwap`), which runs on all six targets.
  table.setOptions((prev) => ({
    ...prev,
    data: currentData(),
    columns: tableColumns(),
    state: currentState(),
    enableRowSelection: $props.selectionMode !== 'none',
    enableMultiRowSelection: $props.selectionMode === 'multiple',
    // Re-pass the server-side page-count sources (#2) so a RUNTIME rowCount/pageCount change
    // takes effect: setOptions REPLACES via `...prev`, which holds the value captured at
    // createTable time, so an omitted key would freeze the mount-time count. The re-feed
    // $watch keys on both props below.
    rowCount: $props.rowCount ?? undefined,
    pageCount: $props.pageCount ?? undefined,
    // Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
    // setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
    // onExpandedChange callback must re-capture fresh currentState each cycle, F6).
    getExpandedRowModel: getExpandedRowModel(),
    getSubRows: ($props.getSubRows || undefined) as any,
    getRowCanExpand: ($props.expandable === true && $props.getSubRows == null) ? (() => true) : undefined,
    onExpandedChange: onExpandedChangeCb,
    // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
    // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
    // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
    // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
    // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
    // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
    // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
    // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
    // are unaffected (they fire regardless of this flag).
    autoResetExpanded: false,
    // Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
    // omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
    // fresh currentState each cycle, F6).
    getGroupedRowModel: getGroupedRowModel(),
    onGroupingChange: onGroupingChangeCb,
    // Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
    // drop the model on re-feed; on React the faceted closures must re-capture so exposed
    // unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: makeFacetedUniqueValues(),
    getFacetedMinMaxValues: makeFacetedMinMaxValues(),
    // Re-pass the per-slice callbacks so React captures fresh currentState each cycle
    // (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
    onSortingChange: onSortingChangeCb,
    onGlobalFilterChange: onGlobalFilterChangeCb,
    onColumnFiltersChange: onColumnFiltersChangeCb,
    onPaginationChange: onPaginationChangeCb,
    onRowSelectionChange: onRowSelectionChangeCb,
    onColumnVisibilityChange: onColumnVisibilityChangeCb,
    onColumnSizingChange: onColumnSizingChangeCb,
    onColumnOrderChange: onColumnOrderChangeCb,
    onColumnPinningChange: onColumnPinningChangeCb,
    onColumnSizingInfoChange: onColumnSizingInfoChangeCb,
  }))
  if (refreshRowModel) refreshRowModel()
}

// LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
// `data` PROPERTY): the re-feed $watch reads `(this.data||[]).length` inside a
// preact-signals effect, but `data` is a Lit @property (not a signal) so the effect
// never re-runs when the consumer pushes new rows post-mount (the sticky demo seeds 20
// rows in its own $onMount AFTER the child mounted empty → the body stayed at 0). The
// slice models DO re-pull (their $data.<slice>Default signals are effect-tracked), so
// only a raw `data` reference/length change slips through. $onUpdate (Lit updated())
// fires on ANY property change incl `data`; guard with a stored last-seen data ref +
// length so it re-feeds ONLY on a real data change (no churn). On the coarse-render
// targets the watch already covers it; this is a cheap idempotent backstop.
// External-swap history reset (grid-wide undo/redo, 260709-8ct; #8 fix). Keyed on the CONTROLLED
// `$props.data` REFERENCE changing — deliberately NOT on `currentData()` inside reFeed. An internal
// writeback changes `$data.dataDefault` SYNCHRONOUSLY and only LATER round-trips into `$props.data`;
// keying on `$props.data`'s OWN change means we never observe the transient window where a fine-
// grained target's reFeed reads a stale, unstamped `$props.data` mid-write and wrongly wipes a
// just-recorded edit's history (the stale-read false-clear — the SAME failure that broke the
// content-signature variant — that regressed Solid/Lit when this clear lived in reFeed). When
// `$props.data` genuinely changes: a new array carrying DATA_WRITE_TOKEN round-tripped from one of
// OUR writes → keep; one without it is a dataset the consumer handed us → external swap → clear. A
// non-data tick (sort/filter/pagination) never touches `$props.data` → never clears. Called from
// BOTH the coarse re-feed watch AND the $onUpdate backstop (Lit's @property `data` the effect-
// tracked watch can't observe); both are ref-gated so the redundant call is an idempotent no-op.
let lastPropsData: unknown = null
const maybeClearHistoryOnExternalSwap = () => {
  const pd = $props.data
  if (pd === lastPropsData) return   // $props.data did not change → not an external swap
  lastPropsData = pd
  if (!$props.undoable) return
  if (pd != null && (pd as any)[DATA_WRITE_TOKEN_KEY] != null) return   // descends from our write → keep
  clearHistory()
}

let lastData = null
let lastDataLen = -1
$onUpdate(() => {
  maybeClearHistoryOnExternalSwap()
  if (!table) return
  // Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
  // $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
  // bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
  // identical length) still re-feeds.
  const d = currentData() || []
  if (d === lastData && d.length === lastDataLen) return
  lastData = d
  lastDataLen = d.length
  reFeed()
})

// Header click → toggle sort. Shift-click → ADD a secondary sort (multi-sort). Driven
// through table-core's column API so the onSortingChange funnel emits the fresh state.
const onHeaderSort = (colId, evt) => {
  if (!table) return
  const col = table.getColumn(colId)
  if (!col || !col.getCanSort()) return
  const multi = !!(evt && evt.shiftKey)
  // toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
  col.toggleSorting(undefined, multi)
}

// aria-sort string for a column header: 'ascending' | 'descending' | 'none'. Reads
// Reactive tick: read $data.rowModelVer (bumped by every refreshRowModel) so a
// template binding that calls a table-READING chrome helper (pagination/sort/pin/
// visibility predicates below) re-evaluates when the row model changes. On the
// coarse-render targets (Vue/React/Angular) the whole template re-runs anyway so this
// is a no-op; on the FINE-GRAINED targets (Solid/Lit) a helper that only reads the
// non-reactive `table` let would be computed ONCE (when table is still null → the
// default branch) and never update — pagination would read "Page 1 of 1" forever,
// aria-sort never flips, the pin position never sticks. Touching rowModelVer puts each
// helper in the reactive scope. The chrome helpers prefix `tick()` in their guard.
const tick = () => $data.rowModelVer
import { ariaSortFor, sortIndicator, defFor, visibleCellsFor, editMetaOf, columnEditable, editorTypeOf, editorOptionsOf, hasEditorSlot, hasFilterSlot, columnIsFilterable, headerLabel, headerWidth, onResizeStart, findHeader, columnIsResizing, columnIsVisible, onToggleVisibility, allLeafColumns, columnPinSide, onPinColumn, pinStyle, thStyle } from './columnChrome.rzts'

import { onGlobalFilterInput, onColumnFilterInput, globalFilterValue, pageIndex, pageSize, displayPageCount, canPrevPage, canNextPage, onPrevPage, onNextPage, onPageSizeChange, isSelectColumn, isExpanderColumn, rowCanExpand, rowIsExpanded, rowShowsDetail, onToggleExpand, bodyCellStyle, rowIsGrouped, groupingActive, cellIsGrouped, cellIsAggregated, cellIsPlaceholder, groupSubRowCount, groupingKeys, groupableColumns, stopEvent, isAllRowsSelected, isSomeRowsSelected, onToggleAllRows, rowIsSelected, onToggleRow, onHideColumn, hasAnyFilterableColumn } from './filterPaginationRowChrome.rzts'
// `indeterminate` is a DOM PROPERTY, not an HTML attribute — a `:indeterminate="…"`
// binding only takes effect on Vue (which binds known DOM props); on
// React/Solid/Angular/Lit/Svelte it lands as an inert attribute and `el.indeterminate`
// stays false. So set it IMPERATIVELY: query the select-all checkbox off the component
// root ($el — post-mount safe) and assign the property. Called from refreshRowModel
// (every selection change re-pulls the row model) so it stays in lockstep with the
// table-core selection state. The select-all box is NOT re-created by a selection
// change (only its checked attr flips), so the live element persists.
// `box` is aliased through a module-scope null-let (typeNeutralize → `any`) so the
// strict bundled-leaf tsc accepts `.indeterminate` (querySelector returns `Element`,
// which has no `indeterminate` — it is an HTMLInputElement DOM property). Same idiom
// as Column's `let reg = null; reg = $inject(...)`.
let selectAllBox = null
const syncIndeterminate = () => {
  if (!$el || !$el.querySelector) return
  selectAllBox = $el.querySelector('.rdt-select-all')
  if (selectAllBox) selectAllBox.indeterminate = isSomeRowsSelected() && !isAllRowsSelected()
}

// The registry API handed to <Column> children (whole-object-replace — T-48-PP guard).
$provide('data-table:columns', {
  registerColumn: (id, spec) => {
    if (id == null) return
    const key = String(id)
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') return
    $data.colReg = { ...$data.colReg, [key]: spec }
  },
  unregisterColumn: (id) => {
    if (id == null) return
    const r = { ...$data.colReg }
    delete r[String(id)]
    $data.colReg = r
  },
})

import { sortColumn, clearSorting, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, getRowIndexRelativeToPage, cut } from './exposeStateVerbs.rzts'

import { isGrid, tableRole, cellRole, rowIndexOf, colIndexOf, headerColIndexOf, pageRowOffset, toAbsRow, prePaginationRowCount, cellTabindex, isActiveCell, resolveCellEl, focusActiveCell, totalRowCount, headerRowCount, gridAriaRowCount, bodyAriaRowIndex, visibleColCount, bodyRowCount, clamp, moveCol, moveRow, gotoColEdge, gotoRowEdge, gotoStart, gotoEnd, currentCellEl, focusables, enterControl, cycleWithinCell } from './gridFocusNav.rzts'

import { onGridKeyDown, syncActiveFromEvent, onGridMouseDown, onGridDblClick, onGridClick, onGridFocusOut, clampActiveCell } from './gridKeydownHandlers.rzts'

// B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
// flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
// plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
// is reached through the mount-time refreshRowModel closure, so a `$data.gridEmptyFallback` READ
// there binds the async-stale mount-time value on React (setState is async — the rangeActive /
// pendingEditFollow / B23-nextRows stale-read class). With the body re-populated after a filter
// CLEAR, that stale read skipped the recovery branch on React → the roving tab-stop stayed on the
// header fallback (columnheader) instead of re-seating a body cell (the B6 recovery gap). A
// synchronously-written plain `let` is read fresh on all six → the empty→non-empty recovery
// re-seats activeRow 0 on React too. The other 5 targets are byte-behaviorally identical (they
// already read reactive $data synchronously). A top-level reassigned `let` referenced from the
// refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
let gridEmptyFallback = false

// ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
// A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
// (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
// highlight reattaches to the correct cells across virtualization recycling (the
// activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
// range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
// the row-selection slice (the two never touch each other's state).

// inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
// rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
// the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
// rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
// range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
// (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
// range we just set. The flag suppresses that collapse for the in-flight focus settle (the
// editTransition blur-guard precedent). A top-level let → React hoists to useRef.
let rangeTransition = false
// rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
// pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
// to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
let rangeClickPending = false
// B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
// true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
// arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
// on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
// `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
// range-change. This module-let is written synchronously (no setState async), so the second
// clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
// across all six targets. A top-level let → React hoists to useRef.
let rangeActive = false

import { inRange, getSelectedRange, isFillHandleCell, emitRangeChange, extendRange, setRangeFocus, selectAllBody, clearRange, clampRange } from './rangeSelection.rzts'

import { announce, clipboardActiveAllowed, fieldOfColId, normalizedRange, escapeTsvField, rangeToTsv, parseTsv, copyRange, applyGridToRange, rowOriginalAt, rowIdAt, tileGridToBox, pasteRange, cutRange, clearActiveRange, tileIndex, fillRange } from './clipboardFill.rzts'

// onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
// range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
// cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
// pointermove extends the range to the cell under the pointer; pointerup commits the fill.
let fillDragging = false
// CR-04: track the live fill-drag document listeners in module-lets so $onUnmount can remove
// them if the component unmounts MID-DRAG (the `up` handler clears them on a normal release,
// but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
let fillDragMove = null
let fillDragUp = null
import { teardownFillDrag, cellIndexFromPoint, onFillHandlePointerDown } from './fillDrag.rzts'

// §6 (260709-3qt) drag-to-select — mirror the fill-drag listener discipline. rangeDragging gates
// the live gesture; rangeDragMove/rangeDragUp track the document pointermove/pointerup handlers so
// a mid-drag unmount ($onUnmount → teardownRangeDrag) can remove them (CR-04). rangeDragMoved flips
// true once the drag enters a DIFFERENT cell than its mousedown anchor; onGridClick reads it to
// suppress a singleClickEdit editor-open after a drag (reset per-gesture in beginRangeDrag). Each
// top-level let → React hoists to useRef.
let rangeDragging = false
let rangeDragMove = null
let rangeDragUp = null
let rangeDragMoved = false
import { teardownRangeDrag, beginRangeDrag } from './dragSelect.rzts'

import { activeCellColumnId, isActiveCellEditable, isEditing, cellAriaInvalid, runValidator, setInvalid, replaceRowValue, sourceIndexOfRow, editingColumnId, editingColumnField, editingCellValue, editingRowOriginal, editingRowId, focusEditorWhenReady, columnIdAt, cellValueAt, beginEdit, focusCellWhenReady, indexOfRowIn, endEdit, endRowEdit, editorAutofocusFor, coerceCellValue, commitEdit, toggleActiveBooleanCell, cancelEdit } from './editCellLifecycle.rzts'

import { editableColumnsForRow, focusRowEditorAt, beginRowEdit, commitRow, cancelRow, replaceRowValues, nextEditableCell, prevEditableCell } from './editRowLifecycle.rzts'

// Transient guard: true while an editor commit/cancel/Tab-advance is tearing the current
// editor down. The unmounting editor fires a `blur` as it leaves the DOM — without this
// guard onEditorBlur would re-enter commitEdit on the (already-resolved or newly-opened)
// cell, double-counting cell-edit-commit. A top-level `let` (React hoists to useRef).
let editTransition = false

// B23: a pending "follow the committed row's focus" request, set by commitEdit (a single-cell
// commit that may relocate the row under an active sort/filter) and consumed ONCE by the next
// refreshRowModel pass — which runs with the FRESH re-derived row model, so it can resolve the
// committed row's NEW display index (React-stale-safe) and re-seat focus there. Shape:
// { rowOriginal, rowId, col } or null. A top-level `let` (React hoists to useRef → persists).
let pendingEditFollow = null

// Sync idempotency latch for a cell commit (drop-in double cell-edit-commit fix, 260705):
// commitEdit's `$data.editingRow < 0` re-entry guard is ASYNC-STALE on React — a deferred
// drop-in editor's unmount-blur (onBlur → $props.commit → commitEdit) fires AFTER commitEdit
// has already returned (editTransition is a SYNC latch, cleared before the async blur), while
// `$data.editingRow` in that stale closure still reads the OLD (pre-endEdit) value, so the
// second commit slips through and re-emits `cell-edit-commit`. A top-level `let` is written/read
// synchronously by plain assignment (unaffected by React's setState batching — that's the point)
// so it stays correct across the async window editTransition/editingRow cannot cover. Set true on
// a SUCCESSFUL commitEdit/toggleActiveBooleanCell; reset to false wherever a NEW edit session
// begins (beginEdit/beginRowEdit/editCell) so the next legitimate commit fires exactly once.
// A top-level `let` (React hoists to useRef → persists).
let committedThisSession = false

// ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
// In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
// (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
// rowDraft — so the four editors open simultaneously never clobber one shared value. These
// helpers let the ONE editor template branch serve BOTH modes (no per-mode template fork):
// the template binds editorValueFor(colId)/editorCheckedFor(colId) and writes via
// onCellEditorInput(colId, evt)/onCellEditorCheckbox(colId, evt).
import { inRowEdit, editorValueFor, editorCheckedFor, editorCommitFor, editorCancelFor, onCellEditorInput, onCellEditorCheckbox, setRowDraft, onEditorKeyDown, onEditorBlur, editCell, commitEditing, editRow } from './editorBindings.rzts'

import { focusCell, getActiveCell, clearActiveCell } from './gridActiveCellVerbs.rzts'

import { toggleRowExpanded, expandAll, collapseAll, getExpandedRows } from './expand.rzts'
import { applyGrouping, clearGrouping } from './group.rzts'
import { getFacetedUniqueValues, getFacetedMinMaxValues } from './facet.rzts'

$expose({
  sortColumn,
  clearSorting,
  toggleRowExpanded,
  expandAll,
  collapseAll,
  getExpandedRows,
  applyGrouping,
  clearGrouping,
  getFacetedUniqueValues,
  getFacetedMinMaxValues,
  getColumnDefs,
  toggleAllRows,
  clearSelection,
  getSelectedRows,
  setPage,
  setRowsPerPage,
  toggleColumnVisibility,
  applyColumnOrder,
  resetColumnSizing,
  pinColumn,
  focusCell,
  getActiveCell,
  clearActiveCell,
  getRowIndexRelativeToPage,
  editCell,
  commitEditing,
  editRow,
  getSelectedRange,
  cut,
  // 260709-8ct: undo/redo/canUndo/canRedo/clearHistory are already component-scope PRE-
  // DECLARED consts via the undoHistory.rzts import above — referenced BY NAME directly,
  // WITHOUT a pass-through wrapper in exposeStateVerbs.rzts (unlike `cut`, which delegates to
  // clipboardFill's differently-named `cutRange`): no rename/indirection is needed since these
  // five names are already collision-safe (ROZ121/124/137) and match the public verb name.
  undo,
  redo,
  canUndo,
  canRedo,
  clearHistory,
})
</script>

<template>
<!--
  Semantic table markup. The header sort BUTTON carries :aria-sort reflecting the live
  table-core sort state; @click toggles (shift-click adds a secondary sort). The body
  is a keyed r-for over the pulled row model; the per-row cell r-for loop var is `cell`
  — a loop var named the same as the `#cell` child slot is now AUTO-FIXED by the
  Svelte emitter (Phase 73 item #1): `findRForSlotNameCollisions` detects the shadow
  and renames the lowered snippet binding to `cell$$slot` (author's loop var + slot-arg
  values untouched), so the natural name is safe to author directly. The per-cell
  render is a single `#cell` SCOPED SLOT rendered DIRECTLY inside the keyed <td> (D-A:
  no portal, no projection); the consumer switches on the `columnId` scope param to
  vary the render per column, and the slot FALLBACK (`{{ value }}`) is the plain
  accessor value (the template-less fast path). Same for the `#header` scoped slot in
  the <th>.
-->
<div class="rozie-data-table-wrap">
<!-- Declarative <Column> children mount here (the DEFAULT slot). They are
     renderless (each Column's own root is display:none + draws nothing), but they
     MUST be in the rendered tree so their $onMount runs and registers the column
     spec into the parent's `data-table:columns` registry via $inject. Without a
     default <slot/> the <Column> children passed as default-slot content have
     nowhere to mount → they never register → the table renders ZERO columns
     (headers + cells empty). Hidden so it adds no visible chrome (the rete
     FlowCanvas declarative-children precedent: config children stay in the normal
     child tree so $inject resolves tree-scoped). -->
<div class="rdt-column-defs" style="display:none" aria-hidden="true"><slot /></div>
<!-- Validation aria-live region (phase 51 req-5/D-01): a visually-hidden polite status
     region announcing the current editor validation error. Gated `r-if="!!$data.invalidMsg"`
     so it renders ONLY while an editor holds an invalid value — a table with no editable
     columns (invalidMsg always '') never emits this node (byte-identical-off, req-10). -->
<div class="rdt-sr-live" role="status" aria-live="polite" aria-atomic="true" r-if="!!$data.invalidMsg">{{ $data.invalidMsg }}</div>
<!-- Clipboard paste aria-live region (phase 51 req-8/D-03): a visually-hidden polite status
     region announcing the "N of M cells pasted" summary after a TSV paste / drag-fill. A
     SEPARATE node from the validation region (different semantics + lifetime). Gated
     `r-if="!!$data.pasteAnnounce"` so a table that never pasted emits nothing (byte-identical-off). -->
<div class="rdt-sr-live rdt-sr-paste" data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true" r-if="!!$data.pasteAnnounce">{{ $data.pasteAnnounce }}</div>
<!-- Sort/filter aria-live region (#14): a visually-hidden polite status region announcing
     sort + filter changes (silent reorders/removals are otherwise invisible to a screen
     reader). SEPARATE node from the validation + paste regions (different semantics). Gated
     `r-if="!!$data.liveAnnounce"` so a never-sorted/never-filtered table emits nothing
     (byte-identical-off). Fed by the lazy sort/filter $watch (no spurious mount announce). -->
<div class="rdt-sr-live rdt-sr-sortfilter" data-testid="sortfilter-announce" role="status" aria-live="polite" aria-atomic="true" r-if="!!$data.liveAnnounce">{{ $data.liveAnnounce }}</div>
<!-- Global search (req-5) — bound, value-driven (NOT eval'd). Narrows all columns. -->
<div class="rdt-toolbar">
  <input
    class="rdt-global-filter"
    type="text"
    role="searchbox"
    aria-label="Search table"
    :value="globalFilterValue()"
    @input="onGlobalFilterInput($event)"
  />
  <!-- Column-visibility toggle menu (req-8) — a native <details>/<summary> disclosure
       (keyboard-reachable, no JS open-state) with one checkbox per leaf column. Toggling
       a checkbox drives table-core's column.toggleVisibility → visibility-change. -->
  <details class="rdt-colvis" r-if="allLeafColumns().length">
    <summary class="rdt-colvis-summary">Columns</summary>
    <div class="rdt-colvis-menu" role="group" aria-label="Toggle columns">
      <label
        class="rdt-colvis-item"
        r-for="lc in allLeafColumns()"
        :key="lc.id"
      >
        <input
          type="checkbox"
          class="rdt-colvis-checkbox"
          :checked="lc.visible"
          @change="onToggleVisibility(lc.id)"
        />
        <span class="rdt-colvis-label">{{ lc.label }}</span>
      </label>
    </div>
  </details>
</div>

<!-- HEADLESS group bar (phase 50 req-6, D-02 REVISED) — the #groupBar scoped slot, mirroring
     the #cell plain-scoped-slot lowering (no portal/projection; React render-prop edge is the
     documented divergence). Gated on $props.groupable so the host <div> is ABSENT for any
     non-grouping consumer (byte-identical-off, req-10). Slot props: the live ordered `grouping`
     array, the `groupableColumns` (`[{ id, label }]`), and the `applyGrouping`/`clearGrouping`
     helpers (the same $expose verbs) so a consumer builds ANY bar/drag UI themselves. The DEFAULT
     render is a NON-INTERACTIVE styled-token reflection of the grouping state (or empty when
     ungrouped) — the component renders NO draggable / drag handle (D-02 retired). -->
<div class="rdt-group-bar-host" r-if="$props.groupable">
  <slot
    name="groupBar"
    :grouping="groupingKeys()"
    :groupableColumns="groupableColumns()"
    :applyGrouping="applyGrouping"
    :clearGrouping="clearGrouping"
  >
    <span
      class="rdt-group-token"
      data-group-token
      r-for="gk in groupingKeys()"
      :key="gk"
    >{{ gk }}</span>
  </slot>
</div>

<!-- ══ VIRTUAL (phase 53): scroll-bounded WINDOWED table — emitted ONLY when $props.virtual ══
     (D-05). The rdt-scroll wrapper bounds the scroll viewport (max-height from the maxHeight
     prop inline AND mirrored to --rozie-data-table-max-height; the CSS rule supplies the
     token-only fallback — D-06, prop wins). aria-rowcount = the FULL model count (req-6). The
     <tbody> renders a leading spacer <tr>, the windowed { vi, row } slice keyed on the FULL-model
     row.id (Pitfall 3 / req-10 — NOT vi.index), and a trailing spacer <tr>. Because this whole
     branch is r-if and the r-else table below is the verbatim pre-phase markup, the non-virtual
     emitted render is byte-identical (req-1). -->
<div
  class="rdt-scroll"
  r-if="$props.virtual"
  :style="$props.maxHeight ? ('max-height:' + $props.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + $props.maxHeight) : 'overflow:auto'"
>
<table
  class="rozie-data-table"
  :class="{ 'rdt-sticky': $props.stickyHeader }"
  :role="tableRole()"
  :aria-rowcount="gridAriaRowCount()"
  @keydown="onGridKeyDown($event)"
  @focusin="syncActiveFromEvent($event)"
  @focusout="onGridFocusOut($event)"
  @mousedown="onGridMouseDown($event)"
  @dblclick="onGridDblClick($event)"
  @click="onGridClick($event)"
>
  <thead class="rdt-thead" role="rowgroup">
    <tr
      class="rdt-tr"
      role="row"
      r-for="(hg, hgLevel) in $data.headerGroups"
      :key="hg.id"
      :aria-rowindex="hgLevel + 1"
    >
      <th
        class="rdt-th"
        role="columnheader"
        r-for="header in hg.headers"
        :key="header.id"
        :data-col="header.column.id"
        data-grid-cell
        data-row="__header"
        :data-header-level="hgLevel"
        :colspan="header.colSpan > 1 ? header.colSpan : null"
        :data-col-index="headerColIndexOf(hg, header)"
        :tabindex="cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)"
        :class="{ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-expander-th': isExpanderColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg, header), hgLevel) }"
        :aria-sort="ariaSortFor(header.column.id)"
        :style="thStyle(header.column.id)"
      >
        <span style="display:contents" r-if="isSelectColumn(header.column.id)">
          <slot
            name="selectAll"
            :checked="isAllRowsSelected()"
            :indeterminate="isSomeRowsSelected()"
            :toggle="onToggleAllRows"
          >
            <input
              r-if="$props.selectionMode === 'multiple'"
              class="rdt-select-all"
              type="checkbox"
              aria-label="Select all rows"
              :checked="isAllRowsSelected()"
              @change="onToggleAllRows($event)"
            />
          </slot>
        </span>
        <!-- auto-injected expander column (D-04) is CHROME — its header stays EMPTY.
             Without this branch it falls into the generic-column r-else below and renders
             its raw internal id ('__rdt_expander') as the label, plus stray pin-controls and
             a resize handle that don't belong on a chevron column. -->
        <span style="display:contents" r-else-if="isExpanderColumn(header.column.id)"></span>
        <span style="display:contents" r-else>
          <button
            r-if="header.column.getCanSort && header.column.getCanSort()"
            type="button"
            class="rdt-sort-btn"
            @click="onHeaderSort(header.column.id, $event)"
          >
            <span class="rdt-header-label">
              <slot
                name="colHeader"
                :columnId="header.column.id"
                :column="header.column"
                :label="headerLabel(header.column.id)"
              >{{ headerLabel(header.column.id) }}</slot>
            </span>
            <span class="rdt-sort-ind" aria-hidden="true">{{ sortIndicator(header.column.id) }}</span>
          </button>
          <span style="display:contents" r-else>
            <span class="rdt-header-label">
              <slot
                name="colHeader"
                :columnId="header.column.id"
                :column="header.column"
                :label="headerLabel(header.column.id)"
              >{{ headerLabel(header.column.id) }}</slot>
            </span>
          </span>
          <!-- Header ⋯ menu (phase 72, D-01/D-05/D-06): the composed Popover (Option A — the
               published `@rozie-ui/popover-<target>` package) replaces the old always-visible
               three-button pin cluster with ONE per-column menu. Popover
               self-manages its own open state (uncontrolled) — DataTable tracks NO per-column
               open state. trigger=click / placement=bottom-end right-aligns the panel under the
               ⋯ button; strategy=fixed escapes the table's scroll/sticky overflow ancestor. The
               inline rdt-col-filter input + #filter slot host that lived here are REMOVED — they
               relocate to the dedicated filter row (72-05); filtering is intentionally offline
               until that wave lands. -->
          <Popover trigger="click" placement="bottom-end" strategy="fixed" :offset="4">
            <!-- No @click="toggle" here (72-06 fix): trigger="click" already wires a
                 click handler onto Popover's OWN .rozie-popover-anchor wrapper div, which
                 is a TRUE DOM ancestor of this button (both the property-fill-rendered
                 anchor content on Lit AND the plain-slotted anchor on the other 5 targets).
                 A click on this button bubbles to that wrapper and toggles once already —
                 mirrors the working PopoverBehaviorDemo precedent (plain anchor button, no
                 handler of its own). Redundantly ALSO calling the destructured `toggle`
                 here double-fires per click: on synchronous-reactivity targets (Lit
                 verified; Vue/Svelte/Solid/Angular reasoned-identical) the two toggles
                 cancel out and the menu never opens; React's batched state updates
                 happened to mask the cancellation, which is why the 72-03 happy-dom
                 probe (react-only) didn't catch it. -->
            <template #anchor>
              <button
                type="button"
                class="rdt-col-menu-trigger"
                :aria-label="'Column options for ' + headerLabel(header.column.id)"
              >⋯</button>
            </template>
            <div class="rdt-col-menu" role="menu">
              <button
                type="button"
                role="menuitem"
                class="rdt-col-menu-item"
                :aria-pressed="columnPinSide(header.column.id) === 'left'"
                @click="onPinColumn(header.column.id, 'left', $event)"
              >Pin left</button>
              <button
                type="button"
                role="menuitem"
                class="rdt-col-menu-item"
                :aria-pressed="columnPinSide(header.column.id) === 'right'"
                @click="onPinColumn(header.column.id, 'right', $event)"
              >Pin right</button>
              <button
                type="button"
                role="menuitem"
                class="rdt-col-menu-item"
                :aria-pressed="!columnPinSide(header.column.id)"
                @click="onPinColumn(header.column.id, false, $event)"
              >Unpin</button>
              <hr class="rdt-col-menu-sep" />
              <button
                type="button"
                role="menuitem"
                class="rdt-col-menu-item"
                @click="onHideColumn(header.column.id, $event)"
              >Hide column</button>
            </div>
          </Popover>
          <button
            type="button"
            class="rdt-resize-handle"
            :aria-label="'Resize ' + headerLabel(header.column.id)"
            @pointerdown="onResizeStart(header.column.id, $event)"
            @touchstart="onResizeStart(header.column.id, $event)"
          ><span class="rdt-resize-grip" aria-hidden="true"></span></button>
        </span>
      </th>
    </tr>
    <!-- Dedicated filter row (phase 72, D-04/D-10) — the floating-filter pattern. Relocates
         the per-column filter input + #filter slot host removed from the header in 72-03,
         one cell per column, aligned under pinned columns. Gated on hasAnyFilterableColumn()
         so the whole row is ABSENT (byte-identical-off) when no column is filterable. Iterates
         the LEAF header group ($data.headerGroups[length-1].headers), NOT hg from the r-for
         above, so a multi-level-header table's filter cells align 1:1 with the body <td>
         columns rather than an intermediate spanning group. `role="presentation"` on each
         cell — a filter cell is not a column header. No data-grid-cell/data-row/tabindex —
         onGridKeyDown only intercepts a focused `[data-grid-cell]` (gridKeydownHandlers.rzts),
         so these inputs are naturally inert to the roving-tabindex grid and just tab normally
         (mirrors the rdt-spacer inert-row precedent, minus aria-hidden since these cells hold
         real focusable controls). -->
    <tr class="rdt-filter-row" r-if="hasAnyFilterableColumn()">
      <th
        class="rdt-filter-cell"
        role="presentation"
        r-for="header in $data.headerGroups[$data.headerGroups.length - 1].headers"
        :key="header.id"
        :style="pinStyle(header.column.id)"
      >
        <span style="display:contents" r-if="isSelectColumn(header.column.id)"></span>
        <span style="display:contents" r-else-if="isExpanderColumn(header.column.id)"></span>
        <span style="display:contents" r-else>
          <input
            r-if="columnIsFilterable(header.column.id) && !hasFilterSlot()"
            class="rdt-col-filter"
            type="text"
            :aria-label="'Filter ' + headerLabel(header.column.id)"
            :value="columnFilterValue(header.column.id)"
            @input="onColumnFilterInput(header.column.id, $event)"
            @click="stopEvent($event)"
          />
          <span style="display:contents" r-if="columnIsFilterable(header.column.id)">
            <slot
              name="filter"
              :columnId="header.column.id"
              :value="columnFilterValue(header.column.id)"
              :uniqueValues="getFacetedUniqueValues(header.column.id)"
              :minMax="getFacetedMinMaxValues(header.column.id)"
              :setFilter="setColumnFilter"
            />
          </span>
        </span>
      </th>
    </tr>
  </thead>

  <tbody class="rdt-tbody" role="rowgroup">
    <!-- Leading spacer (D-03): occupies the height of the rows scrolled past the top of the
         window. No data-grid-cell/data-index/role — not a navigable, measurable cell (Pitfall 5). -->
    <tr class="rdt-spacer" aria-hidden="true">
      <td :colspan="visibleColCount()" :style="'height:' + padTop() + 'px;padding:0;border:0'"></td>
    </tr>
    <!-- The windowed slice. Loop var `wr` = { vi, row }; keyed on the FULL-model row.id so Lit's
         repeat / Solid's For never recycle a node from one full-model row into another (req-10).
         data-row/data-index use vi.index (O(1), authoritative full-model index — NOT the O(n)
         rowIndexOf scan over the full model). The <td> cell loop mirrors the non-virtual body.
         B13 (phase 63, FULL PARITY): the windowed body now carries the SAME structural branches
         as the non-virtual body (~1711-1905) — the data-group-header/leaf/depth markers + the
         rdt-group-header class on the <tr>, the isExpanderColumn / cellIsGrouped <td> branches,
         and a conditional #detail <tr> sibling — so a virtual + grouping/expand grid renders at
         full parity (no silent feature drop). Wrapped in a <template r-for> (wrapper-free multi-
         root) so the data <tr> + the #detail <tr> ride one keyed iteration, exactly like the
         non-virtual D-04 expand seam. The verified-correct invariants are preserved: group rows
         render a FULL cell set (no colspan, data-col-index intact) and the #detail <tr> carries
         no data-row/data-index so it never shifts the windowed body's row indices. -->
    <template r-for="wr in windowedRows()" :key="wr.row.id">
    <tr
      class="rdt-tr"
      role="row"
      :data-row="wr.vi.index"
      :aria-rowindex="headerRowCount() + wr.vi.index + 1"
      :data-index="wr.vi.index"
      :data-pinned="wr.pinned ? 'true' : null"
      :data-depth="wr.row.depth"
      :data-group-header="rowIsGrouped(wr.row) ? wr.row.id : null"
      :data-group-leaf="(groupingActive() && !rowIsGrouped(wr.row)) ? wr.row.id : null"
      :aria-expanded="rowIsGrouped(wr.row) ? !!rowIsExpanded(wr.row) : null"
      :aria-selected="$props.selectionMode !== 'none' ? !!rowIsSelected(wr.row) : null"
      :aria-level="groupingActive() ? (wr.row.depth + 1) : null"
      :class="{ 'rdt-group-header': rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned }"
    >
      <td
        class="rdt-td"
        :role="cellRole()"
        r-for="cell in visibleCellsFor(wr.row)"
        :key="cell.id"
        :data-col="cell.column.id"
        data-grid-cell
        :data-row="wr.vi.index"
        :data-col-index="colIndexOf(wr.row, cell)"
        :tabindex="cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cell))"
        :class="{ 'rdt-select-td': isSelectColumn(cell.column.id), 'rdt-expander-td': isExpanderColumn(cell.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cell)), 'rdt-cell-active': isActiveCell(String(wr.vi.index), colIndexOf(wr.row, cell)) }"
        :style="bodyCellStyle(wr.row, cell.column.id)"
        :aria-invalid="cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cell))"
        :data-in-range="inRange(wr.vi.index, colIndexOf(wr.row, cell)) ? 'true' : null"
        :data-agg-cell="cellIsAggregated(cell) ? cell.column.id : null"
      >
        <!-- B13 (phase 63) — auto-injected chevron expander cell, windowed body. Identical
             shape to the non-virtual body (~1797): a native <button> (Enter/Space → click
             natively) gated on rowCanExpand(wr.row); aria-expanded reflects wr.row's expanded
             state. FIRST branch (matching the non-virtual order). -->
        <span style="display:contents" r-if="isExpanderColumn(cell.column.id)">
          <button
            r-if="rowCanExpand(wr.row)"
            type="button"
            class="rdt-expander"
            data-expander
            :aria-expanded="!!rowIsExpanded(wr.row)"
            :aria-label="rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row'"
            @click="onToggleExpand(wr.row, $event)"
          >{{ rowIsExpanded(wr.row) ? '▾' : '▸' }}</button>
        </span>
        <span style="display:contents" r-else-if="isSelectColumn(cell.column.id)">
          <slot
            name="selectCell"
            :row="wr.row.original"
            :checked="rowIsSelected(wr.row)"
            :toggle="(e) => onToggleRow(wr.row, e)"
          >
            <input
              class="rdt-select-row"
              type="checkbox"
              aria-label="Select row"
              :checked="rowIsSelected(wr.row)"
              @change="onToggleRow(wr.row, $event)"
            />
          </slot>
        </span>
        <!-- B13 (phase 63) — group-header cell, windowed body. Identical shape to the non-
             virtual body (~1840): the cellIsGrouped branch renders a native <button data-expander
             class="rdt-group-toggle"> (REUSING onToggleExpand — group rows are expandable rows) +
             the group key through the EXISTING #cell slot + the member count. -->
        <span style="display:contents" r-else-if="cellIsGrouped(cell)">
          <button
            type="button"
            class="rdt-expander rdt-group-toggle"
            data-expander
            :aria-expanded="!!rowIsExpanded(wr.row)"
            :aria-label="rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group'"
            @click="onToggleExpand(wr.row, $event)"
          >{{ rowIsExpanded(wr.row) ? '▾' : '▸' }}</button>
          <span class="rdt-group-value">
            <slot
              name="cell"
              :columnId="cell.column.id"
              :column="cell.column"
              :row="wr.row.original"
              :value="cell.getValue()"
            >{{ cell.getValue() }}</slot>
          </span>
          <span class="rdt-group-count">{{ '(' + groupSubRowCount(wr.row) + ')' }}</span>
        </span>
        <!-- Editor branch (phase 51) — virtual body. Identical shape to the non-virtual
             body; keyed on wr.vi.index (the full-model index, the activeRow space). Single-
             cell editing under virtualization (the D-02 pin-row) lands in Plan 51-04; this
             keeps both <td> bodies in lockstep so the branch is structurally consistent. -->
        <span style="display:contents" r-else-if="isEditing(wr.vi.index, colIndexOf(wr.row, cell))">
          <span style="display:contents" r-if="hasEditorSlot(cell.column.id)">
            <slot
              name="editor"
              :columnId="cell.column.id"
              :column="cell.column"
              :row="wr.row.original"
              :value="editorValueFor(cell.column.id)"
              :commit="editorCommitFor(cell.column.id)"
              :cancel="editorCancelFor()"
              :autofocus="editorAutofocusFor(cell.column.id, wr.vi.index)"
            />
          </span>
          <input
            r-else-if="editorTypeOf(cell.column.id) === 'number'"
            class="rdt-cell-editor"
            type="number"
            data-editing-cell
            :value="editorValueFor(cell.column.id)"
            @input="onCellEditorInput(cell.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
          <select
            r-else-if="editorTypeOf(cell.column.id) === 'select'"
            class="rdt-cell-editor"
            data-editing-cell
            :value="editorValueFor(cell.column.id)"
            @change="onCellEditorInput(cell.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          >
            <option r-for="opt in editorOptionsOf(cell.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
          </select>
          <input
            r-else-if="editorTypeOf(cell.column.id) === 'checkbox'"
            class="rdt-cell-editor"
            type="checkbox"
            data-editing-cell
            :checked="editorCheckedFor(cell.column.id)"
            @change="onCellEditorCheckbox(cell.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
          <input
            r-else
            class="rdt-cell-editor"
            type="text"
            data-editing-cell
            :value="editorValueFor(cell.column.id)"
            @input="onCellEditorInput(cell.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
        </span>
        <!-- Placeholder cell (phase grouping fix) — virtual body. Identical shape to the
             non-virtual body: an EMPTY node for a non-grouped, non-aggregated cell on a
             group-header row so table-core's leaked first-leaf getValue() never paints on the
             group line. Aggregated cells (getIsPlaceholder()===false) still flow through r-else. -->
        <span style="display:contents" r-else-if="cellIsPlaceholder(cell)"></span>
        <span r-else class="rdt-cell-value">
          <slot
            name="cell"
            :columnId="cell.column.id"
            :column="cell.column"
            :row="wr.row.original"
            :value="cell.getValue()"
          >{{ cell.getValue() }}</slot>
        </span>
        <!-- Fill handle (req-8 / D-04): the small draggable affordance on the range's
             bottom-right cell. A pointer drag extends the range + value-fills the dragged
             rectangle on release (value-copy ONLY — no series detection). Gated so it renders
             only on the corner cell (byte-identical-off when no range). -->
        <span
          r-if="isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cell))"
          class="rdt-fill-handle"
          data-fill-handle
          data-testid="fill-handle"
          aria-hidden="true"
          @pointerdown="onFillHandlePointerDown($event)"
        ></span>
      </td>
    </tr>
    <!-- B13 (phase 63) — #detail row, windowed body. Identical shape to the non-virtual
         body (~1948): renders the #detail scoped slot ({ row }) under an expanded row, ONLY in
         #detail mode (no getSubRows; rowShowsDetail gates it). Sibling of the data <tr> inside
         the same <template r-for> iteration. Carries NO data-row/data-index → it never shifts
         the windowed body's row indices (the verified-correct invariant). -->
    <tr
      r-if="rowShowsDetail(wr.row)"
      class="rdt-detail-row"
      role="row"
      :data-detail-row="wr.row.id"
    >
      <td class="rdt-detail-cell" :colspan="visibleColCount()">
        <slot name="detail" :row="wr.row.original" />
      </td>
    </tr>
    </template>
    <!-- Trailing spacer (D-03): occupies the height of the rows below the window. -->
    <tr class="rdt-spacer" aria-hidden="true">
      <td :colspan="visibleColCount()" :style="'height:' + padBottom() + 'px;padding:0;border:0'"></td>
    </tr>
  </tbody>
</table>
</div>
<!-- ══ NON-VIRTUAL (byte-identical to the pre-phase output): the bare table, NO scroll wrapper.
     This r-else branch lowers character-for-character to today's markup (req-1). ══ -->
<table
  r-else
  class="rozie-data-table"
  :class="{ 'rdt-sticky': $props.stickyHeader }"
  :role="tableRole()"
  :aria-rowcount="gridAriaRowCount()"
  @keydown="onGridKeyDown($event)"
  @focusin="syncActiveFromEvent($event)"
  @focusout="onGridFocusOut($event)"
  @mousedown="onGridMouseDown($event)"
  @dblclick="onGridDblClick($event)"
  @click="onGridClick($event)"
>
  <thead class="rdt-thead" role="rowgroup">
    <tr
      class="rdt-tr"
      role="row"
      r-for="(hg, hgLevel) in $data.headerGroups"
      :key="hg.id"
      :aria-rowindex="hgLevel + 1"
    >
      <th
        class="rdt-th"
        role="columnheader"
        r-for="header in hg.headers"
        :key="header.id"
        :data-col="header.column.id"
        data-grid-cell
        data-row="__header"
        :data-header-level="hgLevel"
        :colspan="header.colSpan > 1 ? header.colSpan : null"
        :data-col-index="headerColIndexOf(hg, header)"
        :tabindex="cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)"
        :class="{ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-expander-th': isExpanderColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg, header), hgLevel) }"
        :aria-sort="ariaSortFor(header.column.id)"
        :style="thStyle(header.column.id)"
      >
        <!-- auto-injected select-all header (D-04) — slot-overridable via #selectAll;
             default = a select-all checkbox (indeterminate when partial, D-06). The
             default select-all checkbox renders ONLY in 'multiple' mode — a 'single'
             select has no "select all" (it caps at ≤1), so its select-column header
             stays empty (the slot still fires for a consumer override). -->
        <!-- display:contents <span> wrappers instead of `<template r-if>` grouping —
             a `<template>` grouping wrapper renders as a LITERAL inert `<template>`
             DOM element on svelte/solid/lit/angular in this component's nesting (its
             children land in the .content DocumentFragment → never displayed → empty
             headers). A real element with r-if/r-else lowers cleanly on all six;
             display:contents keeps it layout-transparent. -->
        <span style="display:contents" r-if="isSelectColumn(header.column.id)">
          <slot
            name="selectAll"
            :checked="isAllRowsSelected()"
            :indeterminate="isSomeRowsSelected()"
            :toggle="onToggleAllRows"
          >
            <!-- `indeterminate` is a DOM PROPERTY (inert as a bound attribute on 5/6
                 targets) — set imperatively by syncIndeterminate(); do NOT bind it here
                 (React warns on an unknown `indeterminate` JSX attr; the others no-op). -->
            <input
              r-if="$props.selectionMode === 'multiple'"
              class="rdt-select-all"
              type="checkbox"
              aria-label="Select all rows"
              :checked="isAllRowsSelected()"
              @change="onToggleAllRows($event)"
            />
          </slot>
        </span>
        <!-- auto-injected expander column (D-04) is CHROME — its header stays EMPTY.
             Without this branch it falls into the generic-column r-else below and renders
             its raw internal id ('__rdt_expander') as the label, plus stray pin-controls and
             a resize handle that don't belong on a chevron column. -->
        <span style="display:contents" r-else-if="isExpanderColumn(header.column.id)"></span>
        <span style="display:contents" r-else>
          <!-- sortable header → a button that toggles sort (shift = multi). -->
          <button
            r-if="header.column.getCanSort && header.column.getCanSort()"
            type="button"
            class="rdt-sort-btn"
            @click="onHeaderSort(header.column.id, $event)"
          >
            <!-- #header scoped slot, dispatched by columnId; fallback = plain label. -->
            <span class="rdt-header-label">
              <slot
                name="colHeader"
                :columnId="header.column.id"
                :column="header.column"
                :label="headerLabel(header.column.id)"
              >{{ headerLabel(header.column.id) }}</slot>
            </span>
            <span class="rdt-sort-ind" aria-hidden="true">{{ sortIndicator(header.column.id) }}</span>
          </button>
          <!-- non-sortable header (display:contents span, NOT <template r-else>). -->
          <span style="display:contents" r-else>
            <span class="rdt-header-label">
              <slot
                name="colHeader"
                :columnId="header.column.id"
                :column="header.column"
                :label="headerLabel(header.column.id)"
              >{{ headerLabel(header.column.id) }}</slot>
            </span>
          </span>
          <!-- Header ⋯ menu (phase 72, D-01/D-05/D-06): the composed Popover (Option A — the
               published `@rozie-ui/popover-<target>` package) replaces the old always-visible
               three-button pin cluster with ONE per-column menu. Popover
               self-manages its own open state (uncontrolled) — DataTable tracks NO per-column
               open state. trigger=click / placement=bottom-end right-aligns the panel under the
               ⋯ button; strategy=fixed escapes the table's scroll/sticky overflow ancestor. The
               inline rdt-col-filter input + #filter slot host that lived here are REMOVED — they
               relocate to the dedicated filter row (72-05); filtering is intentionally offline
               until that wave lands. Identical composition to the virtual-branch twin above. -->
          <Popover trigger="click" placement="bottom-end" strategy="fixed" :offset="4">
            <!-- No @click="toggle" here (72-06 fix): trigger="click" already wires a
                 click handler onto Popover's OWN .rozie-popover-anchor wrapper div, which
                 is a TRUE DOM ancestor of this button (both the property-fill-rendered
                 anchor content on Lit AND the plain-slotted anchor on the other 5 targets).
                 A click on this button bubbles to that wrapper and toggles once already —
                 mirrors the working PopoverBehaviorDemo precedent (plain anchor button, no
                 handler of its own). Redundantly ALSO calling the destructured `toggle`
                 here double-fires per click: on synchronous-reactivity targets (Lit
                 verified; Vue/Svelte/Solid/Angular reasoned-identical) the two toggles
                 cancel out and the menu never opens; React's batched state updates
                 happened to mask the cancellation, which is why the 72-03 happy-dom
                 probe (react-only) didn't catch it. -->
            <template #anchor>
              <button
                type="button"
                class="rdt-col-menu-trigger"
                :aria-label="'Column options for ' + headerLabel(header.column.id)"
              >⋯</button>
            </template>
            <div class="rdt-col-menu" role="menu">
              <button
                type="button"
                role="menuitem"
                class="rdt-col-menu-item"
                :aria-pressed="columnPinSide(header.column.id) === 'left'"
                @click="onPinColumn(header.column.id, 'left', $event)"
              >Pin left</button>
              <button
                type="button"
                role="menuitem"
                class="rdt-col-menu-item"
                :aria-pressed="columnPinSide(header.column.id) === 'right'"
                @click="onPinColumn(header.column.id, 'right', $event)"
              >Pin right</button>
              <button
                type="button"
                role="menuitem"
                class="rdt-col-menu-item"
                :aria-pressed="!columnPinSide(header.column.id)"
                @click="onPinColumn(header.column.id, false, $event)"
              >Unpin</button>
              <hr class="rdt-col-menu-sep" />
              <button
                type="button"
                role="menuitem"
                class="rdt-col-menu-item"
                @click="onHideColumn(header.column.id, $event)"
              >Hide column</button>
            </div>
          </Popover>
          <!-- Resize handle (req-9) — a keyboard-reachable button positioned at the
               column's trailing edge; pointerdown/touchstart hand off to table-core's
               resize handler (columnResizeMode:'onChange' → live width delta →
               resize-change). The drag gesture state is owned by table-core, NOT a
               top-level scratch const (the React fragile-binding rule). -->
          <button
            type="button"
            class="rdt-resize-handle"
            :aria-label="'Resize ' + headerLabel(header.column.id)"
            @pointerdown="onResizeStart(header.column.id, $event)"
            @touchstart="onResizeStart(header.column.id, $event)"
          ><span class="rdt-resize-grip" aria-hidden="true"></span></button>
        </span>
      </th>
    </tr>
    <!-- Dedicated filter row (phase 72, D-04/D-10) — IDENTICAL to the virtual-branch twin
         above. See that comment for the full rationale (leaf-header-group iteration,
         gating, pin alignment, grid-nav inertness). -->
    <tr class="rdt-filter-row" r-if="hasAnyFilterableColumn()">
      <th
        class="rdt-filter-cell"
        role="presentation"
        r-for="header in $data.headerGroups[$data.headerGroups.length - 1].headers"
        :key="header.id"
        :style="pinStyle(header.column.id)"
      >
        <span style="display:contents" r-if="isSelectColumn(header.column.id)"></span>
        <span style="display:contents" r-else-if="isExpanderColumn(header.column.id)"></span>
        <span style="display:contents" r-else>
          <input
            r-if="columnIsFilterable(header.column.id) && !hasFilterSlot()"
            class="rdt-col-filter"
            type="text"
            :aria-label="'Filter ' + headerLabel(header.column.id)"
            :value="columnFilterValue(header.column.id)"
            @input="onColumnFilterInput(header.column.id, $event)"
            @click="stopEvent($event)"
          />
          <span style="display:contents" r-if="columnIsFilterable(header.column.id)">
            <slot
              name="filter"
              :columnId="header.column.id"
              :value="columnFilterValue(header.column.id)"
              :uniqueValues="getFacetedUniqueValues(header.column.id)"
              :minMax="getFacetedMinMaxValues(header.column.id)"
              :setFilter="setColumnFilter"
            />
          </span>
        </span>
      </th>
    </tr>
  </thead>

  <tbody class="rdt-tbody" role="rowgroup">
    <!-- D-04 expand seam (the canonical shape Wave 3 grouping reuses): each keyed iteration
         is a <template r-for> (wrapper-free multi-root, core e6ac185d) holding the data <tr>
         followed by a conditional #detail <tr>. getSubRows children arrive as ordinary
         depth-indented rows in $data.rows (NO nested r-for — Pitfall 1). -->
    <template r-for="row in $data.rows" :key="row.id">
    <tr
      class="rdt-tr"
      :class="{ 'rdt-group-header': rowIsGrouped(row) }"
      role="row"
      :data-depth="row.depth"
      :aria-rowindex="bodyAriaRowIndex(row)"
      :data-group-header="rowIsGrouped(row) ? row.id : null"
      :data-group-leaf="(groupingActive() && !rowIsGrouped(row)) ? row.id : null"
      :aria-expanded="rowIsGrouped(row) ? !!rowIsExpanded(row) : null"
      :aria-selected="$props.selectionMode !== 'none' ? !!rowIsSelected(row) : null"
      :aria-level="groupingActive() ? (row.depth + 1) : null"
    >
      <td
        class="rdt-td"
        :role="cellRole()"
        r-for="cell in visibleCellsFor(row)"
        :key="cell.id"
        :data-col="cell.column.id"
        data-grid-cell
        :data-row="rowIndexOf(row)"
        :data-col-index="colIndexOf(row, cell)"
        :tabindex="cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cell))"
        :class="{ 'rdt-select-td': isSelectColumn(cell.column.id), 'rdt-expander-td': isExpanderColumn(cell.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cell)), 'rdt-cell-active': isActiveCell(String(rowIndexOf(row)), colIndexOf(row, cell)) }"
        :style="bodyCellStyle(row, cell.column.id)"
        :aria-invalid="cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cell))"
        :data-in-range="inRange(rowIndexOf(row), colIndexOf(row, cell)) ? 'true' : null"
        :data-agg-cell="cellIsAggregated(cell) ? cell.column.id : null"
      >
        <!-- auto-injected chevron expander cell (phase 50, D-04) — a native <button>
             (handles Enter/Space → click natively; NO explicit @keydown.enter/.space which
             would double-toggle). aria-expanded reflects row.getIsExpanded(); the chevron
             renders only when row.getCanExpand(). -->
        <span style="display:contents" r-if="isExpanderColumn(cell.column.id)">
          <button
            r-if="rowCanExpand(row)"
            type="button"
            class="rdt-expander"
            data-expander
            :aria-expanded="!!rowIsExpanded(row)"
            :aria-label="rowIsExpanded(row) ? 'Collapse row' : 'Expand row'"
            @click="onToggleExpand(row, $event)"
          >{{ rowIsExpanded(row) ? '▾' : '▸' }}</button>
        </span>
        <!-- auto-injected per-row select checkbox (D-04/D-05) — slot-overridable via
             #selectCell; checkbox-only toggle (row body does NOT select). The
             conditional branches use `display:contents` <span> wrappers (NOT
             `<template r-if>` grouping): a `<template>` grouping wrapper renders as a
             LITERAL inert `<template>` DOM element on svelte/solid/lit/angular in this
             component's nesting (its children land in the template's .content
             DocumentFragment → never displayed → empty cells). A real element with
             r-if/r-else lowers cleanly on all six; `display:contents` keeps it
             layout-transparent so the <td> box is unaffected. -->
        <span style="display:contents" r-else-if="isSelectColumn(cell.column.id)">
          <slot
            name="selectCell"
            :row="row.original"
            :checked="rowIsSelected(row)"
            :toggle="(e) => onToggleRow(row, e)"
          >
            <input
              class="rdt-select-row"
              type="checkbox"
              aria-label="Select row"
              :checked="rowIsSelected(row)"
              @change="onToggleRow(row, $event)"
            />
          </slot>
        </span>
        <!-- ── Group-header cell (phase 50 req-4/5, D-04) — the grouped cell on a flattened
             group-header row (cell.getIsGrouped()). Renders a native <button data-expander>
             (REUSING the D-04 onToggleExpand path — group rows are expandable rows, no new
             collapse branch) + the group key through the EXISTING #cell slot (cell.getValue())
             + the member count. Aggregated cells (cell.getIsAggregated()) are NOT branched here
             — they flow through the #cell r-else below via cell.getValue() (NO aggregatedCell
             template); placeholder cells fall through to the same r-else and render empty. -->
        <span style="display:contents" r-else-if="cellIsGrouped(cell)">
          <button
            type="button"
            class="rdt-expander rdt-group-toggle"
            data-expander
            :aria-expanded="!!rowIsExpanded(row)"
            :aria-label="rowIsExpanded(row) ? 'Collapse group' : 'Expand group'"
            @click="onToggleExpand(row, $event)"
          >{{ rowIsExpanded(row) ? '▾' : '▸' }}</button>
          <span class="rdt-group-value">
            <slot
              name="cell"
              :columnId="cell.column.id"
              :column="cell.column"
              :row="row.original"
              :value="cell.getValue()"
            >{{ cell.getValue() }}</slot>
          </span>
          <span class="rdt-group-count">{{ '(' + groupSubRowCount(row) + ')' }}</span>
        </span>
        <!-- ── Editor branch (phase 51 req-1/2): display↔editor r-if INSIDE the keyed <td>.
             A `display:contents` <span> (NEVER a <template> — inert on 4 targets). The
             #editor scoped slot (req-2) takes a column declaring editor='custom'; every
             other editable column gets a built-in editor keyed on editorTypeOf. Each editor
             element carries data-editing-cell so focusEditorWhenReady resolves it off
             gridRoot (NEVER $refs.cellEditor — ROZ123 + last-ref-wins + Solid TDZ). -->
        <span style="display:contents" r-else-if="isEditing(rowIndexOf(row), colIndexOf(row, cell))">
          <span style="display:contents" r-if="hasEditorSlot(cell.column.id)">
            <slot
              name="editor"
              :columnId="cell.column.id"
              :column="cell.column"
              :row="row.original"
              :value="editorValueFor(cell.column.id)"
              :commit="editorCommitFor(cell.column.id)"
              :cancel="editorCancelFor()"
              :autofocus="editorAutofocusFor(cell.column.id, rowIndexOf(row))"
            />
          </span>
          <input
            r-else-if="editorTypeOf(cell.column.id) === 'number'"
            class="rdt-cell-editor"
            type="number"
            data-editing-cell
            :value="editorValueFor(cell.column.id)"
            @input="onCellEditorInput(cell.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
          <select
            r-else-if="editorTypeOf(cell.column.id) === 'select'"
            class="rdt-cell-editor"
            data-editing-cell
            :value="editorValueFor(cell.column.id)"
            @change="onCellEditorInput(cell.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          >
            <option r-for="opt in editorOptionsOf(cell.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
          </select>
          <input
            r-else-if="editorTypeOf(cell.column.id) === 'checkbox'"
            class="rdt-cell-editor"
            type="checkbox"
            data-editing-cell
            :checked="editorCheckedFor(cell.column.id)"
            @change="onCellEditorCheckbox(cell.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
          <input
            r-else
            class="rdt-cell-editor"
            type="text"
            data-editing-cell
            :value="editorValueFor(cell.column.id)"
            @input="onCellEditorInput(cell.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
        </span>
        <!-- Placeholder cell (phase grouping fix): a non-grouped, non-aggregated cell on a
             group-header row. table-core's cell.getIsPlaceholder() fills it with the FIRST leaf
             row's value, so cell.getValue() would leak a leaf value (e.g. "Services") onto the
             group line. Render an EMPTY node so the group row shows only the grouped key + count
             + any aggregated cells. Placed AFTER the editor branch, BEFORE the default #cell
             r-else; aggregated cells (getIsPlaceholder()===false) still flow through the r-else. -->
        <span style="display:contents" r-else-if="cellIsPlaceholder(cell)"></span>
        <!-- #cell scoped slot rendered DIRECTLY in the framework-owned <td> (D-A: no
             portal, no projection). Dispatched by columnId; the slot FALLBACK is the
             plain accessor value (the template-less fast path). -->
        <span r-else class="rdt-cell-value">
          <slot
            name="cell"
            :columnId="cell.column.id"
            :column="cell.column"
            :row="row.original"
            :value="cell.getValue()"
          >{{ cell.getValue() }}</slot>
        </span>
        <!-- Fill handle (req-8 / D-04) — non-virtual body. Identical shape to the virtual
             body; keyed on rowIndexOf(row). Renders only on the range's bottom-right corner. -->
        <span
          r-if="isFillHandleCell(rowIndexOf(row), colIndexOf(row, cell))"
          class="rdt-fill-handle"
          data-fill-handle
          data-testid="fill-handle"
          aria-hidden="true"
          @pointerdown="onFillHandlePointerDown($event)"
        ></span>
      </td>
    </tr>
    <!-- #detail row (phase 50 req-2, D-04): the ONLY genuinely additive DOM row — renders
         the #detail scoped slot ({ row }) under an expanded row, ONLY in #detail mode (no
         getSubRows; rowShowsDetail gates it). The React render-prop edge (documented
         divergence). Sibling of the data <tr> inside the same <template r-for> iteration. -->
    <tr
      r-if="rowShowsDetail(row)"
      class="rdt-detail-row"
      role="row"
      :data-detail-row="row.id"
    >
      <td class="rdt-detail-cell" :colspan="visibleColCount()">
        <slot name="detail" :row="row.original" />
      </td>
    </tr>
    </template>
  </tbody>
</table>

<!-- Pagination chrome (req-6) — prev/next + page-size select. Page indicator is
     1-based for display; pageIndex() is 0-based. SUPPRESSED when virtual (req-9): windowing
     renders the full pre-pagination model, so the pagination chrome is meaningless — gated by
     `!$props.virtual`. The D-07 dev-mode warn (in $onMount) fires alongside this suppression
     when the consumer ALSO configured client pagination. -->
<div class="rdt-pagination" role="group" aria-label="Pagination" r-if="!$props.virtual">
  <button
    type="button"
    class="rdt-page-btn rdt-page-prev"
    :disabled="!canPrevPage()"
    @click="onPrevPage()"
  >Prev</button>
  <span class="rdt-page-status" aria-live="polite">
    {{ 'Page ' + (pageIndex() + 1) + ' of ' + displayPageCount() }}
  </span>
  <button
    type="button"
    class="rdt-page-btn rdt-page-next"
    :disabled="!canNextPage()"
    @click="onNextPage()"
  >Next</button>
  <select
    class="rdt-page-size"
    aria-label="Rows per page"
    :value="pageSize()"
    @change="onPageSizeChange($event)"
  >
    <option :value="10">10</option>
    <option :value="25">25</option>
    <option :value="50">50</option>
    <option :value="100">100</option>
  </select>
</div>
</div>
</template>

<style>
.rozie-data-table {
  border-collapse: collapse;
  width: 100%;
  font: var(--rdt-font, 14px system-ui, sans-serif);
  color: var(--rdt-color, inherit);
}
/* Validation aria-live region (req-5/D-01): visually hidden, announced to AT. */
.rdt-sr-live {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
/* Built-in cell editor (req-1): inline, full-cell-width input/select. */
.rozie-data-table .rdt-cell-editor {
  font: inherit;
  width: 100%;
  box-sizing: border-box;
}
/* Invalid-state cell highlight (req-5/D-01). */
.rozie-data-table .rdt-td[aria-invalid="true"] {
  outline: var(--rdt-invalid-outline, 2px solid #d33);
  outline-offset: -2px;
}
/* Cell-range selection highlight (req-7 / D-07): a SEPARATE visual layer from the
   row-selection slice — a background tint marking the rectangular range. Index-driven
   (inRange()), so it reattaches to the correct cells across virtualization recycling. */
.rozie-data-table .rdt-td.rdt-in-range {
  background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
}
/* Active-cell ring (grid pointer §1, 260708-ni6): a STATE-DRIVEN cell outline (bound via
   isActiveCell → the `.rdt-cell-active` :class), so the ring shows identically on click AND
   keyboard — independent of :focus-visible (which browsers suppress for a mouse-focused
   non-text <td>, the ring bug this fixes). Composes with the aria-invalid outline + the
   in-range tint (distinct tokens). Themes may override --rdt-active-cell-outline. */
.rozie-data-table .rdt-td.rdt-cell-active,
.rozie-data-table .rdt-th.rdt-cell-active {
  outline: var(--rdt-active-cell-outline, 2px solid #2563eb);
  outline-offset: -2px;
}
/* Fill handle (req-8 / D-04): a small draggable square anchored at the range's bottom-right
   corner. The owning <td> is positioned relative so the handle sits in its corner. */
.rozie-data-table .rdt-td {
  position: relative;
}
.rozie-data-table .rdt-fill-handle {
  position: absolute;
  right: -3px;
  bottom: -3px;
  width: 8px;
  height: 8px;
  background: var(--rdt-fill-handle-bg, #2563eb);
  border: 1px solid #fff;
  cursor: crosshair;
  z-index: 1;
  touch-action: none;
}
.rozie-data-table .rdt-th,
.rozie-data-table .rdt-td {
  padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
  text-align: left;
  border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table .rdt-thead .rdt-th {
  font-weight: var(--rdt-header-weight, 600);
  /* OPAQUE default (was rgba(0,0,0,0.03)): a translucent header lets the scrolling body
     bleed through in sticky mode. #f7f7f7 is the visual equivalent of the old 3%-black
     tint over white, but solid. The three design-system themes already ship opaque
     header backgrounds; this makes the zero-config default consistent with them. */
  background: var(--rdt-header-bg, #f7f7f7);
}
.rozie-data-table .rdt-sort-btn {
  display: inline-flex;
  align-items: center;
  gap: var(--rdt-sort-gap, 0.35em);
  background: none;
  border: none;
  font: inherit;
  font-weight: inherit;
  color: inherit;
  cursor: pointer;
  padding: 0;
}
.rozie-data-table .rdt-sort-ind {
  font-size: 0.8em;
  opacity: var(--rdt-sort-ind-opacity, 0.7);
}
/* Sticky header (req-12) — token-driven, gated by the stickyHeader prop. The background
   is NOT re-declared here: --rdt-header-bg is OPAQUE (see base.css), so the base
   `.rdt-thead .rdt-th` background rule already gives sticky cells an opaque backdrop the
   scrolling body can't bleed through. A translucent header-bg would show through — that
   is why the default was made opaque rather than left at rgba(0,0,0,0.03). */
.rozie-data-table.rdt-sticky .rdt-thead .rdt-th {
  position: sticky;
  top: var(--rdt-sticky-top, 0);
  z-index: var(--rdt-sticky-z, 2);
}
/* Vertical row windowing (phase 53, req-3/D-06) — the bounded scroll viewport, emitted ONLY
   when virtual. The maxHeight prop sets max-height inline (and mirrors the custom property);
   this rule supplies the TOKEN-ONLY fallback so a consumer can bound the container purely via
   --rozie-data-table-max-height without passing the prop. overflow:auto enables row windowing. */
.rozie-data-table-wrap .rdt-scroll {
  max-height: var(--rozie-data-table-max-height);
  overflow: auto;
}
/* Headless group-bar host (phase 50 req-6, D-02 REVISED) — token-driven, NON-interactive.
   The default render is a styled-token reflection of the grouping state; the component ships
   NO drag affordance (consumers build any bar/drag UI from the #groupBar slot props). */
.rozie-data-table-wrap .rdt-group-bar-host {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--rdt-group-bar-gap, 0.375rem);
}
.rozie-data-table-wrap :deep(.rdt-group-token) {
  display: inline-flex;
  align-items: center;
  padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
  border-radius: var(--rdt-group-token-radius, 999px);
  background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
  font-size: var(--rdt-group-token-size, 0.8125em);
}
/* Lit cross-shadow: a slotted GroupBar drop-in renders inside DataTable's shadow tree
   (this.groupBar(params) render-function path), but its tokens live in the drop-in's OWN
   shadow root, which :deep() can't pierce. The drop-in exposes them as part="group-token";
   ::part() reaches across. Mirrors the :deep(.rdt-group-token) box above. Inert on the
   other five targets (no shadow boundary → no exposed parts). See the ::part(col-filter)
   rule below for the same pattern on the filter drop-ins. */
::part(group-token) {
  display: inline-flex;
  align-items: center;
  padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
  border-radius: var(--rdt-group-token-radius, 999px);
  background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
  font-size: var(--rdt-group-token-size, 0.8125em);
}
/* Group-header rows + grouped-cell chrome (phase 50 req-4/5) — group rows ride the D-04 seam. */
.rozie-data-table .rdt-group-header {
  background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
  font-weight: var(--rdt-group-header-weight, 600);
}
.rozie-data-table .rdt-group-toggle {
  margin-right: var(--rdt-group-toggle-gap, 0.375rem);
}
.rozie-data-table .rdt-group-count {
  margin-left: var(--rdt-group-count-gap, 0.375rem);
  opacity: var(--rdt-group-count-opacity, 0.65);
  font-weight: 400;
}
/* Filter / pagination chrome (req-5, req-6) — token-driven. */
.rozie-data-table-wrap {
  display: flex;
  flex-direction: column;
  gap: var(--rdt-chrome-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-toolbar {
  display: flex;
  gap: var(--rdt-toolbar-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-global-filter,
.rozie-data-table-wrap :deep(.rdt-col-filter) {
  font: inherit;
  /* border-box so the padding + border count INSIDE the declared width — without it
     the col-filter's `width: 100%` + padding overflows its (constrained) header cell. */
  box-sizing: border-box;
  padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
  border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-filter-radius, 4px);
  background: var(--rdt-filter-bg, transparent);
  color: inherit;
}
.rozie-data-table-wrap :deep(.rdt-col-filter) {
  display: block;
  margin-top: var(--rdt-col-filter-gap, 0.25rem);
  width: 100%;
  font-weight: normal;
}
/* Lit cross-shadow: a slotted Filter drop-in (rozie-filter-text/-select/-number-range)
   renders inside DataTable's shadow tree via the this.filter(params) render-function path,
   but the input it renders lives in the DROP-IN's OWN shadow root — which :deep() cannot
   pierce (shadow boundaries are opaque to outside CSS; see docs/guide/features.md). The
   drop-in exposes that control as part="col-filter"; ::part() is the standards-track pierce
   for exactly this. Mirrors the :deep(.rdt-col-filter) box above. Inert on the other five
   targets (no shadow boundary → no exposed parts to match). */
::part(col-filter) {
  font: inherit;
  box-sizing: border-box;
  padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
  border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-filter-radius, 4px);
  background: var(--rdt-filter-bg, transparent);
  color: inherit;
  display: block;
  margin-top: var(--rdt-col-filter-gap, 0.25rem);
  width: 100%;
  font-weight: normal;
}
/* Dedicated filter row (phase 72, D-11-CSS / D-10) — the floating-filter band beneath the
   header row. A subtle band background + bottom border distinguishes it from both the
   header row above and the body rows below; the cell itself carries no padding of its own
   (it's role="presentation", not a real header/data cell) so the input's own box controls
   the row's visual height. Reuses the existing --rdt-col-filter-* input tokens (L2331+)
   for the input itself — only the row band + cell sizing is new here. */
.rozie-data-table .rdt-filter-row {
  background: var(--rdt-filter-row-bg, rgba(0, 0, 0, 0.015));
}
.rozie-data-table .rdt-filter-cell {
  padding: var(--rdt-filter-cell-padding, 0.35rem 0.75rem);
  border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table-wrap .rdt-filter-row :deep(.rdt-col-filter) {
  font-size: var(--rdt-filter-row-input-size, 0.9em);
}
.rozie-data-table-wrap .rdt-pagination {
  display: flex;
  align-items: center;
  gap: var(--rdt-pagination-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-page-btn {
  font: inherit;
  cursor: pointer;
  padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
  border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-page-btn-radius, 4px);
  background: var(--rdt-page-btn-bg, transparent);
  color: inherit;
}
.rozie-data-table-wrap .rdt-page-btn:disabled {
  opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
  cursor: default;
}
.rozie-data-table-wrap .rdt-page-status {
  font-size: var(--rdt-page-status-size, 0.9em);
}
.rozie-data-table-wrap .rdt-page-size {
  font: inherit;
  padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
  border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-page-size-radius, 4px);
  background: var(--rdt-page-size-bg, transparent);
  color: inherit;
}
/* Column-management chrome (req-8/9/10/11) — token-driven. */
/* The <th> is the positioning context for the trailing resize handle. */
.rozie-data-table .rdt-th {
  position: relative;
}
/* Resize handle (req-9) — a thin grab strip at the column's trailing edge. */
.rozie-data-table .rdt-resize-handle {
  position: absolute;
  top: 0;
  right: 0;
  height: 100%;
  width: var(--rdt-resize-handle-width, 6px);
  padding: 0;
  border: none;
  background: none;
  cursor: col-resize;
  touch-action: none;
  user-select: none;
}
.rozie-data-table .rdt-resize-grip {
  display: block;
  width: var(--rdt-resize-grip-width, 2px);
  height: 100%;
  margin: 0 auto;
  background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
}
.rozie-data-table .rdt-resize-handle:hover .rdt-resize-grip,
.rozie-data-table .rdt-th-resizing .rdt-resize-grip {
  background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
}
/* Header ⋯ menu (phase 72, D-11-CSS) — the old always-visible rdt-pin-controls/rdt-pin-btn
   button-group styling is repurposed here: the ⋯ trigger replaces the button-group host,
   and the menuitem rows reuse the old active-pin token (--rdt-pin-btn-active-bg). The
   floating PANEL box (bg/border/radius/shadow/padding) is Popover's own concern via its
   --rozie-popover-* tokens (the composed @rozie-ui/popover-<target>) — this only styles the
   trigger + the menu's internal item list, never re-implementing the panel chrome. */
.rozie-data-table .rdt-col-menu-trigger {
  font: inherit;
  font-size: var(--rdt-col-menu-trigger-size, 0.9em);
  line-height: 1;
  cursor: pointer;
  margin-left: var(--rdt-col-menu-trigger-margin, 0.35em);
  padding: var(--rdt-col-menu-trigger-padding, 0.15em 0.4em);
  border: var(--rdt-col-menu-trigger-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-col-menu-trigger-radius, 3px);
  background: var(--rdt-col-menu-trigger-bg, transparent);
  color: inherit;
}
.rozie-data-table .rdt-col-menu-trigger:hover {
  background: var(--rdt-col-menu-trigger-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-data-table .rdt-col-menu-trigger:focus-visible {
  outline: var(--rdt-col-menu-trigger-focus-outline, 2px solid #2563eb);
  outline-offset: 1px;
}
/* Menu panel content — the inner item list. Popover's floating box supplies the outer
   panel chrome (border/shadow/radius/bg); this only lays out the menuitems inside it. */
.rozie-data-table .rdt-col-menu {
  display: flex;
  flex-direction: column;
  gap: var(--rdt-col-menu-item-gap, 0.15rem);
  min-width: var(--rdt-col-menu-min-width, 9rem);
}
.rozie-data-table .rdt-col-menu-item {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  cursor: pointer;
  padding: var(--rdt-col-menu-item-padding, 0.35em 0.6em);
  border: none;
  border-radius: var(--rdt-col-menu-item-radius, 3px);
  background: none;
  color: inherit;
  white-space: nowrap;
}
.rozie-data-table .rdt-col-menu-item:hover {
  background: var(--rdt-col-menu-item-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-data-table .rdt-col-menu-item[aria-pressed='true'] {
  background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
  font-weight: 700;
}
.rozie-data-table .rdt-col-menu-sep {
  margin: var(--rdt-col-menu-sep-margin, 0.25rem 0);
  border: none;
  border-top: var(--rdt-col-menu-sep-border, 1px solid rgba(0, 0, 0, 0.1));
}
/* Column-visibility toggle menu (req-8) — native <details> disclosure. */
.rozie-data-table-wrap .rdt-colvis {
  position: relative;
}
.rozie-data-table-wrap .rdt-colvis-summary {
  cursor: pointer;
  font: inherit;
  padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
  border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-colvis-summary-radius, 4px);
  list-style: none;
  user-select: none;
}
.rozie-data-table-wrap .rdt-colvis-menu {
  position: absolute;
  z-index: var(--rdt-colvis-menu-z, 5);
  margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
  padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
  display: flex;
  flex-direction: column;
  gap: var(--rdt-colvis-item-gap, 0.25rem);
  border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-colvis-menu-radius, 4px);
  background: var(--rdt-colvis-menu-bg, #fff);
  box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
}
.rozie-data-table-wrap .rdt-colvis-item {
  display: flex;
  align-items: center;
  gap: var(--rdt-colvis-label-gap, 0.4em);
  cursor: pointer;
  white-space: nowrap;
}
/* Auto-injected select column (req-7, D-04) — token-driven, narrow checkbox cell. The default
   width MUST equal the select column def's `size` (44 — columnBuilders.rzts): the select column
   is a permanently left-pinned rail, and pinStyle's sticky offset is computed from table-core's
   column SIZE, so a rendered width that disagreed with the size would misalign the sticky rail.
   Overriding this var alone (without matching the def size) is unsupported for that reason. */
.rozie-data-table .rdt-select-th,
.rozie-data-table .rdt-select-td {
  width: var(--rdt-select-col-width, 44px);
  text-align: var(--rdt-select-col-align, center);
  white-space: nowrap;
}
/* Expander is a chevron-only CHROME column. Its width default MUST equal the expander column
   def's `size` (40 — columnBuilders.rzts) for the same left-pinned-rail reason as the select
   column above (a fixed narrow width now replaces the old `width:1%` shrink trick, which only
   fixed the VISUAL width and left table-core's 150px size model — hence the sticky offset —
   wrong once pinned). Deep tree indentation is applied as padding-left WITHIN the cell. */
.rozie-data-table .rdt-expander-th,
.rozie-data-table .rdt-expander-td {
  width: var(--rdt-expander-col-width, 40px);
  text-align: var(--rdt-expander-col-align, center);
  white-space: nowrap;
}
.rozie-data-table .rdt-select-all,
.rozie-data-table .rdt-select-row {
  cursor: pointer;
  accent-color: var(--rdt-select-accent, currentColor);
}
</style>

</rozie>

…and Rozie compiles it to six framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/data-table-{react,vue,svelte,angular,solid,lit}):

tsx
import { Fragment, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, parseInlineStyle, rozieAttr, rozieContext, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './DataTable.css';
import Popover from '@rozie-ui/popover-react';
import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';

// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).

interface GroupBarCtx { grouping: any; groupableColumns: any; applyGrouping: any; clearGrouping: any; }

interface SelectAllCtx { checked: any; indeterminate: any; toggle: any; }

interface ColHeaderCtx { columnId: any; column: any; label: any; }

interface FilterCtx { columnId: any; value: any; uniqueValues: any; minMax: any; setFilter: any; }

interface SelectCellCtx { row: any; checked: any; toggle: any; }

interface CellCtx { columnId: any; column: any; row: any; value: any; }

interface EditorCtx { columnId: any; column: any; row: any; value: any; commit: any; cancel: any; autofocus: any; }

interface DetailCtx { row: any; }

interface DataTableProps {
  /**
   * The row data — `model: true`, so a committed cell/row edit writes a **fresh** array back through `r-model:data` (uncontrolled fallback `dataDefault`). A stable reference per Rozie's setup-once model — fed directly into table-core (never map/cloned in the watcher).
   * @example
   * <DataTable r-model:data="rows" :columns="cols" />
   */
  data: any[];
  defaultData?: any[];
  onDataChange?: (data: any[]) => void;
  /**
   * Config-array column fallback (lower precedence than `<Column>` children). Each entry: `{ id?, field, header?, sortable?, filterable?, pinned?, width? }`. Columns may come from this array, from `<Column>` children, or both (id-keyed last-write-wins union).
   */
  columns?: any[];
  /**
   * Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
   */
  selectionMode?: string;
  /**
   * `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
   */
  sorting?: any[];
  defaultSorting?: any[];
  onSortingChange?: (sorting: any[]) => void;
  /**
   * The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
   */
  globalFilter?: string;
  defaultGlobalFilter?: string;
  onGlobalFilterChange?: (globalFilter: string) => void;
  /**
   * `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
   */
  columnFilters?: any[];
  defaultColumnFilters?: any[];
  onColumnFiltersChange?: (columnFilters: any[]) => void;
  /**
   * `{ pageIndex, pageSize }`. Defaults to `{ pageIndex: 0, pageSize: 10 }`; feeds the prev/next + page-size chrome (and `getPaginationRowModel()`). Two-way: funnels a fresh object through `page-change`.
   */
  pagination?: Record<string, any>;
  defaultPagination?: Record<string, any>;
  onPaginationChange?: (pagination: Record<string, any>) => void;
  /**
   * Server-side hook: sets `manualPagination` / `manualFiltering` / `manualSorting` so table-core trusts the consumer-supplied rows and only emits the change events (the consumer fetches each page).
   */
  manual?: boolean;
  /**
   * Total server-side row count for `manual` pagination; lets the table compute page count when it doesn't hold the full dataset.
   */
  rowCount?: (number) | null;
  /**
   * Explicit total page count for `manual` pagination; overrides rowCount-derived count.
   */
  pageCount?: (number) | null;
  /**
   * Opt-in **expandable rows**. When `true`, a leading chevron expander column auto-injects (after the select column) and `getExpandedRowModel` activates; default `false` is byte-identical-off. Every row can expand to reveal a `#detail` panel unless `getSubRows` is supplied (then only rows with children expand). Bind `:expandable="true"` (a bare attr only coerces on Vue+Lit).
   */
  expandable?: boolean;
  /**
   * `ExpandedState` — `{ [rowId]: true }`, or the `true` literal after `expandAll` (declared `type: [Object, Boolean]`). Multi-expand (multiple rows open at once). Surfaces through `expand-change`; uncontrolled fallback (`$data.expandedDefault`) when unbound — the default is `null` so the uncontrolled fallback AND the grouping auto-expand default are reachable (a non-null default would short-circuit them). When grouping is active and `expanded` is untouched, group subtrees auto-expand.
   */
  expanded?: (Record<string, any> | boolean) | null;
  defaultExpanded?: (Record<string, any> | boolean) | null;
  onExpandedChange?: (expanded: (Record<string, any> | boolean) | null) => void;
  /**
   * Table-level child-row accessor `(originalRow, index) => TData[] | undefined` that drives nested sub-rows. When supplied (with `expandable`), table-core flattens the hierarchy and the expand seam reveals depth-indented child rows. Null → the `#detail` scoped slot is the expand mode.
   */
  getSubRows?: ((...args: any[]) => any) | null;
  /**
   * Opt-in gate for the **headless `#groupBar`** host region. Default `false` is byte-identical-off. `getGroupedRowModel` is wired unconditionally (inert when `grouping` is empty), so grouping is driven by the `grouping` model; this flag only gates the consumer-facing group-bar surface (the component ships **no** built-in drag UI).
   */
  groupable?: boolean;
  /**
   * `GroupingState` — an ordered `string[]` of column ids (multi-column → nested groups, e.g. `['region','category']`). An empty/unbound list is ungrouped (byte-identical-off). Group-header rows are collapsible (they ride the expand model). Surfaces through `group-change`; uncontrolled fallback (`$data.groupingDefault`, default `[]`) when unbound — the default is `null` (mirroring `expanded`) so the uncontrolled fallback is reachable and the grouping auto-expand default can activate when a consumer applies grouping without binding `r-model:grouping` (a non-null `[]` default would short-circuit it). All reads are null-guarded, so table-core still receives an array.
   */
  grouping?: (any[]) | null;
  defaultGrouping?: (any[]) | null;
  onGroupingChange?: (grouping: (any[]) | null) => void;
  /**
   * `RowSelectionState` — `{ [rowId]: true }`. Checkbox-only toggle (the row body does not select). Driven by the `selectionMode` chrome. Two-way: fires `selection-change` regardless of binding.
   */
  rowSelection?: Record<string, any>;
  defaultRowSelection?: Record<string, any>;
  onRowSelectionChange?: (rowSelection: Record<string, any>) => void;
  /**
   * `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
   */
  columnVisibility?: Record<string, any>;
  defaultColumnVisibility?: Record<string, any>;
  onColumnVisibilityChange?: (columnVisibility: Record<string, any>) => void;
  /**
   * `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
   */
  columnSizing?: Record<string, any>;
  defaultColumnSizing?: Record<string, any>;
  onColumnSizingChange?: (columnSizing: Record<string, any>) => void;
  /**
   * `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
   */
  columnOrder?: any[];
  defaultColumnOrder?: any[];
  onColumnOrderChange?: (columnOrder: any[]) => void;
  /**
   * `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
   */
  columnPinning?: Record<string, any>;
  defaultColumnPinning?: Record<string, any>;
  onColumnPinningChange?: (columnPinning: Record<string, any>) => void;
  /**
   * Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
   */
  stickyHeader?: boolean;
  /**
   * `'table'` (default, row-oriented, byte-behaviorally identical to a plain accessible table) | `'grid'` (GA since Phase 63) — lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)**: `role="grid"`, a roving single tab-stop, 2-D APG arrow-key cell navigation, range selection, and clipboard support.
   */
  interactionMode?: string;
  /**
   * Grid mode only. When `true`, a plain click on an **editable** cell opens its editor immediately (single-click-to-edit) instead of just activating the cell. Default `false` keeps click-to-activate (double-click opens the editor). Shift+click (range selection) and clicks on non-editable cells are unaffected.
   */
  singleClickEdit?: boolean;
  /**
   * Grid mode. When `true`, every committed data mutation (cell/row edit, paste, fill, cut, clear) becomes one undo step: Ctrl/Cmd+Z undoes, Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z redoes. Default `false` records no history and Ctrl+Z/Y are inert.
   */
  undoable?: boolean;
  /**
   * The maximum number of undo steps retained (oldest evicted past this depth). Only consulted when `undoable` is `true`.
   */
  undoLimit?: number;
  /**
   * Opt-in vertical **row windowing**. When `true`, only the visible slice of rows renders inside a bounded `rdt-scroll` container (with leading/trailing spacer rows preserving total scroll height), windowing over the full filtered + sorted (pre-pagination) model and suppressing the client pagination chrome. Default `false` is byte-identical to a non-virtual table.
   */
  virtual?: boolean;
  /**
   * Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
   */
  estimateRowHeight?: number;
  /**
   * A CSS length string bounding the `rdt-scroll` container when `virtual` is on (e.g. `'400px'`). Mirrored to the `--rozie-data-table-max-height` custom property; the prop wins, the token is the fallback.
   */
  maxHeight?: string;
  onSortChange?: (...args: any[]) => void;
  onExpandChange?: (...args: any[]) => void;
  onGroupChange?: (...args: any[]) => void;
  onFilterChange?: (...args: any[]) => void;
  onPageChange?: (...args: any[]) => void;
  onSelectionChange?: (...args: any[]) => void;
  onVisibilityChange?: (...args: any[]) => void;
  onResizeChange?: (...args: any[]) => void;
  onReorderChange?: (...args: any[]) => void;
  onPinChange?: (...args: any[]) => void;
  onHistoryChange?: (...args: any[]) => void;
  onActivecellChange?: (...args: any[]) => void;
  onRangeChange?: (...args: any[]) => void;
  onCellEditCommit?: (...args: any[]) => void;
  onRowEditCommit?: (...args: any[]) => void;
  children?: ReactNode;
  renderGroupBar?: (ctx: GroupBarCtx) => ReactNode;
  renderSelectAll?: (ctx: SelectAllCtx) => ReactNode;
  renderColHeader?: (ctx: ColHeaderCtx) => ReactNode;
  renderFilter?: (ctx: FilterCtx) => ReactNode;
  renderSelectCell?: (ctx: SelectCellCtx) => ReactNode;
  renderCell?: (ctx: CellCtx) => ReactNode;
  renderEditor?: (ctx: EditorCtx) => ReactNode;
  renderDetail?: (ctx: DetailCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export interface DataTableHandle {
  sortColumn: (...args: any[]) => any;
  clearSorting: (...args: any[]) => any;
  toggleRowExpanded: (...args: any[]) => any;
  expandAll: (...args: any[]) => any;
  collapseAll: (...args: any[]) => any;
  getExpandedRows: (...args: any[]) => any;
  applyGrouping: (...args: any[]) => any;
  clearGrouping: (...args: any[]) => any;
  getFacetedUniqueValues: (...args: any[]) => any;
  getFacetedMinMaxValues: (...args: any[]) => any;
  getColumnDefs: (...args: any[]) => any;
  toggleAllRows: (...args: any[]) => any;
  clearSelection: (...args: any[]) => any;
  getSelectedRows: (...args: any[]) => any;
  setPage: (...args: any[]) => any;
  setRowsPerPage: (...args: any[]) => any;
  toggleColumnVisibility: (...args: any[]) => any;
  applyColumnOrder: (...args: any[]) => any;
  resetColumnSizing: (...args: any[]) => any;
  pinColumn: (...args: any[]) => any;
  focusCell: (...args: any[]) => any;
  getActiveCell: (...args: any[]) => any;
  clearActiveCell: (...args: any[]) => any;
  getRowIndexRelativeToPage: (...args: any[]) => any;
  editCell: (...args: any[]) => any;
  commitEditing: (...args: any[]) => any;
  editRow: (...args: any[]) => any;
  getSelectedRange: (...args: any[]) => any;
  cut: (...args: any[]) => any;
  undo: (...args: any[]) => any;
  redo: (...args: any[]) => any;
  canUndo: (...args: any[]) => any;
  canRedo: (...args: any[]) => any;
  clearHistory: (...args: any[]) => any;
}

const DataTable = forwardRef<DataTableHandle, DataTableProps>(function DataTable(_props: DataTableProps, ref): JSX.Element {
  const __ctx_data_table_columns = rozieContext("data-table:columns");
  const __defaultColumns = useState(() => (() => [])())[0];
  const props: Omit<DataTableProps, 'columns' | 'selectionMode' | 'manual' | 'rowCount' | 'pageCount' | 'expandable' | 'getSubRows' | 'groupable' | 'stickyHeader' | 'interactionMode' | 'singleClickEdit' | 'undoable' | 'undoLimit' | 'virtual' | 'estimateRowHeight' | 'maxHeight'> & { columns: any[]; selectionMode: string; manual: boolean; rowCount: (number) | null; pageCount: (number) | null; expandable: boolean; getSubRows: ((...args: any[]) => any) | null; groupable: boolean; stickyHeader: boolean; interactionMode: string; singleClickEdit: boolean; undoable: boolean; undoLimit: number; virtual: boolean; estimateRowHeight: number; maxHeight: string } = {
    ..._props,
    columns: _props.columns ?? __defaultColumns,
    selectionMode: _props.selectionMode ?? 'none',
    manual: _props.manual ?? false,
    rowCount: _props.rowCount ?? null,
    pageCount: _props.pageCount ?? null,
    expandable: _props.expandable ?? false,
    getSubRows: _props.getSubRows ?? null,
    groupable: _props.groupable ?? false,
    stickyHeader: _props.stickyHeader ?? false,
    interactionMode: _props.interactionMode ?? 'table',
    singleClickEdit: _props.singleClickEdit ?? false,
    undoable: _props.undoable ?? false,
    undoLimit: _props.undoLimit ?? 100,
    virtual: _props.virtual ?? false,
    estimateRowHeight: _props.estimateRowHeight ?? 40,
    maxHeight: _props.maxHeight ?? '',
  };
  const table = useRef<any>(null);
  const refreshRowModel = useRef<any>(null);
  const virtualizer = useRef<any>(null);
  const pendingEditFollow = useRef<any>(null);
  const gridRoot = useRef<any>(null);
  const gridScrollEl = useRef<any>(null);
  const virtualizerCleanup = useRef<any>(null);
  const expandedTouched = useRef(false);
  const programmatic = useRef(0);
  const remeasurePending = useRef(false);
  const gridEmptyFallback = useRef(false);
  const rangeActive = useRef(false);
  const selectAllBox = useRef<any>(null);
  const fillDragMove = useRef<any>(null);
  const fillDragUp = useRef<any>(null);
  const fillDragging = useRef(false);
  const rangeDragMove = useRef<any>(null);
  const rangeDragUp = useRef<any>(null);
  const rangeDragging = useRef(false);
  const lastData = useRef<any>(null);
  const lastDataLen = useRef(-1);
  const lastPropsData = useRef<unknown>(null);
  const undoStack = useRef<unknown[]>([]);
  const redoStack = useRef<unknown[]>([]);
  const focusIntentEpoch = useRef(0);
  const committedThisSession = useRef(false);
  const editTransition = useRef(false);
  const restoringHistory = useRef<boolean>(false);
  const rangeTransition = useRef(false);
  const rangeClickPending = useRef(false);
  const rangeDragMoved = useRef(false);
  const [data, setData] = useControllableState({
    value: props.data,
    defaultValue: props.defaultData ?? [],
    onValueChange: props.onDataChange,
  });
  const [sorting, setSorting] = useControllableState({
    value: props.sorting,
    defaultValue: props.defaultSorting ?? (() => [])(),
    onValueChange: props.onSortingChange,
  });
  const [globalFilter, setGlobalFilter] = useControllableState({
    value: props.globalFilter,
    defaultValue: props.defaultGlobalFilter ?? '',
    onValueChange: props.onGlobalFilterChange,
  });
  const [columnFilters, setColumnFilters] = useControllableState({
    value: props.columnFilters,
    defaultValue: props.defaultColumnFilters ?? (() => [])(),
    onValueChange: props.onColumnFiltersChange,
  });
  const [pagination, setPagination] = useControllableState({
    value: props.pagination,
    defaultValue: props.defaultPagination ?? (() => ({
    pageIndex: 0,
    pageSize: 10
  }))(),
    onValueChange: props.onPaginationChange,
  });
  const [expanded, setExpanded] = useControllableState({
    value: props.expanded,
    defaultValue: props.defaultExpanded ?? null,
    onValueChange: props.onExpandedChange,
  });
  const [grouping, setGrouping] = useControllableState({
    value: props.grouping,
    defaultValue: props.defaultGrouping ?? null,
    onValueChange: props.onGroupingChange,
  });
  const [rowSelection, setRowSelection] = useControllableState({
    value: props.rowSelection,
    defaultValue: props.defaultRowSelection ?? (() => ({}))(),
    onValueChange: props.onRowSelectionChange,
  });
  const [columnVisibility, setColumnVisibility] = useControllableState({
    value: props.columnVisibility,
    defaultValue: props.defaultColumnVisibility ?? (() => ({}))(),
    onValueChange: props.onColumnVisibilityChange,
  });
  const [columnSizing, setColumnSizing] = useControllableState({
    value: props.columnSizing,
    defaultValue: props.defaultColumnSizing ?? (() => ({}))(),
    onValueChange: props.onColumnSizingChange,
  });
  const [columnOrder, setColumnOrder] = useControllableState({
    value: props.columnOrder,
    defaultValue: props.defaultColumnOrder ?? (() => [])(),
    onValueChange: props.onColumnOrderChange,
  });
  const [columnPinning, setColumnPinning] = useControllableState({
    value: props.columnPinning,
    defaultValue: props.defaultColumnPinning ?? (() => ({
    left: [],
    right: []
  }))(),
    onValueChange: props.onColumnPinningChange,
  });
  const _expandableRef = useRef(props.expandable);
  _expandableRef.current = props.expandable;
  const _getSubRowsRef = useRef(props.getSubRows);
  _getSubRowsRef.current = props.getSubRows;
  const _manualRef = useRef(props.manual);
  _manualRef.current = props.manual;
  const _pageCountRef = useRef(props.pageCount);
  _pageCountRef.current = props.pageCount;
  const _rowCountRef = useRef(props.rowCount);
  _rowCountRef.current = props.rowCount;
  const _selectionModeRef = useRef(props.selectionMode);
  _selectionModeRef.current = props.selectionMode;
  const _virtualRef = useRef(props.virtual);
  _virtualRef.current = props.virtual;
  const _dataRef = useRef(data);
  _dataRef.current = data;
  const _paginationRef = useRef(pagination);
  _paginationRef.current = pagination;
  const [dataDefault, setDataDefault] = useState<any[]>([]);
  const [sortingDefault, setSortingDefault] = useState<any[]>([]);
  const [globalFilterDefault, setGlobalFilterDefault] = useState('');
  const [columnFiltersDefault, setColumnFiltersDefault] = useState<any[]>([]);
  const [paginationDefault, setPaginationDefault] = useState({
    pageIndex: 0,
    pageSize: 10
  });
  const [rowSelectionDefault, setRowSelectionDefault] = useState<Record<string, any>>({});
  const [expandedDefault, setExpandedDefault] = useState<Record<string, any>>({});
  const [groupingDefault, setGroupingDefault] = useState<any[]>([]);
  const [columnVisibilityDefault, setColumnVisibilityDefault] = useState<Record<string, any>>({});
  const [columnSizingDefault, setColumnSizingDefault] = useState<Record<string, any>>({});
  const [columnOrderDefault, setColumnOrderDefault] = useState<any[]>([]);
  const [columnPinningDefault, setColumnPinningDefault] = useState({
    left: [],
    right: []
  });
  const [columnSizingInfo, setColumnSizingInfo] = useState({
    startOffset: null,
    startSize: null,
    deltaOffset: null,
    deltaPercentage: null,
    isResizingColumn: false,
    columnSizingStart: []
  });
  const [colReg, setColReg] = useState<Record<string, any>>({});
  const [rows, setRows] = useState<any[]>([]);
  const [headerGroups, setHeaderGroups] = useState<any[]>([]);
  const [rowModelVer, setRowModelVer] = useState(0);
  const [windowVer, setWindowVer] = useState(0);
  const [activeRow, setActiveRow] = useState(0);
  const [activeColIndex, setActiveColIndex] = useState(0);
  const [activeIsHeader, setActiveIsHeader] = useState(false);
  const [activeHeaderLevel, setActiveHeaderLevel] = useState(0);
  const [activeInControl, setActiveInControl] = useState(false);
  const [editingRow, setEditingRow] = useState(-1);
  const [editingCol, setEditingCol] = useState(-1);
  const [draftValue, setDraftValue] = useState<any>(null);
  const [invalidMsg, setInvalidMsg] = useState('');
  const [editVer, setEditVer] = useState(0);
  const [editFocusColId, setEditFocusColId] = useState<any>(null);
  const [editingRowIndex, setEditingRowIndex] = useState<any>(null);
  const [rowDraft, setRowDraft] = useState<Record<string, any>>({});
  const [rangeAnchor, setRangeAnchor] = useState<any>(null);
  const [rangeFocus, setRangeFocus] = useState<any>(null);
  const [pasteAnnounce, setPasteAnnounce] = useState('');
  const [liveAnnounce, setLiveAnnounce] = useState('');
  const __rozieRoot = useRef<HTMLDivElement | null>(null);
  const _watch0First = useRef(true);
  const _watch1First = useRef(true);

  // ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
  // Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
  // via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
  // later change is a one-line edit.
  const GRID_PAGE_STEP = 10;
  // The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
  // $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
  // shadow-safe because the query runs from INSIDE the component's own scope (the listbox
  // querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
  // computed/template binding (ROZ123).
  // ── Grid-wide undo/redo (260709-8ct) — history STATE lives in top-level `let` (mirroring
  // `programmatic` above), NOT $data: recording a snapshot on every keystroke must not trigger
  // a reactive re-render. React hoists each to useRef. undoStack/redoStack hold `data` array
  // REFERENCES (never deep copies — see undoHistory.rzts's header comment on the shared-row
  // invariant). restoringHistory suppresses re-recording while an undo()/redo() replay is
  // in flight.
  //
  // The external-swap history reset keys on data ORIGIN, not a timing window. Every internal
  // writeback stamps its fresh `data` array with a durable, non-enumerable marker under
  // DATA_WRITE_TOKEN_KEY (see writeData in writeFunnels.rzts); the reset (maybeClearHistoryOnExternal
  // Swap, below) clears history ONLY when a newly-supplied `$props.data` carries no marker — it did
  // not come from us, so it is a genuine external dataset swap. Presence of the marker ⟺ "descends
  // from one of our writes", and it survives EVERYTHING that defeated the four flag/timer variants:
  //   1. A raw-reference latch (`lastWrittenData === currentData()`) — Vue `reactive()` / Svelte 5
  //      `$state` / Solid store re-wrap a written array in a NEW Proxy on its way back through props,
  //      so `===` never holds. (A non-enumerable own PROPERTY, by contrast, is forwarded through
  //      every target's reactive Proxy via `Reflect.get` — readable through the wrap.)
  //   2. A single-consume boolean — the re-feed watch fires MULTIPLE times per write; the first pass
  //      consumed the flag, a later pass wrongly cleared.
  //   3. A content signature (`JSON.stringify`) — the watch can fire with a TRANSIENTLY STALE
  //      `currentData()` mid-settle (Solid/Lit), a real-but-older value → false mismatch.
  //   4. A deferred settle-window flag (rAF, then a 96ms macrotask) — a slow re-feed on a LARGE
  //      controlled table OUTRAN the window (#8); no fixed timeout can be correct (re-feed latency
  //      scales with dataset size).
  // A STRING key (not a JS Symbol) is deliberate: it is stable BY VALUE on all six targets with ZERO
  // caching, whereas a `Symbol()` needs a per-instance memo to hold one identity — and Lit lowers
  // `$computed(() => Symbol())` to a plain getter that RE-MINTS the Symbol on every read, so writeData
  // and the reset would stamp/read DIFFERENT symbols and the marker would never match. Non-enumerable
  // → invisible to JSON.stringify / spread / Object.keys (the consumer's data stays clean); namespaced
  // so a consumer array never collides.
  const DATA_WRITE_TOKEN_KEY = '__rozieDataWriteToken';
  function groupingActiveDefault() {
    return ((grouping != null ? grouping : groupingDefault) || []).length > 0;
  }
  function effectiveColumnPinning(): any {
    const base = columnPinning != null ? columnPinning : columnPinningDefault;
    const rail: string[] = [];
    if (selectionEnabled()) rail.push(SELECT_COL_ID);
    if (props.expandable === true) rail.push(EXPANDER_COL_ID);
    if (rail.length === 0) return base;
    const left = base && base.left ? base.left : [];
    const deduped = left.filter((id: string) => id !== SELECT_COL_ID && id !== EXPANDER_COL_ID);
    return {
      ...base,
      left: rail.concat(deduped)
    };
  }
  const currentState = useCallback((): any => ({
    sorting: sorting != null ? sorting : sortingDefault,
    globalFilter: globalFilter != null ? globalFilter : globalFilterDefault,
    columnFilters: columnFilters != null ? columnFilters : columnFiltersDefault,
    pagination: pagination != null ? pagination : paginationDefault,
    rowSelection: rowSelection != null ? rowSelection : rowSelectionDefault,
    // expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
    // literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
    // guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
    // GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
    // `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
    // literal so the grouped subtree is visible by default; the first toggle latches
    // expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
    // unchanged → byte-identical-off (the table + the expandable-rows feature both keep
    // $data.expandedDefault).
    expanded: expanded != null ? expanded : groupingActiveDefault() && !expandedTouched.current ? true : expandedDefault,
    // grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
    // to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
    // is inert when this is empty (byte-identical-off, req-10).
    grouping: grouping != null ? grouping : groupingDefault,
    columnVisibility: columnVisibility != null ? columnVisibility : columnVisibilityDefault,
    columnSizing: columnSizing != null ? columnSizing : columnSizingDefault,
    columnOrder: columnOrder != null ? columnOrder : columnOrderDefault,
    columnPinning: effectiveColumnPinning(),
    // columnSizingInfo: table-core's transient resize-gesture state. We pass an
    // EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
    // `column.getIsResizing()` / `getResizeHandler()` read
    // `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
    // absent. Seed the default shape (matches table-core's
    // getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
    // every render. Not a two-way model slice (transient gesture state, not consumer
    // state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
    columnSizingInfo: columnSizingInfo
  }), [columnFilters, columnFiltersDefault, columnOrder, columnOrderDefault, columnSizing, columnSizingDefault, columnSizingInfo, columnVisibility, columnVisibilityDefault, effectiveColumnPinning, expanded, expandedDefault, globalFilter, globalFilterDefault, grouping, groupingActiveDefault, groupingDefault, pagination, paginationDefault, rowSelection, rowSelectionDefault, sorting, sortingDefault]);
  const currentData = useCallback((): any => data != null ? data : dataDefault, [data, dataDefault]);
  function isSafeKey(k: any) {
    return k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
  }
  function wrapAggregationFn(fn: any) {
    if (typeof fn === 'string') return fn;
    if (typeof fn !== 'function') return undefined;
    return (columnId: any, leafRows: any, childRows: any) => {
      try {
        return fn(columnId, leafRows, childRows);
      } catch (err: any) {
        return undefined;
      }
    };
  }
  function buildConfigDef(c: any) {
    if (!c) return null;
    // Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
    // getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
    // header spans its leaf children (B12). The group id falls back to its header text so it
    // stays addressable (no accessor; group columns carry no data).
    if (Array.isArray(c.columns)) {
      const kids = [];
      for (const child of c.columns as any) {
        const cd = buildConfigDef(child);
        if (cd) kids.push(cd);
      }
      if (!kids.length) return null;
      // Group id: an explicit c.id wins. Otherwise synthesize a STABLE UNIQUE id from the child
      // column ids (which are unique per leaf accessor / recursively-synthesized per nested group)
      // — NOT the header text. Falling back to c.header collided two same-titled groups (e.g. both
      // "Details") into one by-id map key, so the columnDefs LWW merge silently dropped one whole
      // group column + its children. The child-id derivation is deterministic (stable across
      // renders — no Math.random/Date). A group with neither id nor header (nor derivable children)
      // stays dropped as before.
      let gid = c.id;
      if (gid == null) gid = c.header != null ? '__grp_' + kids.map((k: any) => k.id).join('_') : null;
      if (gid == null) return null;
      const id = String(gid);
      if (!isSafeKey(id)) return null;
      return {
        id,
        header: c.header != null ? c.header : id,
        columns: kids
      };
    }
    const rawId = c.id != null ? c.id : c.field;
    if (rawId == null) return null;
    const id = String(rawId);
    if (!isSafeKey(id)) return null;
    return {
      id,
      accessorKey: c.field != null ? c.field : id,
      header: c.header != null ? c.header : id,
      enableSorting: c.sortable === true,
      // per-column filter opt-in (req-5). table-core gates the filter input + value
      // funnel on enableColumnFilter; a column with filterable !== true cannot be
      // filtered (and renders no per-column filter input in the chrome below).
      enableColumnFilter: c.filterable === true,
      filterable: c.filterable === true,
      // Expandable-rows reserved per-column metadata (phase 50, D-04).
      expandable: c.expandable === true,
      // Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
      // so every data column is offered to the headless #groupBar by default; the per-column
      // aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
      // a custom fn defensively wrapped (T-50-04).
      groupable: c.groupable !== false,
      aggregationFn: wrapAggregationFn(c.aggregationFn),
      pinned: c.pinned != null ? c.pinned : '',
      width: c.width != null ? c.width : '',
      // Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
      // metadata carrier the display↔editor branch + runValidator read. Off by default.
      meta: {
        editable: c.editable === true,
        editor: c.editor != null ? c.editor : 'text',
        editorOptions: c.editorOptions != null ? c.editorOptions : [],
        validate: typeof c.validate === 'function' ? c.validate : null
      }
    };
  }
  function columnDefs() {
    const byId = Object.create(null);
    const order = [];
    const cfg = props.columns || [];
    for (const c of cfg as any) {
      const def = buildConfigDef(c);
      if (!def) continue;
      const id = def.id;
      if (!(id in byId)) order.push(id);
      byId[id] = def;
    }
    const reg = colReg || {};
    for (const id in reg) {
      if (!isSafeKey(id)) continue;
      const spec = reg[id];
      if (!spec) continue;
      if (!(id in byId)) order.push(id);
      byId[id] = {
        id,
        accessorKey: spec.field != null ? spec.field : id,
        header: spec.header != null ? spec.header : id,
        enableSorting: spec.sortable === true,
        enableColumnFilter: spec.filterable === true,
        filterable: spec.filterable === true,
        // Expandable-rows reserved per-column metadata (phase 50, D-04).
        expandable: spec.expandable === true,
        // Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
        groupable: spec.groupable !== false,
        aggregationFn: wrapAggregationFn(spec.aggregationFn),
        pinned: spec.pinned != null ? spec.pinned : '',
        width: spec.width != null ? spec.width : '',
        // Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
        meta: {
          editable: spec.editable === true,
          editor: spec.editor != null ? spec.editor : 'text',
          editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
          validate: typeof spec.validate === 'function' ? spec.validate : null
        }
      };
    }
    const out = [];
    for (const id of order as any) if (byId[id]) out.push(byId[id]);
    return out;
  }
  // The constant id of the auto-injected leading checkbox column (D-04). Distinct from
  // any consumer column id (the registry/config guard never produces a leading "__").
  const SELECT_COL_ID = '__rdt_select';

  // The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
  // Distinct from any consumer column id (the registry/config guard never produces a leading
  // "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
  // The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
  // Distinct from any consumer column id (the registry/config guard never produces a leading
  // "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
  const EXPANDER_COL_ID = '__rdt_expander';

  // The table-core ColumnDef set actually fed to createTable / setOptions: the resolved
  // user columns, PLUS a LEADING checkbox column when selectionMode is 'single' OR
  // 'multiple' (D-04). The select column carries enableSorting/enableColumnFilter:false
  // and an isSelectColumn marker the template uses to render checkbox chrome (NOT an
  // accessor value). 'none' injects nothing. In 'single' mode the per-row checkbox
  // renders but the select-all HEADER checkbox is suppressed (selecting a row caps at
  // ≤1 via enableMultiRowSelection:false) — a single-select needs a per-row control,
  // not a select-all, so without injecting the column single mode would expose NO
  // selection UI at all.
  function selectionEnabled() {
    return props.selectionMode === 'single' || props.selectionMode === 'multiple';
  }
  const tableColumns = useCallback(() => {
    const cols = columnDefs();
    // Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
    // isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
    // value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
    // → byte-identical-off (req-10).
    let withExpander = cols;
    if (props.expandable === true) {
      const expanderCol = {
        id: EXPANDER_COL_ID,
        enableSorting: false,
        enableColumnFilter: false,
        filterable: false,
        isExpanderColumn: true,
        pinned: '',
        width: '',
        // Explicit narrow size so table-core's getSize()/getStart('left') match the RENDERED
        // width. Without it table-core assumes its 150px default, which is fine for an UNPINNED
        // chrome column (a CSS `width:1%` trick shrinks it visually) but breaks the moment the
        // column joins the left-pinned rail: pinStyle's sticky offset is Σ preceding pinned
        // SIZES, so a phantom 150px would push every real pinned column ~150px too far right and
        // overlap. Keep this in sync with the `--rdt-expander-col-width` CSS default (40px).
        size: 40
      };
      withExpander = [expanderCol].concat(cols);
    }
    if (selectionEnabled()) {
      const selectCol = {
        id: SELECT_COL_ID,
        enableSorting: false,
        enableColumnFilter: false,
        filterable: false,
        isSelectColumn: true,
        pinned: '',
        width: '',
        // Explicit narrow size so table-core's sticky-offset math (getStart('left')) matches the
        // rendered checkbox width once this column joins the left-pinned rail — see the expander
        // note above. Keep in sync with the `--rdt-select-col-width` CSS default (44px).
        size: 44
      };
      return [selectCol].concat(withExpander);
    }
    return withExpander;
  }, [columnDefs, props.expandable, selectionEnabled]);
  function writeSorting(next: any) {
    if (programmatic.current) return;
    programmatic.current++;
    setSortingDefault(next); // fresh array only (never in-place)
    setSorting(next); // two-way emit if bound (no-op-diff if not)
    props.onSortChange && props.onSortChange(next);
    programmatic.current--;
  }
  function applyUpdater(updater: any, current: any) {
    return typeof updater === 'function' ? updater(current) : updater;
  }
  function writeExpanded(next: any) {
    if (programmatic.current) return;
    programmatic.current++;
    // Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
    // the user now owns the expanded state, so currentState() stops defaulting grouped rows to
    // the `true` expand-all literal and honors $data.expandedDefault from here on.
    expandedTouched.current = true;
    setExpandedDefault(next); // fresh value only (never in-place)
    setExpanded(next); // two-way emit if bound (no-op-diff if not)
    // Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
    // prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
    // interface, and an `expanded-change` event would camelCase to the SAME identifier
    // → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
    // sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
    // sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
    props.onExpandChange && props.onExpandChange(next);
    programmatic.current--;
  }
  function writeGrouping(next: any) {
    if (programmatic.current) return;
    programmatic.current++;
    setGroupingDefault(next); // fresh ordered array only (never in-place push)
    setGrouping(next); // two-way emit if bound (no-op-diff if not)
    props.onGroupChange && props.onGroupChange(next);
    programmatic.current--;
  }
  function writeGlobalFilter(next: any) {
    if (programmatic.current) return;
    programmatic.current++;
    setGlobalFilterDefault(next);
    setGlobalFilter(next);
    props.onFilterChange && props.onFilterChange({
      globalFilter: next
    });
    programmatic.current--;
  }
  function writeColumnFilters(next: any) {
    if (programmatic.current) return;
    programmatic.current++;
    setColumnFiltersDefault(next);
    setColumnFilters(next);
    props.onFilterChange && props.onFilterChange({
      columnFilters: next
    });
    programmatic.current--;
  }
  const { onPageChange: _rozieProp_onPageChange } = props;
    const writePagination = useCallback((next: any) => {
    if (programmatic.current) return;
    programmatic.current++;
    setPaginationDefault(next);
    setPagination(next);
    _rozieProp_onPageChange && _rozieProp_onPageChange(next);
    programmatic.current--;
  }, [_rozieProp_onPageChange, setPagination]);
  function writeRowSelection(next: any) {
    if (programmatic.current) return;
    programmatic.current++;
    setRowSelectionDefault(next);
    setRowSelection(next);
    props.onSelectionChange && props.onSelectionChange(next);
    programmatic.current--;
  }
  function writeColumnVisibility(next: any) {
    if (programmatic.current) return;
    programmatic.current++;
    setColumnVisibilityDefault(next);
    setColumnVisibility(next);
    props.onVisibilityChange && props.onVisibilityChange(next);
    programmatic.current--;
  }
  function writeColumnSizing(next: any) {
    if (programmatic.current) return;
    programmatic.current++;
    setColumnSizingDefault(next);
    setColumnSizing(next);
    props.onResizeChange && props.onResizeChange(next);
    programmatic.current--;
  }
  function writeColumnOrder(next: any) {
    if (programmatic.current) return;
    programmatic.current++;
    setColumnOrderDefault(next);
    setColumnOrder(next);
    props.onReorderChange && props.onReorderChange(next);
    programmatic.current--;
  }
  function writeColumnPinning(next: any) {
    if (programmatic.current) return;
    // effectiveColumnPinning() forces the auto-injected chrome ids (select/expander) into the
    // table-core `left` rail, so table-core hands them back here on every pin change. Strip them
    // before persisting: the CONSUMER's columnPinning model + the pin-change event must reflect
    // only their own columns, never our internal rail ids (which re-inject each render anyway).
    const strip = (ids: any) => (ids || []).filter((id: any) => id !== SELECT_COL_ID && id !== EXPANDER_COL_ID);
    const clean = {
      ...next,
      left: strip(next && next.left),
      right: strip(next && next.right)
    };
    programmatic.current++;
    setColumnPinningDefault(clean);
    setColumnPinning(clean);
    props.onPinChange && props.onPinChange(clean);
    programmatic.current--;
  }
  function writeData(next: any) {
    if (programmatic.current) return;
    if (props.undoable && !restoringHistory.current) {
      const prevU = canUndo();
      const prevR = canRedo();
      recordSnapshot(currentData());
      emitHistoryChangeIfEdged(prevU, prevR);
    }
    const fresh = Array.isArray(next) ? next.slice() : next;
    try {
      Object.defineProperty(fresh, DATA_WRITE_TOKEN_KEY, {
        value: true,
        enumerable: false,
        configurable: true,
        writable: true
      });
    } catch (_e: any) {/* a frozen/sealed array can't be stamped — our fresh arrays never are */}
    programmatic.current++;
    setDataDefault(fresh); // fresh raw array only (never in-place, never a proxy)
    setData(fresh); // two-way emit if bound (no-op-diff if not)
    programmatic.current--;
  }
  function columnFilterValue(colId: any) {
    const cf = currentState().columnFilters || [];
    for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
    return '';
  }
  function setColumnFilter(colId: any, value: any) {
    const prev = currentState().columnFilters || [];
    const next = [];
    for (const f of prev as any) if (f && f.id !== colId) next.push(f);
    if (value != null && value !== '') next.push({
      id: colId,
      value
    });
    writeColumnFilters(next);
  }
  function recordSnapshot(current: any) {
    undoStack.current.push(current);
    const limit = props.undoLimit != null ? props.undoLimit : 100;
    while (undoStack.current.length > limit) undoStack.current.shift();
    redoStack.current = [];
  }
  function canUndo() {
    return undoStack.current.length > 0;
  }
  function canRedo() {
    return redoStack.current.length > 0;
  }
  function clearHistory() {
    undoStack.current = [];
    redoStack.current = [];
  }
  function emitHistoryChange() {
    props.onHistoryChange && props.onHistoryChange({
      canUndo: canUndo(),
      canRedo: canRedo()
    });
  }
  function emitHistoryChangeIfEdged(prevU: any, prevR: any) {
    const nextU = canUndo();
    const nextR = canRedo();
    if (nextU !== prevU || nextR !== prevR) emitHistoryChange();
  }
  function undo() {
    if (!canUndo()) return;
    const prev = undoStack.current.pop();
    redoStack.current.push(currentData());
    restoringHistory.current = true;
    writeData(prev);
    restoringHistory.current = false;
    emitHistoryChange();
  }
  function redo() {
    if (!canRedo()) return;
    const next = redoStack.current.pop();
    undoStack.current.push(currentData());
    restoringHistory.current = true;
    writeData(next);
    restoringHistory.current = false;
    emitHistoryChange();
  }
  const onSortingChangeCb = useCallback((updater: any) => {
    writeSorting(applyUpdater(updater, currentState().sorting));
  }, [applyUpdater, currentState, writeSorting]);
  const onExpandedChangeCb = useCallback((updater: any) => {
    writeExpanded(applyUpdater(updater, currentState().expanded));
  }, [applyUpdater, currentState, writeExpanded]);
  const onGroupingChangeCb = useCallback((updater: any) => {
    writeGrouping(applyUpdater(updater, currentState().grouping));
  }, [applyUpdater, currentState, writeGrouping]);
  const onGlobalFilterChangeCb = useCallback((updater: any) => {
    writeGlobalFilter(applyUpdater(updater, currentState().globalFilter));
  }, [applyUpdater, currentState, writeGlobalFilter]);
  const onColumnFiltersChangeCb = useCallback((updater: any) => {
    writeColumnFilters(applyUpdater(updater, currentState().columnFilters));
  }, [applyUpdater, currentState, writeColumnFilters]);
  const onPaginationChangeCb = useCallback((updater: any) => {
    writePagination(applyUpdater(updater, currentState().pagination));
  }, [applyUpdater, currentState, writePagination]);
  const onRowSelectionChangeCb = useCallback((updater: any) => {
    writeRowSelection(applyUpdater(updater, currentState().rowSelection));
  }, [applyUpdater, currentState, writeRowSelection]);
  const onColumnVisibilityChangeCb = useCallback((updater: any) => {
    writeColumnVisibility(applyUpdater(updater, currentState().columnVisibility));
  }, [applyUpdater, currentState, writeColumnVisibility]);
  const onColumnSizingChangeCb = useCallback((updater: any) => {
    writeColumnSizing(applyUpdater(updater, currentState().columnSizing));
  }, [applyUpdater, currentState, writeColumnSizing]);
  const onColumnOrderChangeCb = useCallback((updater: any) => {
    writeColumnOrder(applyUpdater(updater, currentState().columnOrder));
  }, [applyUpdater, currentState, writeColumnOrder]);
  const onColumnPinningChangeCb = useCallback((updater: any) => {
    writeColumnPinning(applyUpdater(updater, currentState().columnPinning));
  }, [applyUpdater, currentState, writeColumnPinning]);
  const onColumnSizingInfoChangeCb = useCallback((updater: any) => {
    const next = applyUpdater(updater, columnSizingInfo);
    setColumnSizingInfo(prev => next != null ? next : prev);
  }, [applyUpdater, columnSizingInfo]);
  const windowSource = useCallback(() => {
    if (!table.current) return [];
    if (props.virtual) return table.current.getPrePaginationRowModel().rows;
    return table.current.getRowModel().rows;
  }, [props.virtual]);
  function scheduleRemeasure() {
    if (remeasurePending.current) return;
    remeasurePending.current = true;
    let ranMicro = false;
    const microPass = () => {
      remeasureWindow();
    };
    const rafPass = () => {
      remeasurePending.current = false;
      remeasureWindow();
    };
    if (typeof queueMicrotask !== 'undefined') {
      ranMicro = true;
      queueMicrotask(microPass);
    }
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending.current = false;else setTimeout(rafPass, 0);
  }
  function pinnedEditIndex() {
    if (editingRow >= 0) return editingRow;
    if (editingRowIndex != null) return editingRowIndex;
    return -1;
  }
  function pinnedMeasurement(pin: any) {
    if (!virtualizer.current || pin < 0) return null;
    const ms = virtualizer.current.getMeasurements();
    return ms && ms[pin] ? ms[pin] : null;
  }
  const remeasureWindow = useCallback(() => {
    if (!virtualizer.current || !gridRoot.current) return;
    // Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
    // exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
    // null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
    // scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
    // resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
    // regression); the next settled onChange re-measures the stable window. Manual-scroll
    // recycling (the CR-01 case) has scrollState === null, so it measures normally.
    if (virtualizer.current.scrollState) return;
    const trs = gridRoot.current.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
    for (const tr of trs as any) virtualizer.current.measureElement(tr);
  }, []);
  function virtualItemKey(i: any) {
    const src = windowSource();
    return src && src[i] ? src[i].id : undefined;
  }
  const virtualizerOptions = useCallback((): any => ({
    count: windowSource().length,
    getScrollElement: () => gridScrollEl.current,
    estimateSize: () => props.estimateRowHeight,
    observeElementRect,
    observeElementOffset,
    scrollToFn: elementScroll,
    measureElement,
    overscan: 8,
    getItemKey: virtualItemKey,
    onChange: () => {
      setWindowVer(prev => prev + 1);
      // CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
      // virtual-core only observe()s a node you explicitly hand to measureElement (it does
      // NOT auto-discover rendered rows — measureElement is the SOLE caller of
      // observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
      // into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
      // estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
      // so the new <tr> set is in the DOM before we measure. Safe from an infinite
      // measure→onChange→measure loop: measureElement is idempotent on an already-observed
      // node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
      // measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
      // re-measure is a no-op.
      scheduleRemeasure();
    }
  }), [props.estimateRowHeight, scheduleRemeasure, virtualItemKey, windowSource]);
  function pinMeasurement(pin: number): {
    start: number;
    size: number;
    index: number;
    end: number;
  } | null {
    return pinnedMeasurement(pin);
  }
  function windowedRows() {
    // SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
    // early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
    // which happens at initial render while `virtualizer` is still null (it is built in $onMount,
    // after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
    // BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
    // signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
    // blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
    // placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
    // first re-run that picks up the now-non-null virtualizer.
    // ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
    // pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
    void windowVer;
    void editVer;
    if (!virtualizer.current) {
      // Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
      // but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
      // the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
      // rows appear on the first onChange after _didMount.
      if (!props.virtual) {
        const rowList = rows || [];
        return rowList.map((r: any) => ({
          vi: null,
          row: r
        }));
      }
      return [];
    }
    const items = virtualizer.current.getVirtualItems();
    const rowList = rows || [];
    // WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
    // shrink window where the virtualizer count is stale relative to $data.rows on the async
    // onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
    // throw "Cannot read properties of undefined"; filter it here so the template never sees it.
    const out = items.map((vi: any) => ({
      vi,
      row: rowList[vi.index]
    })).filter((wr: any) => wr.row);
    // ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
    // window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
    // into another full-model row), LEADING the slice when it sits above the window and TRAILING
    // it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
    // padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
    // into the real windowing.
    const pin = pinnedEditIndex();
    if (pin >= 0 && rowList[pin]) {
      let inWindow = false;
      for (let i = 0; i < items.length; i++) {
        if (items[i].index === pin) {
          inWindow = true;
          break;
        }
      }
      if (!inWindow) {
        const pm = pinMeasurement(pin);
        const firstStart = items.length ? items[0].start : 0;
        const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
        const pinnedEntry = {
          vi: pm != null ? pm : {
            index: pin
          },
          row: rowList[pin],
          pinned: true
        };
        if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
      }
    }
    return out;
  }
  function padTop() {
    // SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
    // spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
    // and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
    void windowVer;
    void editVer;
    if (!props.virtual || !virtualizer.current) return 0;
    const items = virtualizer.current.getVirtualItems();
    let pad = items.length ? items[0].start : 0;
    // D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
    // in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
    // that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
    const pin = pinnedEditIndex();
    if (pin >= 0) {
      const pm = pinMeasurement(pin);
      const inWindow = pmIndexInWindow(items, pin);
      if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
    }
    return pad < 0 ? 0 : pad;
  }
  function padBottom() {
    // subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
    // return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
    // on pin/unpin.
    void windowVer;
    void editVer;
    if (!props.virtual || !virtualizer.current) return 0;
    const items = virtualizer.current.getVirtualItems();
    if (!items.length) return 0;
    let pad = virtualizer.current.getTotalSize() - items[items.length - 1].end;
    // D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
    // in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
    const pin = pinnedEditIndex();
    if (pin >= 0) {
      const pm = pinMeasurement(pin);
      const inWindow = pmIndexInWindow(items, pin);
      // WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
      // measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
      // index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
      // The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
      // the offset comparison only if the measurement lacks an index (defensive).
      const lastItemIdx = items[items.length - 1].index;
      const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
      if (pm && !inWindow && below) {
        // below the window → it trailed the slice; subtract its height from the trailing spacer.
        if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
      }
    }
    return pad < 0 ? 0 : pad;
  }
  function pmIndexInWindow(items: any, idx: any) {
    for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
    return false;
  }
  function rowIsOutsideWindow(r: any) {
    if (!props.virtual || !virtualizer.current) return false;
    const items = virtualizer.current.getVirtualItems();
    for (const it of items as any) if (it.index === r) return false;
    return true;
  }
  const announceState: {
    sorting: unknown;
    columnFilters: unknown;
    globalFilter: unknown;
  } = useMemo(() => ({
    sorting: null,
    columnFilters: null,
    globalFilter: null
  }), []);
  const effectiveSorting = useCallback(() => sorting != null ? sorting : sortingDefault, [sorting, sortingDefault]);
  const effectiveColumnFilters = useCallback(() => columnFilters != null ? columnFilters : columnFiltersDefault, [columnFilters, columnFiltersDefault]);
  const effectiveGlobalFilter = useCallback(() => globalFilter != null ? globalFilter : globalFilterDefault, [globalFilter, globalFilterDefault]);
  function buildSortFilterAnnounce() {
    const nextSorting = effectiveSorting();
    const nextColumnFilters = effectiveColumnFilters();
    const nextGlobalFilter = effectiveGlobalFilter();
    const sortChanged = nextSorting !== announceState.sorting;
    const filterChanged = nextColumnFilters !== announceState.columnFilters || nextGlobalFilter !== announceState.globalFilter;
    announceState.sorting = nextSorting;
    announceState.columnFilters = nextColumnFilters;
    announceState.globalFilter = nextGlobalFilter;
    if (sortChanged) {
      const active = nextSorting && nextSorting.length ? nextSorting[0] : null;
      if (!active) return 'Sorting cleared';
      const rawLabel = headerLabel(active.id);
      const label = typeof rawLabel === 'string' && rawLabel ? rawLabel : active.id;
      return 'Sorted by ' + label + ', ' + (active.desc ? 'descending' : 'ascending');
    }
    if (filterChanged) {
      return totalRowCount() + ' results';
    }
    return '';
  }
  const reFeed = useCallback(() => {
    if (!table.current) return;
    // NOTE: the external-swap history reset does NOT live here. reFeed() fires on EVERY watched
    // change — including our OWN synchronous internal `$data.dataDefault` write — so a clear keyed
    // on a `currentData()` read here would (on fine-grained targets) fire mid-round-trip against a
    // TRANSIENTLY-STALE `$props.data` and wrongly wipe a just-recorded edit's history. The reset is
    // keyed on the `$props.data` REFERENCE actually changing instead — see the $onUpdate backstop
    // below (`maybeClearHistoryOnExternalSwap`), which runs on all six targets.
    table.current.setOptions((prev: any) => ({
      ...prev,
      data: currentData(),
      columns: tableColumns(),
      state: currentState(),
      enableRowSelection: props.selectionMode !== 'none',
      enableMultiRowSelection: props.selectionMode === 'multiple',
      // Re-pass the server-side page-count sources (#2) so a RUNTIME rowCount/pageCount change
      // takes effect: setOptions REPLACES via `...prev`, which holds the value captured at
      // createTable time, so an omitted key would freeze the mount-time count. The re-feed
      // $watch keys on both props below.
      rowCount: props.rowCount ?? undefined,
      pageCount: props.pageCount ?? undefined,
      // Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
      // setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
      // onExpandedChange callback must re-capture fresh currentState each cycle, F6).
      getExpandedRowModel: getExpandedRowModel(),
      getSubRows: (props.getSubRows || undefined) as any,
      getRowCanExpand: props.expandable === true && props.getSubRows == null ? () => true : undefined,
      onExpandedChange: onExpandedChangeCb,
      // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
      // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
      // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
      // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
      // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
      // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
      // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
      // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
      // are unaffected (they fire regardless of this flag).
      autoResetExpanded: false,
      // Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
      // omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
      // fresh currentState each cycle, F6).
      getGroupedRowModel: getGroupedRowModel(),
      onGroupingChange: onGroupingChangeCb,
      // Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
      // drop the model on re-feed; on React the faceted closures must re-capture so exposed
      // unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
      getFacetedRowModel: getFacetedRowModel(),
      getFacetedUniqueValues: makeFacetedUniqueValues(),
      getFacetedMinMaxValues: makeFacetedMinMaxValues(),
      // Re-pass the per-slice callbacks so React captures fresh currentState each cycle
      // (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
      onSortingChange: onSortingChangeCb,
      onGlobalFilterChange: onGlobalFilterChangeCb,
      onColumnFiltersChange: onColumnFiltersChangeCb,
      onPaginationChange: onPaginationChangeCb,
      onRowSelectionChange: onRowSelectionChangeCb,
      onColumnVisibilityChange: onColumnVisibilityChangeCb,
      onColumnSizingChange: onColumnSizingChangeCb,
      onColumnOrderChange: onColumnOrderChangeCb,
      onColumnPinningChange: onColumnPinningChangeCb,
      onColumnSizingInfoChange: onColumnSizingInfoChangeCb
    }));
    if (refreshRowModel.current) refreshRowModel.current();
  }, [currentData, currentState, onColumnFiltersChangeCb, onColumnOrderChangeCb, onColumnPinningChangeCb, onColumnSizingChangeCb, onColumnSizingInfoChangeCb, onColumnVisibilityChangeCb, onExpandedChangeCb, onGlobalFilterChangeCb, onGroupingChangeCb, onPaginationChangeCb, onRowSelectionChangeCb, onSortingChangeCb, props.expandable, props.getSubRows, props.pageCount, props.rowCount, props.selectionMode, tableColumns]);
  const maybeClearHistoryOnExternalSwap = useCallback(() => {
    const pd = data;
    if (pd === lastPropsData.current) return; // $props.data did not change → not an external swap
    lastPropsData.current = pd;
    if (!props.undoable) return;
    if (pd != null && (pd as any)[DATA_WRITE_TOKEN_KEY] != null) return; // descends from our write → keep
    clearHistory();
  }, [clearHistory, data, props.undoable]);
  const onHeaderSort = useCallback((colId: any, evt: any) => {
    if (!table.current) return;
    const col = table.current.getColumn(colId);
    if (!col || !col.getCanSort()) return;
    const multi = !!(evt && evt.shiftKey);
    // toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
    col.toggleSorting(undefined, multi);
  }, []);
  function tick() {
    return rowModelVer;
  }
  function ariaSortFor(colId: any) {
    if (tick() < 0 || !table.current) return 'none';
    const col = table.current.getColumn(colId);
    if (!col) return 'none';
    const dir = col.getIsSorted();
    if (dir === 'asc') return 'ascending';
    if (dir === 'desc') return 'descending';
    return 'none';
  }
  function sortIndicator(colId: any) {
    if (tick() < 0 || !table.current) return '';
    const col = table.current.getColumn(colId);
    if (!col) return '';
    const dir = col.getIsSorted();
    if (dir === 'asc') return '▲';
    if (dir === 'desc') return '▼';
    return '';
  }
  function defFor(colId: any) {
    const defs = columnDefs();
    for (const d of defs as any) if (d.id === colId) return d;
    return null;
  }
  function visibleCellsFor(row: any) {
    return rowModelVer >= 0 ? row.getVisibleCells() : [];
  }
  function editMetaOf(colId: any) {
    const d = defFor(colId);
    return d && d.meta ? d.meta : null;
  }
  function columnEditable(colId: any) {
    const m = editMetaOf(colId);
    return !!(m && m.editable === true);
  }
  function editorTypeOf(colId: any) {
    const m = editMetaOf(colId);
    return m && m.editor != null ? m.editor : 'text';
  }
  function editorOptionsOf(colId: any) {
    const m = editMetaOf(colId);
    return m && m.editorOptions != null ? m.editorOptions : [];
  }
  function hasEditorSlot(colId: any) {
    return editorTypeOf(colId) === 'custom' && !!(props.renderEditor ?? props.slots?.["editor"]);
  }
  function hasFilterSlot() {
    return !!(props.renderFilter ?? props.slots?.["filter"]);
  }
  function columnIsFilterable(colId: any) {
    const d = defFor(colId);
    return !!(d && d.filterable);
  }
  function headerLabel(colId: any) {
    const d = defFor(colId);
    return d ? d.header : colId;
  }
  function headerWidth(colId: any) {
    if (tick() < 0 || !table.current) return null;
    const col = table.current.getColumn(colId);
    if (!col) return null;
    const w = col.getSize();
    return w != null && w > 0 ? w + 'px' : null;
  }
  const onResizeStart = useCallback((colId: any, evt: any) => {
    // stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
    if (evt && evt.stopPropagation) evt.stopPropagation();
    if (!table.current) return;
    const header = findHeader(colId);
    if (!header || !header.getResizeHandler) return;
    const handler = header.getResizeHandler();
    if (handler) handler(evt);
  }, [findHeader]);
  function findHeader(colId: any) {
    const groups = headerGroups || [];
    for (const hg of groups as any) {
      const hs = hg.headers || [];
      for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
    }
    return null;
  }
  function columnIsResizing(colId: any) {
    if (tick() < 0 || !table.current) return false;
    const header = findHeader(colId);
    return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
  }
  function columnIsVisible(colId: any) {
    if (tick() < 0 || !table.current) return true;
    const col = table.current.getColumn(colId);
    return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
  }
  const onToggleVisibility = useCallback((colId: any) => {
    if (!table.current) return;
    const col = table.current.getColumn(colId);
    if (col && col.toggleVisibility) col.toggleVisibility();
  }, []);
  function allLeafColumns() {
    if (tick() < 0 || !table.current) return [];
    const cols = table.current.getAllLeafColumns ? table.current.getAllLeafColumns() : [];
    const out = [];
    for (const c of cols as any) {
      if (!c || c.id === SELECT_COL_ID || c.id === EXPANDER_COL_ID) continue;
      out.push({
        id: c.id,
        label: headerLabel(c.id),
        visible: !!(c.getIsVisible && c.getIsVisible())
      });
    }
    return out;
  }
  function columnPinSide(colId: any) {
    if (tick() < 0 || !table.current) return false;
    const col = table.current.getColumn(colId);
    if (!col || !col.getIsPinned) return false;
    return col.getIsPinned();
  }
  const onPinColumn = useCallback((colId: any, side: any, evt: any) => {
    if (evt && evt.stopPropagation) evt.stopPropagation();
    if (!table.current) return;
    const col = table.current.getColumn(colId);
    if (col && col.pin) col.pin(side);
  }, []);
  function pinStyle(colId: any, zIndex = 1) {
    if (tick() < 0 || !table.current) return '';
    const col = table.current.getColumn(colId);
    if (!col || !col.getIsPinned) return '';
    const side = col.getIsPinned();
    if (side === 'left') {
      const left = col.getStart ? col.getStart('left') : 0;
      return 'position:sticky;left:' + left + 'px;z-index:' + zIndex + ';';
    }
    if (side === 'right') {
      const right = col.getAfter ? col.getAfter('right') : 0;
      return 'position:sticky;right:' + right + 'px;z-index:' + zIndex + ';';
    }
    return '';
  }
  function thStyle(colId: any) {
    let s = '';
    const w = headerWidth(colId);
    if (w) s += 'width:' + w + ';';
    s += pinStyle(colId, 2);
    return s;
  }
  const onGlobalFilterInput = useCallback((evt: any) => {
    const value = evt && evt.target ? evt.target.value : '';
    if (table.current) {
      table.current.setGlobalFilter(value);
      return;
    }
    writeGlobalFilter(value);
  }, [writeGlobalFilter]);
  const onColumnFilterInput = useCallback((colId: any, evt: any) => {
    const value = evt && evt.target ? evt.target.value : '';
    setColumnFilter(colId, value);
  }, [setColumnFilter]);
  function globalFilterValue() {
    const v = currentState().globalFilter;
    return v != null ? v : '';
  }
  function pageIndex() {
    if (tick() >= 0 && table.current) return table.current.getState().pagination.pageIndex;
    const p = currentState().pagination;
    return p && p.pageIndex != null ? p.pageIndex : 0;
  }
  function pageSize() {
    if (tick() >= 0 && table.current) return table.current.getState().pagination.pageSize;
    const p = currentState().pagination;
    return p && p.pageSize != null ? p.pageSize : 10;
  }
  function displayPageCount() {
    if (tick() < 0 || !table.current) return 1;
    const c = table.current.getPageCount();
    return c != null && c > 0 ? c : 1;
  }
  function canPrevPage() {
    return !!(tick() >= 0 && table.current && table.current.getCanPreviousPage());
  }
  function canNextPage() {
    return !!(tick() >= 0 && table.current && table.current.getCanNextPage());
  }
  const onPrevPage = useCallback(() => {
    if (table.current) table.current.previousPage();
  }, []);
  const onNextPage = useCallback(() => {
    if (table.current) table.current.nextPage();
  }, []);
  const onPageSizeChange = useCallback((evt: any) => {
    if (!table.current) return;
    const v = evt && evt.target ? evt.target.value : '';
    const n = parseInt(v, 10);
    table.current.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
  }, []);
  function isSelectColumn(colId: any) {
    return colId === SELECT_COL_ID;
  }
  function isExpanderColumn(colId: any) {
    return colId === EXPANDER_COL_ID;
  }
  function rowCanExpand(row: any) {
    return !!(tick() >= 0 && row && row.getCanExpand && row.getCanExpand() && !(row.getIsGrouped && row.getIsGrouped()));
  }
  function rowIsExpanded(row: any) {
    return !!(tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
  }
  function rowShowsDetail(row: any) {
    return props.getSubRows == null && !rowIsGrouped(row) && rowIsExpanded(row);
  }
  const onToggleExpand = useCallback((row: any, evt: any) => {
    if (!row || !row.toggleExpanded) return;
    // Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
    // expanded-state re-render. This guards a focus-drop that USED to happen on Solid: when the
    // cell loop reconciled by reference (bare <For>), table-core's fresh cell instances each
    // pull rebuilt the expander <td>/<button> (the <tr> persisted but its cells were rebuilt),
    // dropping DOM focus to <body> and breaking keyboard activation (Enter/Space on the focused
    // expander left nothing focused). Since the emitter now emits `<Key>` for the
    // `:key="cellCtx.id"` cell loop, Solid keeps the cell node on a stable key too — so the
    // expander is no longer recreated and this re-focus is now a defensive no-op on ALL six
    // targets (re-focusing the SAME kept element — the focusActiveCell imperative-refocus
    // precedent). Kept for safety; it costs nothing when the node is unchanged. The rAF defers
    // past the synchronous reactive flush so any (re)created node exists first.
    const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
    row.toggleExpanded();
    if (ownerRow && typeof requestAnimationFrame === 'function') {
      requestAnimationFrame(() => {
        const btn = ownerRow.querySelector('[data-expander]');
        if (btn) btn.focus();
      });
    }
  }, []);
  function bodyCellStyle(row: any, colId: any) {
    const base = pinStyle(colId);
    if (isExpanderColumn(colId) && row && row.depth) {
      // Only the expander column indents (the tree affordance lives here).
      const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
      return base ? base + pad : pad;
    }
    return base;
  }
  function rowIsGrouped(row: any) {
    return !!(tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
  }
  function groupingActive() {
    return tick() >= 0 && (currentState().grouping || []).length > 0;
  }
  function cellIsGrouped(cellCtx: any) {
    return !!(tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
  }
  function cellIsAggregated(cellCtx: any) {
    return !!(tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
  }
  function cellIsPlaceholder(cellCtx: any) {
    return !!(tick() >= 0 && cellCtx && cellCtx.getIsPlaceholder && cellCtx.getIsPlaceholder());
  }
  function groupSubRowCount(row: any) {
    return row && row.getLeafRows ? row.getLeafRows().length : row && row.subRows ? row.subRows.length : 0;
  }
  function groupingKeys() {
    return currentState().grouping || [];
  }
  function groupableColumns() {
    const out = [];
    const defs = columnDefs();
    for (const d of defs as any) {
      if (!d || d.groupable === false) continue;
      out.push({
        id: d.id,
        label: d.header != null ? d.header : d.id
      });
    }
    return out;
  }
  const stopEvent = useCallback((evt: any) => {
    if (evt && evt.stopPropagation) evt.stopPropagation();
  }, []);
  function isAllRowsSelected() {
    return !!(tick() >= 0 && table.current && table.current.getIsAllRowsSelected());
  }
  function isSomeRowsSelected() {
    return !!(tick() >= 0 && table.current && table.current.getIsSomeRowsSelected());
  }
  const onToggleAllRows = useCallback((evt: any) => {
    if (!table.current) return;
    table.current.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
  }, []);
  function rowIsSelected(row: any) {
    if (!row) return false;
    const id = row.id;
    const sel = currentState().rowSelection || {};
    if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
    return !!(row.getIsSelected && row.getIsSelected());
  }
  const onToggleRow = useCallback((row: any, evt: any) => {
    if (!row || !row.toggleSelected) return;
    row.toggleSelected(!!(evt && evt.target && evt.target.checked));
  }, []);
  const onHideColumn = useCallback((colId: any, evt: any) => {
    if (evt && evt.stopPropagation) evt.stopPropagation();
    if (!table.current) return;
    const col = table.current.getColumn(colId);
    if (col && col.toggleVisibility) col.toggleVisibility(false);
  }, []);
  function hasAnyFilterableColumn() {
    const cols = allLeafColumns();
    for (const c of cols as any) {
      if (c && columnIsFilterable(c.id)) return true;
    }
    return false;
  }
  const syncIndeterminate = useCallback(() => {
    if (!__rozieRoot.current || !__rozieRoot.current!.querySelector) return;
    selectAllBox.current = __rozieRoot.current!.querySelector('.rdt-select-all');
    if (selectAllBox.current) selectAllBox.current.indeterminate = isSomeRowsSelected() && !isAllRowsSelected();
  }, [isAllRowsSelected, isSomeRowsSelected]);
  function sortColumn(colId: any, desc: any) {
    if (table.current) table.current.getColumn(colId) && table.current.getColumn(colId).toggleSorting(desc, false);
  }
  function clearSorting() {
    if (table.current) table.current.resetSorting(true);
  }
  function getColumnDefs() {
    return columnDefs();
  }
  function toggleAllRows(value: any) {
    if (table.current) table.current.toggleAllRowsSelected(value);
  }
  function clearSelection() {
    if (table.current) table.current.resetRowSelection(true);
  }
  function getSelectedRows() {
    return table.current ? table.current.getSelectedRowModel().rows.map((r: any) => r.original) : [];
  }
  function setPage(idx: any) {
    if (table.current) table.current.setPageIndex(idx);
  }
  function setRowsPerPage(size: any) {
    if (table.current) table.current.setPageSize(size);
  }
  function toggleColumnVisibility(colId: any) {
    if (table.current) {
      const c = table.current.getColumn(colId);
      if (c && c.toggleVisibility) c.toggleVisibility();
    }
  }
  function applyColumnOrder(order: any) {
    if (table.current) table.current.setColumnOrder(order);
  }
  function resetColumnSizing() {
    if (table.current) table.current.resetColumnSizing(true);
  }
  function pinColumn(colId: any, side: any) {
    if (table.current) {
      const c = table.current.getColumn(colId);
      if (c && c.pin) c.pin(side);
    }
  }
  function getRowIndexRelativeToPage(absRow: any) {
    const abs = absRow == null ? toAbsRow(activeRow) : Math.trunc(Number(absRow)) || 0;
    if (props.virtual) return abs;
    return abs - pageRowOffset();
  }
  function cut() {
    return cutRange();
  }
  const isGrid = useCallback(() => props.interactionMode === 'grid', [props.interactionMode]);
  function tableRole() {
    return isGrid() ? 'grid' : 'table';
  }
  function cellRole() {
    return isGrid() ? 'gridcell' : 'cell';
  }
  function rowIndexOf(row: any) {
    return tick() >= 0 ? (rows || []).indexOf(row) : -1;
  }
  function colIndexOf(row: any, cellCtx: any) {
    return tick() >= 0 ? visibleCellsFor(row).indexOf(cellCtx) : -1;
  }
  function headerColIndexOf(hg: any, header: any) {
    return (hg && hg.headers ? hg.headers : []).indexOf(header);
  }
  function pageRowOffset() {
    if (!isGrid() || props.virtual) return 0;
    return pageIndex() * pageSize();
  }
  function toAbsRow(localRow: any) {
    return localRow + pageRowOffset();
  }
  function prePaginationRowCount() {
    if (!table.current || props.virtual) return bodyRowCount();
    const pm = table.current.getPrePaginationRowModel();
    return pm && pm.rows ? pm.rows.length : bodyRowCount();
  }
  function cellTabindex(rowKey: any, colIndex: any, level = null) {
    if (!isGrid()) return null;
    // B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
    // the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
    // tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
    if (bodyRowCount() === 0) {
      return rowKey === '__header' && colIndex === 0 && level === headerLeafLevel() ? 0 : -1;
    }
    // B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
    // grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
    // lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
    // tab-stops (the roving invariant broke under grouped headers).
    if (activeIsHeader) {
      if (rowKey !== '__header') return -1;
      return colIndex === activeColIndex && level === activeHeaderLevel ? 0 : -1;
    }
    const isActive = rowKey === String(activeRow) && colIndex === activeColIndex;
    return isActive ? 0 : -1;
  }
  function isActiveCell(rowKey: any, colIndex: any, level = null) {
    if (!isGrid()) return false;
    if (activeIsHeader) {
      if (rowKey !== '__header') return false;
      return colIndex === activeColIndex && level === activeHeaderLevel;
    }
    if (rowKey === '__header') return false;
    return rowKey === String(activeRow) && colIndex === activeColIndex;
  }
  function resolveCellEl(rowKey: any, colIndex: any, level = null) {
    if (!gridRoot.current) return null;
    // B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
    // same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
    // integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
    // match (the parent) when the leaf is meant. level is an integer (NO consumer string is
    // interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
    // byte-unchanged.
    let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
    if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
    return gridRoot.current.querySelector(sel);
  }
  function focusActiveCell(nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) {
    if (!isGrid() || !gridRoot.current) return;
    // #9 focus-intent epoch: focusActiveCell is THE single seam every keyboard nav re-asserts
    // focus through, so it establishes a fresh "where focus should be" on every call — bump the
    // epoch here (BEFORE arming the virtual-scroll focusWhenReady poll below). A SUBSEQUENT
    // focusActiveCell (the next user nav) bumps again → any pending focusWhenReady captured the
    // OLD value → aborts instead of yanking focus back. The poll captures the POST-bump value so
    // a lone scroll-to-focus with no later nav still lands (epoch stable across its own frames).
    focusIntentEpoch.current = focusIntentEpoch.current + 1;
    const r = nextRow == null ? activeRow : nextRow;
    const c = nextCol == null ? activeColIndex : nextCol;
    // B12: thread the FRESH post-write header level (the grouped-header analog of the
    // nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
    // level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
    const lvl = nextLevel == null ? activeHeaderLevel : nextLevel;
    // Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
    // crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
    // Angular's signal write are async within one handler — re-reading $data.activeIsHeader
    // here returns the PRE-write value, resolving focus to the BODY cell instead of the
    // header. Callers pass the fresh isHeader local; falls back to $data when omitted.
    const header = nextIsHeader == null ? activeIsHeader : nextIsHeader;
    // ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
    // rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
    // double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
    // in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
    // The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
    // while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
    if (props.virtual && virtualizer.current && !header && rowIsOutsideWindow(r)) {
      virtualizer.current.scrollToIndex(r, {
        align: 'center'
      });
      // Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
      // bump → the framework commits the scrolled-in row. On React that commit is async (setState →
      // reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
      // fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
      // React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
      // fast-committing targets resolve on the first attempt (behavior unchanged), React retries
      // across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
      // cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
      let focusAttempts = 0;
      // #9: capture the epoch AFTER this call's own bump (above) so the poll never aborts itself
      // (its captured value equals the current epoch). A LATER focusActiveCell / focusCell /
      // active-cell-moving focusin bumps the epoch → the check below aborts this stale poll.
      const myEpoch = focusIntentEpoch.current;
      const focusWhenReady = () => {
        // A newer focus intent superseded this poll — abort WITHOUT focusing (the user has since
        // navigated / clicked elsewhere; re-focusing this off-window target would yank focus back).
        if (focusIntentEpoch.current !== myEpoch) return;
        const el = resolveCellEl(String(r), c);
        if (el) {
          el.focus();
          return;
        }
        focusAttempts = focusAttempts + 1;
        if (focusAttempts >= 30) return;
        if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
      };
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
      return;
    }
    const rowKey = header ? '__header' : String(r);
    const el = resolveCellEl(rowKey, c, header ? lvl : null);
    if (el) el.focus();
  }
  function totalRowCount() {
    if (!table.current) return (rows || []).length;
    const fm = table.current.getFilteredRowModel();
    return fm && fm.rows ? fm.rows.length : (rows || []).length;
  }
  function headerRowCount() {
    return (headerGroups || []).length;
  }
  function gridAriaRowCount() {
    return headerRowCount() + totalRowCount();
  }
  function ariaPageOffset() {
    return table.current ? pageIndex() * pageSize() : 0;
  }
  function bodyAriaRowIndex(row: any) {
    return headerRowCount() + rowIndexOf(row) + ariaPageOffset() + 1;
  }
  function visibleColCount() {
    // NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
    // state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
    // self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
    const rowList = rows || [];
    if (rowList.length) return rowList[0].getVisibleCells().length;
    const hg = headerGroups || [];
    return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
  }
  function bodyRowCount() {
    return (rows || []).length;
  }
  function clamp(v: any, lo: any, hi: any) {
    return v < lo ? lo : v > hi ? hi : v;
  }
  function headerLeafLevel() {
    const hg = headerGroups || [];
    return hg.length ? hg.length - 1 : 0;
  }
  function headerCountAtLevel(level: any) {
    const hg = headerGroups || [];
    if (!hg.length) return visibleColCount();
    const grp = level >= 0 && level < hg.length ? hg[level] : null;
    if (!grp || !grp.headers) return visibleColCount();
    return grp.headers.length;
  }
  function headerAt(level: any, colIndex: any) {
    const hg = headerGroups || [];
    const grp = hg[level];
    if (!grp || !grp.headers) return null;
    return grp.headers[colIndex] || null;
  }
  function parentHeaderColIndex(level: any, colIndex: any) {
    if (level <= 0) return -1;
    const h = headerAt(level, colIndex);
    if (!h || !h.column || !h.column.parent) return -1;
    const parentId = h.column.parent.id;
    const hg = headerGroups || [];
    const pg = hg[level - 1];
    if (!pg || !pg.headers) return -1;
    for (let i = 0; i < pg.headers.length; i++) {
      const ph = pg.headers[i];
      if (ph && ph.column && ph.column.id === parentId) return i;
    }
    return -1;
  }
  function firstChildHeaderColIndex(level: any, colIndex: any) {
    const h = headerAt(level, colIndex);
    if (!h || !h.column) return -1;
    const kids = h.column.columns || [];
    if (!kids.length) return -1;
    const childId = kids[0].id;
    const hg = headerGroups || [];
    const cg = hg[level + 1];
    if (!cg || !cg.headers) return -1;
    for (let i = 0; i < cg.headers.length; i++) {
      const ch = cg.headers[i];
      if (ch && ch.column && ch.column.id === childId) return i;
    }
    return -1;
  }
  function moveCol(delta: any) {
    // #10: when a grouped PARENT header is active, clamp against the header count AT THE ACTIVE
    // LEVEL (which may be fewer than the leaf-column count) so ArrowRight never overruns onto a
    // phantom cell past that level's headers. Body cells + the leaf header level keep visibleColCount().
    const count = activeIsHeader ? headerCountAtLevel(activeHeaderLevel) : visibleColCount();
    const max = count - 1;
    const nextCol = clamp(activeColIndex + delta, 0, max < 0 ? 0 : max);
    setActiveColIndex(nextCol);
    return nextCol;
  }
  function moveRow(delta: any) {
    const lastRow = bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const leafLevel = headerLeafLevel();
    if (activeIsHeader) {
      if (delta > 0) {
        // B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
        // level down); from the LEAF header level, drop into the body (row 0). A header-level
        // move re-targets activeColIndex (parent↔child column indices differ), so the fresh
        // col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
        if (activeHeaderLevel < leafLevel) {
          const childCol = firstChildHeaderColIndex(activeHeaderLevel, activeColIndex);
          if (childCol >= 0) {
            const nextLevel = activeHeaderLevel + 1;
            setActiveHeaderLevel(nextLevel);
            setActiveColIndex(childCol);
            return {
              row: activeRow,
              col: childCol,
              isHeader: true,
              level: nextLevel
            };
          }
        }
        // At the leaf header: an empty grid has no body to drop into → stay put.
        if (bodyRowCount() === 0) return {
          row: activeRow,
          col: activeColIndex,
          isHeader: true,
          level: activeHeaderLevel
        };
        // B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
        // (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
        // page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
        // (delta-1 = 0); clamped to the page-last body row.
        const landRow = clamp(delta - 1, 0, maxRow);
        setActiveIsHeader(false);
        setActiveRow(landRow);
        return {
          row: landRow,
          col: activeColIndex,
          isHeader: false,
          level: 0
        };
      }
      // B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
      // spans the active column; at the top level (or no real parent) stay put. The parent col
      // index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
      const parentCol = parentHeaderColIndex(activeHeaderLevel, activeColIndex);
      if (parentCol >= 0) {
        const nextLevel = activeHeaderLevel - 1;
        setActiveHeaderLevel(nextLevel);
        setActiveColIndex(parentCol);
        return {
          row: activeRow,
          col: parentCol,
          isHeader: true,
          level: nextLevel
        };
      }
      return {
        row: activeRow,
        col: activeColIndex,
        isHeader: true,
        level: activeHeaderLevel
      };
    }
    // In the body: an upward move from row 0 crosses into the LEAF header level (the header row
    // adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
    // activeColIndex carries over unchanged.
    if (delta < 0 && activeRow === 0) {
      setActiveIsHeader(true);
      setActiveHeaderLevel(leafLevel);
      return {
        row: activeRow,
        col: activeColIndex,
        isHeader: true,
        level: leafLevel
      };
    }
    const nextRow = clamp(activeRow + delta, 0, maxRow);
    setActiveRow(nextRow);
    setActiveIsHeader(false);
    return {
      row: nextRow,
      col: activeColIndex,
      isHeader: false,
      level: 0
    };
  }
  function gotoColEdge(toEnd: any) {
    // #10: End on a grouped PARENT header lands on that level's LAST header (headerCountAtLevel-1),
    // not the leaf-column max — otherwise the ring strands on a phantom cell past the level's
    // headers. Home is index 0 either way. Body cells + the leaf header level keep visibleColCount().
    const count = activeIsHeader ? headerCountAtLevel(activeHeaderLevel) : visibleColCount();
    const max = count - 1;
    const nextCol = toEnd ? max < 0 ? 0 : max : 0;
    setActiveColIndex(nextCol);
    return nextCol;
  }
  function gotoRowEdge(toEnd: any) {
    const lastRow = bodyRowCount() - 1;
    const nextRow = toEnd ? lastRow < 0 ? 0 : lastRow : 0;
    setActiveRow(nextRow);
    setActiveIsHeader(false);
    return nextRow;
  }
  function gotoStart() {
    setActiveIsHeader(false);
    setActiveRow(0);
    setActiveColIndex(0);
    return {
      row: 0,
      col: 0
    };
  }
  function gotoEnd() {
    const lastRow = bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const max = visibleColCount() - 1;
    const maxCol = max < 0 ? 0 : max;
    setActiveIsHeader(false);
    setActiveRow(maxRow);
    setActiveColIndex(maxCol);
    return {
      row: maxRow,
      col: maxCol
    };
  }
  function currentCellEl() {
    const rowKey = activeIsHeader ? '__header' : String(activeRow);
    return resolveCellEl(rowKey, activeColIndex, activeIsHeader ? activeHeaderLevel : null);
  }
  function focusables(cellEl: any) {
    if (!cellEl || !cellEl.querySelectorAll) return [];
    const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
    return list.filter((n: any) => !n.disabled);
  }
  function enterControl() {
    const cellEl = currentCellEl();
    const list = focusables(cellEl);
    if (!list.length) return;
    setActiveInControl(true);
    list[0].focus();
  }
  function cycleWithinCell(cellEl: any, forward: any) {
    const list = focusables(cellEl);
    if (!list.length) return;
    const active = gridRoot.current ? gridRoot.current.getRootNode().activeElement : null;
    const cur = list.indexOf(active);
    let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
    if (i >= list.length) i = 0;
    if (i < 0) i = list.length - 1;
    list[i].focus();
  }
  const { onActivecellChange: _rozieProp_onActivecellChange } = props;
    const onGridKeyDown = useCallback((e: any) => {
    if (!isGrid() || !e) return;
    const key = e.key;
    // Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
    // via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
    // early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
    // modes (editing / in-control / navigation) stay mutually exclusive and ordered.
    if (editingRow >= 0) return;
    // Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
    // editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
    // keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
    if (editingRowIndex != null) return;
    // Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
    if (activeInControl) {
      if (key === 'Escape') {
        e.preventDefault();
        setActiveInControl(false);
        // Return focus to the OWNING cell (no move happened) — pass the current indices
        // explicitly (the React-emitted seam types both params as required; a zero-arg call
        // is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
        focusActiveCell(activeRow, activeColIndex);
      } else if (key === 'Tab') {
        e.preventDefault();
        cycleWithinCell(currentCellEl(), !e.shiftKey);
      }
      return;
    }
    // WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
    // grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
    // user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
    // NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
    // focused node; if it is not itself a [data-grid-cell], let the event pass through.
    const tgt = e.target;
    if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
    // Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
    // nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
    // async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
    // plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
    // in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
    // fires ONLY on a real move (a clamped no-op edge move leaves them identical).
    const prevRow = activeRow;
    const prevCol = activeColIndex;
    const prevIsHeader = activeIsHeader;
    const prevLevel = activeHeaderLevel;
    let nextRow = prevRow;
    let nextCol = prevCol;
    let nextIsHeader = prevIsHeader;
    // B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
    // threaded into the focus seam so a leaf↔parent header move lands focus at the correct
    // level. moveRow returns it; the non-vertical branches keep the pre-move level.
    let nextLevel = prevLevel;
    // ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
    // the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
    // fall through to a plain navigation move). Body cells only (no range from a header). The
    // extendRange call owns focus + the range-change emit, so return immediately. ──────────
    // ── §8 (260709-3qt) Ctrl/Cmd+Arrow — jump the active cell to the data-region edge (plain
    // Ctrl) or EXTEND the range to that edge (Ctrl+Shift). Body cells only (a header-active
    // Ctrl+Arrow falls through to the plain-arrow branches unchanged). Tested BEFORE the
    // Shift+Arrow / plain-arrow cascade so the modifier combo is matched first. preventDefault
    // suppresses the browser's native Ctrl+Arrow scroll/word-jump. The Ctrl+Shift branch owns
    // extendRange's focus + range-change emit (returns); the plain-Ctrl branch sets the fresh
    // nextRow/nextCol locals and FALLS THROUGH to the shared focus seam (like Ctrl+Home/End). ──
    if ((e.ctrlKey || e.metaKey) && e.shiftKey && !activeIsHeader && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
      e.preventDefault();
      if (key === 'ArrowUp') extendRange(-activeRow, 0);else if (key === 'ArrowDown') extendRange(bodyRowCount() - 1 - activeRow, 0);else if (key === 'ArrowLeft') extendRange(0, -activeColIndex);else extendRange(0, visibleColCount() - 1 - activeColIndex);
      return;
    } else if ((e.ctrlKey || e.metaKey) && !activeIsHeader && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
      e.preventDefault();
      clearRange();
      if (key === 'ArrowUp') {
        nextRow = gotoRowEdge(false);
        nextIsHeader = false;
      } else if (key === 'ArrowDown') {
        nextRow = gotoRowEdge(true);
        nextIsHeader = false;
      } else if (key === 'ArrowLeft') {
        nextCol = gotoColEdge(false);
      } else {
        nextCol = gotoColEdge(true);
      }
    } else if (key === 'ArrowRight' && e.shiftKey && !activeIsHeader) {
      e.preventDefault();
      extendRange(0, 1);
      return;
    } else if (key === 'ArrowLeft' && e.shiftKey && !activeIsHeader) {
      e.preventDefault();
      extendRange(0, -1);
      return;
    } else if (key === 'ArrowDown' && e.shiftKey && !activeIsHeader) {
      e.preventDefault();
      extendRange(1, 0);
      return;
    } else if (key === 'ArrowUp' && e.shiftKey && !activeIsHeader) {
      e.preventDefault();
      extendRange(-1, 0);
      return;
    } else if (key === 'ArrowRight') {
      e.preventDefault();
      clearRange();
      nextCol = moveCol(1);
    } else if (key === 'ArrowLeft') {
      e.preventDefault();
      clearRange();
      nextCol = moveCol(-1);
    } else if (key === 'ArrowDown') {
      e.preventDefault();
      clearRange();
      const m = moveRow(1);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'ArrowUp') {
      e.preventDefault();
      clearRange();
      const m = moveRow(-1);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'PageDown') {
      e.preventDefault();
      const m = moveRow(GRID_PAGE_STEP);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'PageUp') {
      e.preventDefault();
      const m = moveRow(-GRID_PAGE_STEP);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'Home') {
      e.preventDefault();
      if (e.ctrlKey || e.metaKey) {
        const s = gotoStart();
        nextRow = s.row;
        nextCol = s.col;
        nextIsHeader = false;
      } else {
        nextCol = gotoColEdge(false);
      }
    } else if (key === 'End') {
      e.preventDefault();
      if (e.ctrlKey || e.metaKey) {
        const en = gotoEnd();
        nextRow = en.row;
        nextCol = en.col;
        nextIsHeader = false;
      } else {
        nextCol = gotoColEdge(true);
      }
    }
    // ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
    // pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
    // edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
    // type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
    // gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
    // falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
    // (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
    else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
      e.preventDefault();
      copyRange();
      return;
    } else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
      e.preventDefault();
      pasteRange();
      return;
    }
    // ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
    // source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
    // Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
    // and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
    // shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
    else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
      e.preventDefault();
      cutRange();
      return;
    }
    // ── 260709-8ct (grid-wide undo/redo) — Ctrl/Cmd+Z undoes; Ctrl/Cmd+Y OR Ctrl/Cmd+Shift+Z
    // redoes. Undoable-gated (`$props.undoable`) — when off, neither preventDefault nor
    // undo()/redo() runs, so a shipped grid with undoable unset is byte-behaviorally unchanged
    // (the browser's own native undo/redo, if any, still fires). NOT clipboardActiveAllowed-
    // gated (unlike Ctrl+C/V/X/Delete above): undo/redo is GRID-WIDE and must work regardless of
    // whether a header or body cell is active. Tested the Ctrl+Shift+Z (redo) combo BEFORE the
    // plain Ctrl+Z (undo) branch so a Shift+Z never falls into undo.
    else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey) && e.shiftKey) {
      if (props.undoable) {
        e.preventDefault();
        redo();
        return;
      }
    } else if ((key === 'y' || key === 'Y') && (e.ctrlKey || e.metaKey)) {
      if (props.undoable) {
        e.preventDefault();
        redo();
        return;
      }
    } else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey)) {
      if (props.undoable) {
        e.preventDefault();
        undo();
        return;
      }
    }
    // ── §7 (260709-3qt) — Delete/Backspace CLEARS the active cell / range through the SAME
    // write-funnel as Cut (applyGridToRange of an empty grid), MINUS the clipboard copy. B11-gated
    // by clipboardActiveAllowed so a header-active Delete/Backspace falls through to NATIVE behavior
    // (never a silent body mutation). The top-of-handler editing early-returns + the line-39
    // data-grid-cell guard keep this to navigation mode; applyGridToRange skips read-only/non-editable
    // cells. Reversible via Ctrl+Z when `undoable` is on (260709-8ct) — clearActiveRange funnels
    // through the SAME writeData seam undo/redo replay through, so no separate inverse machinery
    // is needed here.
    else if ((key === 'Delete' || key === 'Backspace') && clipboardActiveAllowed()) {
      e.preventDefault();
      clearActiveRange();
      return;
    }
    // ── §8 (260709-3qt) — Ctrl/Cmd+A selects the WHOLE BODY range (drives the same range corners
    // shift+arrow uses). preventDefault ALWAYS so the page is never selected in grid mode; only a
    // body-active Ctrl+A builds the range (a header-active Ctrl+A is a no-op — selects nothing). ──
    else if ((key === 'a' || key === 'A') && (e.ctrlKey || e.metaKey)) {
      e.preventDefault();
      if (!activeIsHeader) selectAllBody();
      return;
    }
    // ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
    // EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
    // (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
    // collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
    // at least the active editable column); a non-editable active cell falls through unchanged.
    else if (key === 'F2' && e.shiftKey && isActiveCellEditable()) {
      e.preventDefault();
      beginRowEdit((rows || [])[activeRow]);
      return;
    }
    // ── Boolean in-place toggle (design doc 2026-07-05, Change 1) — a built-in
    // editor:'checkbox' cell toggles + commits INSTANTLY on Space/Enter/F2, no editor opens
    // (the spreadsheet-standard shape for a two-state value). Tested BEFORE the generic
    // Enter/F2 edit-entry branch below (a checkbox cell must never fall into the open-an-
    // editor ceremony) and gated the SAME way (isActiveCellEditable) plus editorTypeOf ===
    // 'checkbox'. Full-row edit mode is unaffected — the editingRowIndex early return at the
    // top of onGridKeyDown already excludes it.
    else if ((key === 'Enter' || key === 'F2' || key === ' ') && isActiveCellEditable() && editorTypeOf(activeCellColumnId()) === 'checkbox') {
      e.preventDefault();
      toggleActiveBooleanCell();
      return;
    }
    // ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
    // Gated by isActiveCellEditable(): a non-editable active cell falls through to
    // enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
    // (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
    else if ((key === 'Enter' || key === 'F2') && isActiveCellEditable()) {
      e.preventDefault();
      beginEdit(activeRow, activeColIndex, null);
      return;
    } else if (isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && editorTypeOf(activeCellColumnId()) !== 'checkbox') {
      // B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
      // checkbox/select/date editor must NOT take the typed char as its value (it would
      // force-check the checkbox, seed a garbage select option, or corrupt the date) — open
      // those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
      // Checkbox is excluded entirely (type-to-edit disabled — the branch above already
      // handles Space/Enter/F2; any OTHER printable key on a checkbox cell is a no-op).
      e.preventDefault();
      const editType = editorTypeOf(activeCellColumnId());
      const seed = editType === 'text' || editType === 'number' ? key : null;
      beginEdit(activeRow, activeColIndex, seed);
      return;
    }
    // ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
    // expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
    // verified invariant) so it never hits the edit branches above and would otherwise fall to
    // enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
    // Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
    // model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
    // ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
    // full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
    else if (key === 'Enter' && !activeIsHeader && rowIsGrouped((rows || [])[activeRow])) {
      e.preventDefault();
      // C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
      // active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
      // the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
      // row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
      // fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
      // DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
      // while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
      // settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
      // onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
      // SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
      // until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
      // already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
      const grpRow = activeRow;
      const grpCol = activeColIndex;
      onToggleExpand((rows || [])[activeRow], e);
      // guardMoved=true: the group header row is UNCHANGED by its own collapse, so a stale late
      // rAF poll must not steal focus back after the user has already ArrowDown'd to another row.
      recoverGridFocus(String(grpRow), grpCol, null, true);
      return;
    } else if (key === 'Enter' || key === 'F2') {
      e.preventDefault();
      enterControl();
      return;
    } else return;
    // THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
    // focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
    focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
    // WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
    // changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
    // row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
    // B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
    // C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
    // keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
    // toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
    // The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
    if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
      // Mirror getActiveCell's shape (this payload + getActiveCell are documented to speak the
      // SAME language): a header cell has no body-row index, so emit rowIndex:null + isHeader:true
      // rather than a bogus toAbsRow(nextRow) — which would compute a real body-row absolute index
      // for a HEADER move, misleading a consumer into thinking that body row is the active cell.
      _rozieProp_onActivecellChange && _rozieProp_onActivecellChange(nextIsHeader ? {
        rowIndex: null,
        colIndex: nextCol,
        isHeader: true
      } : {
        rowIndex: toAbsRow(nextRow),
        colIndex: nextCol,
        isHeader: false
      });
    }
  }, [_rozieProp_onActivecellChange, activeCellColumnId, activeColIndex, activeHeaderLevel, activeInControl, activeIsHeader, activeRow, beginEdit, beginRowEdit, bodyRowCount, clearActiveRange, clearRange, clipboardActiveAllowed, copyRange, currentCellEl, cutRange, cycleWithinCell, editingRow, editingRowIndex, editorTypeOf, enterControl, extendRange, focusActiveCell, gotoColEdge, gotoEnd, gotoRowEdge, gotoStart, isActiveCellEditable, isGrid, moveCol, moveRow, onToggleExpand, pasteRange, props.undoable, recoverGridFocus, redo, rowIsGrouped, rows, selectAllBody, toAbsRow, toggleActiveBooleanCell, undo, visibleColCount]);
  const syncActiveFromEvent = useCallback((e: any) => {
    if (!isGrid() || !e) return;
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null) return;
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(col)) return;
    // #9: snapshot the PRE-write active position so we can bump the focus-intent epoch ONLY when
    // this focusin genuinely MOVES the active cell (a click landing on a NEW cell). A no-op focusin
    // — focus arriving on the ALREADY-active cell, e.g. a scroll/page-switch poll's own el.focus()
    // or focusActiveCell's synchronous re-seat — must NOT bump, or it would abort a legitimate
    // in-flight recovery on its own settling frames (the poll would see a changed epoch and quit).
    const prevIsHeader = activeIsHeader;
    const prevRow = activeRow;
    const prevCol = activeColIndex;
    const prevLevel = activeHeaderLevel;
    const isHeader = rowAttr === '__header';
    setActiveIsHeader(isHeader);
    let movedRow = prevRow;
    let movedLevel = prevLevel;
    if (isHeader) {
      // B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
      // roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
      // one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
      const lvlAttr = cellEl.getAttribute('data-header-level');
      const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : headerLeafLevel();
      movedLevel = Number.isFinite(lvl) ? lvl : headerLeafLevel();
      setActiveHeaderLevel(movedLevel);
    } else {
      const row = parseInt(rowAttr, 10);
      if (Number.isFinite(row)) {
        movedRow = row;
        setActiveRow(row);
      }
    }
    setActiveColIndex(col);
    // #9: a genuine active-cell MOVE is a fresh focus intent — supersede any pending async focus
    // poll (scroll-to / page-switch). Compare against the PRE-write snapshot: bump only when the
    // header-flag, column, or (per mode) the header LEVEL / body ROW actually changed.
    if (isHeader !== prevIsHeader || col !== prevCol || (isHeader ? movedLevel !== prevLevel : movedRow !== prevRow)) {
      focusIntentEpoch.current = focusIntentEpoch.current + 1;
    }
    // A plain focus collapses any range back to the single active cell — EXCEPT (a) the
    // programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
    // ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
    // focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
    // BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
    // path owns the shift case and flags it here so the collapse is skipped.
    if (rangeTransition.current) {
      rangeTransition.current = false;
    } else if (rangeClickPending.current) {
      rangeClickPending.current = false;
    } else {
      clearRange();
    }
    // The cell box (not an inner control) receiving focus = navigation mode.
    if (tgt === cellEl) setActiveInControl(false);
  }, [activeColIndex, activeHeaderLevel, activeIsHeader, activeRow, clearRange, headerLeafLevel, isGrid]);
  const onGridMouseDown = useCallback((e: any) => {
    if (!isGrid() || !e) return;
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    // §6: a plain mousedown inside the fill handle is owned by the handle's own pointerdown drag —
    // never begin a range paint from it (the shift path never lands on the 8px handle).
    if (!e.shiftKey && tgt.closest('[data-fill-handle]')) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
    const row = parseInt(rowAttr, 10);
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(row) || !Number.isFinite(col)) return;
    if (e.shiftKey) {
      // Shift+Click: set the moving corner (keeping the anchor) and flag rangeClickPending so the
      // follow-up focusin does not collapse the range (a focusin carries no reliable shiftKey).
      setRangeFocus$local(row, col);
      setActiveIsHeader(false);
      setActiveRow(row);
      setActiveColIndex(col);
      rangeClickPending.current = true;
      return;
    }
    // §6 plain mousedown → begin a document-level drag-select anchored at this cell. The mousedown's
    // native focusin commits the ACTIVE cell to (row,col); beginRangeDrag's first cross-cell
    // pointermove paints the range via setRangeFocus (anchored at the active cell). A mousedown with
    // no move collapses to a single active cell (no range).
    beginRangeDrag(row, col);
  }, [beginRangeDrag, isGrid, setRangeFocus$local]);
  const onGridDblClick = useCallback((e: any) => {
    if (!isGrid() || !e) return;
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
    const row = parseInt(rowAttr, 10);
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(row) || !Number.isFinite(col)) return;
    // NB the local is `rowObj` (NOT `activeRow`): $data.activeRow lowers to the bare React state
    // binding `activeRow`, so a `const activeRow = …` local self-shadows it (TS2448 TDZ — the
    // visibleColCount `rowList` self-shadow class). ($data.rows || [])[row] is the active flattened
    // row (page-relative non-virtual / full-model virtual — both index $data.rows, matching the C2
    // Enter-on-group path + syncActiveFromEvent's row parse).
    const rowObj = (rows || [])[row];
    if (rowIsGrouped(rowObj)) {
      // Group-header cell → toggle its collapse/expand through the SAME onToggleExpand funnel the
      // chevron uses (mirrors the C2 Enter-on-group path verbatim), then re-seat focus after the
      // re-render (guardMoved=true — the group-header row is unchanged by its own collapse, so a
      // stale late rAF must not steal focus back after a subsequent nav).
      e.preventDefault();
      onToggleExpand(rowObj, e);
      recoverGridFocus(String(row), col, null, true);
      return;
    }
    // Editable body cell → open its editor (seed=null → seed the EXISTING value, the in-place F2/
    // Enter entry). A non-editable body cell is a no-op: the cell stays active (focusin already set
    // it + the §1 ring), matching the spreadsheet display-vs-edit convention.
    const colId = columnIdAt(row, col);
    if (colId != null && columnEditable(colId)) {
      e.preventDefault();
      beginEdit(row, col, null);
    }
  }, [beginEdit, columnEditable, columnIdAt, isGrid, onToggleExpand, recoverGridFocus, rowIsGrouped, rows]);
  const onGridClick = useCallback((e: any) => {
    if (!isGrid() || !e) return;
    if (!props.singleClickEdit) return;
    if (e.shiftKey) return;
    // §6 (260709-3qt): a drag-select that MOVED must never open the editor — the editor opens only
    // on a genuine mouseup-no-drag click. beginRangeDrag resets rangeDragMoved=false per gesture, so
    // the flag is always fresh; consume it here so a subsequent plain click still edits.
    if (rangeDragMoved.current) {
      rangeDragMoved.current = false;
      return;
    }
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
    const row = parseInt(rowAttr, 10);
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(row) || !Number.isFinite(col)) return;
    // Already editing THIS exact cell → no-op (a click inside an open editor must not re-open it).
    if (editingRow === row && editingCol === col) return;
    const colId = columnIdAt(row, col);
    if (colId != null && columnEditable(colId)) beginEdit(row, col, null);
  }, [beginEdit, columnEditable, columnIdAt, editingCol, editingRow, isGrid, props.singleClickEdit]);
  const onGridFocusOut = useCallback((e: any) => {
    if (!isGrid() || !activeInControl) return;
    const next = e ? e.relatedTarget : null;
    const cellEl = currentCellEl();
    if (!cellEl || !next || !cellEl.contains(next)) setActiveInControl(false);
  }, [activeInControl, currentCellEl, isGrid]);
  function recoverGridFocus(rowKey: any, col: any, level: any, guardMoved = false) {
    if (!gridRoot.current) return;
    let attempts = 0;
    const tryFocus = () => {
      if (guardMoved) {
        const ae = gridRoot.current && gridRoot.current.getRootNode ? gridRoot.current.getRootNode().activeElement : null;
        const aeCell = ae && ae.closest ? ae.closest('[data-grid-cell]') : null;
        if (aeCell && gridRoot.current.contains(aeCell)) {
          const aeRow = aeCell.getAttribute('data-row');
          if (aeRow != null && aeRow !== rowKey) return;
        }
      }
      const el = resolveCellEl(rowKey, col, level);
      if (el) {
        el.focus();
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  }
  const clampActiveCell = useCallback((rowCount: any, colCount: any) => {
    if (!isGrid()) return;
    // B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
    // just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
    // async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
    // and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
    // out of bounds on React only). Falls back to the live helpers when called without bounds.
    const colN = colCount != null ? colCount : visibleColCount();
    const rowN = rowCount != null ? rowCount : bodyRowCount();
    // B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
    // shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
    // framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
    // — true on all six, incl React's async setState), so the doomed cell + its focus are still
    // observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
    // programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
    // Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
    // doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
    // The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
    // NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
    // shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
    // DOM coords are always fresh.
    let recoverFocus = false;
    let doomedRow = -1;
    let doomedCol = 0;
    if (gridRoot.current) {
      const rootNode = gridRoot.current.getRootNode ? gridRoot.current.getRootNode() : null;
      const focusedEl = rootNode ? rootNode.activeElement : null;
      const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
      if (focusedCell && gridRoot.current.contains(focusedCell)) {
        const fRowAttr = focusedCell.getAttribute('data-row');
        const fColAttr = focusedCell.getAttribute('data-col-index');
        if (fRowAttr != null && fRowAttr !== '__header') {
          const fr = parseInt(fRowAttr, 10);
          const fc = parseInt(fColAttr, 10);
          if (Number.isFinite(fr) && fr > rowN - 1) {
            recoverFocus = true;
            doomedRow = fr;
            doomedCol = Number.isFinite(fc) ? fc : 0;
          }
        }
      }
    }
    const maxCol = colN - 1;
    const col = clamp(activeColIndex, 0, maxCol < 0 ? 0 : maxCol);
    if (col !== activeColIndex) setActiveColIndex(col);
    // B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
    // cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
    // an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
    // re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
    if (rowN <= 0) {
      setActiveIsHeader(true);
      setActiveHeaderLevel(headerLeafLevel());
      setActiveColIndex(0);
      // B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
      // reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
      // async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
      // class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
      // recovery branch below actually runs on React too.
      gridEmptyFallback.current = true;
      clampRange(rowN - 1, colN - 1);
      // B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
      // reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
      // Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
      // (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
      // the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
      return;
    }
    // B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
    // re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
    // cell. A user-driven header position (not the empty fallback) is left untouched.
    if (gridEmptyFallback.current) {
      gridEmptyFallback.current = false;
      setActiveIsHeader(false);
      setActiveRow(0);
    }
    if (!activeIsHeader) {
      const lastRow = rowN - 1;
      const maxRow = lastRow < 0 ? 0 : lastRow;
      const row = clamp(activeRow, 0, maxRow);
      if (row !== activeRow) setActiveRow(row);
    }
    // B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
    // shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
    // out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
    clampRange(rowN - 1, colN - 1);
    // B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
    // when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
    // into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
    if (recoverFocus) {
      const recRow = clamp(doomedRow, 0, rowN - 1);
      const recCol = clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
      recoverGridFocus(String(recRow), recCol, null);
    }
  }, [activeColIndex, activeIsHeader, activeRow, bodyRowCount, clamp, clampRange, headerLeafLevel, isGrid, recoverGridFocus, visibleColCount]);
  function inRange(rIdx: any, cIdx: any) {
    const a = rangeAnchor;
    const f = rangeFocus;
    if (!a || !f) return false;
    const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
    const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
    const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
    const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
    return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
  }
  function getSelectedRange() {
    // B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
    // payload) never reports a corner past a shrunken model — React-stale-safe (the eager
    // refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
    const a = rangeAnchor;
    const f = rangeFocus;
    if (!a && !f) return {
      anchor: null,
      focus: null
    };
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return {
      anchor: null,
      focus: null
    };
    const clampCorner = (c: any) => c == null ? null : {
      rowIndex: clamp(c.rowIndex, 0, maxRow),
      colIndex: clamp(c.colIndex, 0, maxCol)
    };
    return {
      anchor: clampCorner(a),
      focus: clampCorner(f)
    };
  }
  function isFillHandleCell(rIdx: any, cIdx: any) {
    const a = rangeAnchor;
    const f = rangeFocus;
    if (!a || !f) return false;
    const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
    const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
    return rIdx === r1 && cIdx === c1;
  }
  function emitRangeChange(anchor: any, focus: any) {
    props.onRangeChange && props.onRangeChange({
      anchor,
      focus
    });
  }
  function extendRange(dRow: any, dCol: any) {
    if (activeIsHeader) return;
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return;
    // Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
    let anchor = rangeAnchor;
    let focus = rangeFocus;
    const hadRange = !!(anchor && focus);
    if (!anchor || !focus) {
      anchor = {
        rowIndex: activeRow,
        colIndex: activeColIndex
      };
      focus = {
        rowIndex: activeRow,
        colIndex: activeColIndex
      };
    }
    const nextRow = clamp(focus.rowIndex + dRow, 0, maxRow);
    const nextCol = clamp(focus.colIndex + dCol, 0, maxCol);
    const nextFocus = {
      rowIndex: nextRow,
      colIndex: nextCol
    };
    setRangeAnchor(anchor);
    setRangeFocus(nextFocus);
    rangeActive.current = true;
    // Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
    // from the range's leading edge, the spreadsheet convention).
    setActiveRow(nextRow);
    setActiveColIndex(nextCol);
    // Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
    // settle on the new focus corner is part of THIS range extension, not a fresh navigation.
    rangeTransition.current = true;
    focusActiveCell(nextRow, nextCol, false);
    // B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
    // and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
    // change → no emit. Seeding a brand-new range (no prior range) is always a change (the
    // rectangle came into existence) even if its first corner is a degenerate 1×1.
    if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
      emitRangeChange(anchor, nextFocus);
    }
  }
  function setRangeFocus$local(rIdx: any, cIdx: any) {
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return;
    let anchor = rangeAnchor;
    if (!anchor) anchor = {
      rowIndex: activeRow,
      colIndex: activeColIndex
    };
    const r = clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
    const c = clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
    const nextFocus = {
      rowIndex: r,
      colIndex: c
    };
    setRangeAnchor(anchor);
    setRangeFocus(nextFocus);
    rangeActive.current = true;
    emitRangeChange(anchor, nextFocus);
  }
  function selectAllBody() {
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return;
    const anchor = {
      rowIndex: 0,
      colIndex: 0
    };
    const focus = {
      rowIndex: maxRow,
      colIndex: maxCol
    };
    setRangeAnchor(anchor);
    setRangeFocus(focus);
    rangeActive.current = true;
    emitRangeChange(anchor, focus);
  }
  function clearRange() {
    // B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
    // in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
    // move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
    // would let the SECOND call through and emit a duplicate range-change. rangeActive flips
    // synchronously → the second call returns here.
    if (!rangeActive.current) return;
    rangeActive.current = false;
    setRangeAnchor(null);
    setRangeFocus(null);
    emitRangeChange(null, null);
  }
  function clampRange(maxRowArg: any, maxColArg: any) {
    const a = rangeAnchor;
    const f = rangeFocus;
    if (!a && !f) return;
    // Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
    // shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
    const maxRow = maxRowArg != null ? maxRowArg : bodyRowCount() - 1;
    const maxCol = maxColArg != null ? maxColArg : visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) {
      setRangeAnchor(null);
      setRangeFocus(null);
      rangeActive.current = false;
      return;
    }
    if (a) {
      const ar = clamp(a.rowIndex, 0, maxRow);
      const ac = clamp(a.colIndex, 0, maxCol);
      if (ar !== a.rowIndex || ac !== a.colIndex) setRangeAnchor({
        rowIndex: ar,
        colIndex: ac
      });
    }
    if (f) {
      const fr = clamp(f.rowIndex, 0, maxRow);
      const fc = clamp(f.colIndex, 0, maxCol);
      if (fr !== f.rowIndex || fc !== f.colIndex) setRangeFocus({
        rowIndex: fr,
        colIndex: fc
      });
    }
  }
  function announce(msg: any) {
    setPasteAnnounce(msg != null ? msg : '');
  }
  function clipboardActiveAllowed() {
    return !activeIsHeader;
  }
  function fieldOfColId(colId: any) {
    const d = defFor(colId);
    return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
  }
  function normalizedRange() {
    const a = rangeAnchor;
    const f = rangeFocus;
    if (!a || !f) return null;
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return null;
    const ar = clamp(a.rowIndex, 0, maxRow);
    const ac = clamp(a.colIndex, 0, maxCol);
    const fr = clamp(f.rowIndex, 0, maxRow);
    const fc = clamp(f.colIndex, 0, maxCol);
    return {
      r0: ar < fr ? ar : fr,
      r1: ar > fr ? ar : fr,
      c0: ac < fc ? ac : fc,
      c1: ac > fc ? ac : fc
    };
  }
  function escapeTsvField(s: any) {
    if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
      return '"' + s.replace(/"/g, '""') + '"';
    }
    return s;
  }
  function rangeToTsv() {
    const box = normalizedRange();
    const r0 = box ? box.r0 : activeRow;
    const r1 = box ? box.r1 : activeRow;
    const c0 = box ? box.c0 : activeColIndex;
    const c1 = box ? box.c1 : activeColIndex;
    const lines = [];
    for (let r = r0; r <= r1; r++) {
      const cells = [];
      for (let c = c0; c <= c1; c++) {
        const v = cellValueAt(r, c);
        cells.push(escapeTsvField(v == null ? '' : String(v)));
      }
      lines.push(cells.join('\t'));
    }
    return lines.join('\n');
  }
  function parseTsv(text: any) {
    const str = text != null ? String(text) : '';
    // CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
    // large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
    // single-pass scan allocates a cell-per-character grid.
    if (str === '' || str.length > 2000000) return [];
    // B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
    // cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
    // newlines, and doubled quotes ("") inside it are literal content until the closing quote;
    // an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
    const rows = [];
    let row = [];
    let field = '';
    let inQuotes = false;
    let i = 0;
    const n = str.length;
    while (i < n) {
      const ch = str[i];
      if (inQuotes) {
        if (ch === '"') {
          if (i + 1 < n && str[i + 1] === '"') {
            field = field + '"';
            i = i + 2;
            continue;
          }
          inQuotes = false;
          i = i + 1;
          continue;
        }
        field = field + ch;
        i = i + 1;
        continue;
      }
      if (ch === '"' && field === '') {
        inQuotes = true;
        i = i + 1;
        continue;
      }
      if (ch === '\t') {
        row.push(field);
        field = '';
        i = i + 1;
        continue;
      }
      if (ch === '\r') {
        if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
        row.push(field);
        field = '';
        rows.push(row);
        row = [];
        i = i + 1;
        continue;
      }
      if (ch === '\n') {
        row.push(field);
        field = '';
        rows.push(row);
        row = [];
        i = i + 1;
        continue;
      }
      field = field + ch;
      i = i + 1;
    }
    // Flush the trailing field + row.
    row.push(field);
    rows.push(row);
    // Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
    if (rows.length > 1) {
      const last = rows[rows.length - 1];
      if (last.length === 1 && last[0] === '') rows.pop();
    }
    return rows;
  }
  function copyRange() {
    // B11: never copy from a header-active state (the reusable clipboard guard).
    if (!clipboardActiveAllowed()) return;
    if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
    try {
      const p = navigator.clipboard.writeText(rangeToTsv());
      if (p && p.catch) p.catch(() => {});
    } catch (err: any) {/* best-effort copy */}
  }
  function applyGridToRange(grid: any, originRow: any, originCol: any) {
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return {
      wrote: 0,
      total: 0
    };
    let total = 0;
    let wrote = 0;
    const committed = [];
    // Build the fresh data array incrementally so the whole paste is ONE writeData.
    let next = currentData();
    for (let gr = 0; gr < grid.length; gr++) {
      const r = originRow + gr;
      if (r > maxRow) break;
      const cols = grid[gr] || [];
      for (let gc = 0; gc < cols.length; gc++) {
        const c = originCol + gc;
        if (c > maxCol) break;
        total = total + 1;
        const colId = columnIdAt(r, c);
        if (colId == null || !columnEditable(colId)) continue;
        const rowObj = rowOriginalAt(r);
        // B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
        // single-cell commit coercion) — a numeric column commits a real Number, an empty cell
        // commits null; every other editor type passes through verbatim. No mixed/garbage types
        // ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
        const value = coerceCellValue(colId, cols[gc]);
        // T-51-01: validate the pasted value as plain DATA before any write.
        if (runValidator(colId, value, rowObj) !== true) continue;
        const field = fieldOfColId(colId);
        const srcIndex = sourceIndexOfRow(r);
        const oldValue = rowObj ? rowObj[field] : null;
        next = replaceRowValue(next, srcIndex, field, value);
        committed.push({
          rowId: rowIdAt(r),
          columnId: colId,
          oldValue,
          newValue: value
        });
        wrote = wrote + 1;
      }
    }
    if (wrote > 0) {
      editTransition.current = true;
      writeData(next);
      editTransition.current = false;
      // One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
      for (let i = 0; i < committed.length; i++) props.onCellEditCommit && props.onCellEditCommit(committed[i]);
    }
    // WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
    // targeted real cells but every one was skipped (validation-failed / non-editable), announce a
    // distinct validation-failed message instead of a misleading "0 of M cells pasted".
    if (wrote > 0) announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) announce('No cells pasted — ' + total + ' cells were invalid or read-only');
    return {
      wrote,
      total
    };
  }
  function rowOriginalAt(rowIndex: any) {
    const rowList = rows || [];
    const row = rowList[rowIndex];
    return row ? row.original : null;
  }
  function rowIdAt(rowIndex: any) {
    const rowList = rows || [];
    const row = rowList[rowIndex];
    return row ? row.id : null;
  }
  function tileGridToBox(grid: any, box: any) {
    const srcRows = grid.length;
    // srcCols is the MAX row width across ALL rows (not grid[0].length): a RAGGED clipboard
    // (a later row WIDER than the first, e.g. TSV "a\tb\nc\td\te") would otherwise never read
    // the extra column and silently drop those cells. A row SHORTER than srcCols tiles its
    // missing cells as '' (the `v != null ? v : ''` coercion below), never undefined.
    let srcCols = 0;
    for (let i = 0; i < srcRows; i++) {
      const w = grid[i] && grid[i].length ? grid[i].length : 0;
      if (w > srcCols) srcCols = w;
    }
    if (srcRows <= 0 || srcCols <= 0) return grid;
    const boxRows = box.r1 - box.r0 + 1;
    const boxCols = box.c1 - box.c0 + 1;
    const rows = boxRows > srcRows ? boxRows : srcRows;
    const cols = boxCols > srcCols ? boxCols : srcCols;
    const out = [];
    for (let r = 0; r < rows; r++) {
      const srcLine = grid[r % srcRows] || [];
      const line = [];
      for (let c = 0; c < cols; c++) {
        const v = srcLine[c % srcCols];
        line.push(v != null ? v : '');
      }
      out.push(line);
    }
    return out;
  }
  function pasteRange() {
    // B11: never paste into a header-active state (the reusable clipboard guard) — a header
    // anchor would silently write body row 0 at the header's column.
    if (!clipboardActiveAllowed()) return;
    if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
    // CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
    // C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
    // single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
    // on React; re-reading them inside the async .then() returns the mount-render stale value, so a
    // selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
    // the box + anchor now and pass them into tileGridToBox / applyGridToRange.
    const box = normalizedRange();
    const anchorRow = box ? box.r0 : activeRow;
    const anchorCol = box ? box.c0 : activeColIndex;
    const destBox = box || {
      r0: anchorRow,
      r1: anchorRow,
      c0: anchorCol,
      c1: anchorCol
    };
    let p: any = null;
    try {
      p = navigator.clipboard.readText();
    } catch (err: any) {
      return;
    }
    if (!p || !p.then) return;
    p.then((text: any) => {
      const grid = parseTsv(text);
      if (!grid.length) return;
      // C3: tile the clipboard block to fill the destination range (single→range fill,
      // smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
      const tiled = tileGridToBox(grid, destBox);
      applyGridToRange(tiled, anchorRow, anchorCol);
    }).catch(() => {});
  }
  function cutRange() {
    if (!clipboardActiveAllowed()) return;
    // Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
    const box = normalizedRange();
    const r0 = box ? box.r0 : activeRow;
    const r1 = box ? box.r1 : activeRow;
    const c0 = box ? box.c0 : activeColIndex;
    const c1 = box ? box.c1 : activeColIndex;
    // Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
    if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
      try {
        const cp = navigator.clipboard.writeText(rangeToTsv());
        if (cp && cp.catch) cp.catch(() => {});
      } catch (err: any) {/* best-effort copy */}
    }
    // Clear the source: a grid of empty strings sized to the range, applied at the top-left.
    const grid = [];
    for (let r = r0; r <= r1; r++) {
      const cols = [];
      for (let c = c0; c <= c1; c++) cols.push('');
      grid.push(cols);
    }
    applyGridToRange(grid, r0, c0);
  }
  function clearActiveRange() {
    if (!clipboardActiveAllowed()) return;
    // Snapshot the source rectangle synchronously (the ROZ138 concern cutRange/pasteRange share).
    const box = normalizedRange();
    const r0 = box ? box.r0 : activeRow;
    const r1 = box ? box.r1 : activeRow;
    const c0 = box ? box.c0 : activeColIndex;
    const c1 = box ? box.c1 : activeColIndex;
    const grid = [];
    for (let r = r0; r <= r1; r++) {
      const cols = [];
      for (let c = c0; c <= c1; c++) cols.push('');
      grid.push(cols);
    }
    applyGridToRange(grid, r0, c0);
  }
  function tileIndex(i: any, lo: any, hi: any) {
    const span = hi - lo + 1;
    if (span <= 1) return lo;
    let k = (i - lo) % span;
    if (k < 0) k = k + span;
    return lo + k;
  }
  function fillRange(sourceBox: any, endCell: any) {
    // B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
    // the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
    // React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
    // grows), so deriving the box from the threaded endpoints is what makes the fill cover the
    // dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
    let box;
    if (sourceBox && sourceBox.r0 != null && endCell) {
      let r0 = sourceBox.r0;
      let r1 = sourceBox.r1;
      let c0 = sourceBox.c0;
      let c1 = sourceBox.c1;
      if (endCell.r < r0) r0 = endCell.r;
      if (endCell.r > r1) r1 = endCell.r;
      if (endCell.c < c0) c0 = endCell.c;
      if (endCell.c > c1) c1 = endCell.c;
      box = {
        r0,
        r1,
        c0,
        c1
      };
    } else {
      box = normalizedRange();
    }
    if (!box) return;
    const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
      r0: box.r0,
      r1: box.r0,
      c0: box.c0,
      c1: box.c0
    };
    const grid = [];
    for (let r = box.r0; r <= box.r1; r++) {
      const cols = [];
      for (let c = box.c0; c <= box.c1; c++) {
        const sr = tileIndex(r, src.r0, src.r1);
        const sc = tileIndex(c, src.c0, src.c1);
        const v = cellValueAt(sr, sc);
        cols.push(v == null ? '' : String(v));
      }
      grid.push(cols);
    }
    applyGridToRange(grid, box.r0, box.c0);
  }
  const teardownFillDrag = useCallback(() => {
    if (typeof document !== 'undefined') {
      if (fillDragMove.current) document.removeEventListener('pointermove', fillDragMove.current);
      if (fillDragUp.current) document.removeEventListener('pointerup', fillDragUp.current);
    }
    fillDragMove.current = null;
    fillDragUp.current = null;
    fillDragging.current = false;
  }, []);
  function cellIndexFromPoint(clientX: any, clientY: any) {
    if (typeof document === 'undefined' || !document.elementFromPoint) return null;
    let el = document.elementFromPoint(clientX, clientY);
    // Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
    // a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
    // and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
    // deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
    while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
      const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
      if (!inner || inner === el) break;
      el = inner;
    }
    if (!el || !el.closest) return null;
    const cellEl = el.closest('[data-grid-cell]');
    if (!cellEl) return null;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
    const r = parseInt(rowAttr, 10);
    const c = parseInt(colAttr, 10);
    if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
    return {
      r,
      c
    };
  }
  const onFillHandlePointerDown = useCallback((e: any) => {
    if (!e) return;
    if (e.preventDefault) e.preventDefault();
    if (e.stopPropagation) e.stopPropagation();
    // #leak: tear down any orphaned PRIOR gesture BEFORE reassigning the module-let handlers. If a
    // pointerup was missed (pointer released off-window, context menu, alt-tab), the prior fillDrag's
    // document pointermove/pointerup stay attached; overwriting fillDragMove/fillDragUp below would
    // strand them (removeEventListener could never reach the old refs) → a permanent global
    // pointermove leak. teardownFillDrag is idempotent (no-op when nothing is attached).
    teardownFillDrag();
    fillDragging.current = true;
    // B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
    // range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
    // up/left drag copies from the real origin (not the post-drag corner that would flip to a
    // target cell). Captured per-gesture in the closure (no module-let needed).
    const sourceBox = normalizedRange();
    // B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
    // the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
    let lastCell = sourceBox ? {
      r: sourceBox.r1,
      c: sourceBox.c1
    } : null;
    const move = (ev: any) => {
      if (!fillDragging.current) return;
      const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
      // B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
      // pointermove (the pointer fires many per cell) spams the event with identical payloads.
      // Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
      // seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
      // or re-enters the same cell is suppressed (the range is unchanged).
      if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
        lastCell = cell;
        setRangeFocus$local(cell.r, cell.c);
      }
    };
    const up = () => {
      // teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
      teardownFillDrag();
      // A plain click on the fill handle (pointerdown+up with NO intervening drag) leaves lastCell
      // at the source box's own origin corner (r1,c1), so fillRange(sourceBox, corner) would
      // recommit the source range onto ITSELF — a no-op write that pollutes undo history and fires
      // spurious per-cell cell-edit-commit events (oldValue === newValue). Only fill when the drag
      // actually reached a cell past the source origin.
      if (lastCell && sourceBox && (lastCell.r !== sourceBox.r1 || lastCell.c !== sourceBox.c1)) {
        fillRange(sourceBox, lastCell);
      }
    };
    // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
    fillDragMove.current = move;
    fillDragUp.current = up;
    if (typeof document !== 'undefined') {
      document.addEventListener('pointermove', move);
      document.addEventListener('pointerup', up);
    }
  }, [cellIndexFromPoint, fillRange, normalizedRange, setRangeFocus$local, teardownFillDrag]);
  const teardownRangeDrag = useCallback(() => {
    if (typeof document !== 'undefined') {
      if (rangeDragMove.current) document.removeEventListener('pointermove', rangeDragMove.current);
      if (rangeDragUp.current) document.removeEventListener('pointerup', rangeDragUp.current);
    }
    rangeDragMove.current = null;
    rangeDragUp.current = null;
    rangeDragging.current = false;
  }, []);
  function beginRangeDrag(anchorR: any, anchorC: any) {
    // #leak: tear down any orphaned PRIOR range gesture BEFORE reassigning the module-let handlers.
    // A missed pointerup (off-window release, context menu, alt-tab) leaves the prior drag's document
    // pointermove/pointerup attached; overwriting rangeDragMove/rangeDragUp below would strand them
    // (removeEventListener could never reach the old refs) → a permanent global pointermove leak.
    // teardownRangeDrag is idempotent (no-op when nothing is attached) and does NOT touch
    // rangeDragMoved, which is reset per-gesture immediately below.
    teardownRangeDrag();
    rangeDragging.current = true;
    rangeDragMoved.current = false;
    let lastCell = {
      r: anchorR,
      c: anchorC
    };
    const move = (ev: any) => {
      if (!rangeDragging.current) return;
      const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
      if (cell && (cell.r !== lastCell.r || cell.c !== lastCell.c)) {
        lastCell = cell;
        rangeDragMoved.current = true;
        setRangeFocus$local(cell.r, cell.c);
      }
    };
    const up = () => {
      // teardownRangeDrag clears rangeDragging + removes both listeners (the fill-drag CR-04 path).
      teardownRangeDrag();
    };
    // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
    rangeDragMove.current = move;
    rangeDragUp.current = up;
    if (typeof document !== 'undefined') {
      document.addEventListener('pointermove', move);
      document.addEventListener('pointerup', up);
    }
  }
  function activeCellColumnId() {
    if (activeIsHeader) return null;
    const rowList = rows || [];
    const row = rowList[activeRow];
    if (!row) return null;
    const cells = visibleCellsFor(row);
    const cell = cells[activeColIndex];
    return cell && cell.column ? cell.column.id : null;
  }
  function isActiveCellEditable() {
    const colId = activeCellColumnId();
    return colId != null && columnEditable(colId);
  }
  function isEditing(rowIndex: any, colIndex: any) {
    if (editVer < 0) return false;
    if (editingRowIndex != null && editingRowIndex === rowIndex) {
      const colId = columnIdAt(rowIndex, colIndex);
      return colId != null && columnEditable(colId);
    }
    return editingRow === rowIndex && editingCol === colIndex;
  }
  function cellAriaInvalid(rowIndex: any, colIndex: any): 'true' | null {
    return isEditing(rowIndex, colIndex) && !!invalidMsg ? 'true' : null;
  }
  function runValidator(colId: any, value: any, row: any) {
    const m = editMetaOf(colId);
    const v = m ? m.validate : null;
    if (typeof v !== 'function') return true;
    let r: any = null;
    try {
      r = v(value, row);
    } catch (err: any) {
      return 'Invalid value';
    }
    if (r === true) return true;
    if (typeof r === 'string') return r;
    return 'Invalid value';
  }
  function setInvalid(msg: any) {
    setInvalidMsg(msg != null ? msg : '');
  }
  function replaceRowValue(rows: any, rowIndex: any, field: any, value: any) {
    const src = rows || [];
    const out = [];
    for (let i = 0; i < src.length; i++) {
      if (i === rowIndex) {
        // WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
        // and would copy inherited enumerable props of typed/class-instance row objects.
        out.push({
          ...(src[i] || {}),
          [field]: value
        });
      } else {
        out.push(src[i]);
      }
    }
    return out;
  }
  function sourceIndexOfRow(visibleRowIndex: any) {
    const rowList = rows || [];
    const row = rowList[visibleRowIndex];
    if (!row) return visibleRowIndex;
    const orig = row.original;
    const data = currentData() || [];
    const idx = data.indexOf(orig);
    return idx >= 0 ? idx : visibleRowIndex;
  }
  function editingColumnId() {
    const rowList = rows || [];
    const row = rowList[editingRow];
    if (!row) return null;
    const cells = visibleCellsFor(row);
    const cell = cells[editingCol];
    return cell && cell.column ? cell.column.id : null;
  }
  function editingColumnField() {
    const colId = editingColumnId();
    if (colId == null) return null;
    const d = defFor(colId);
    return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
  }
  function editingCellValue() {
    const rowList = rows || [];
    const row = rowList[editingRow];
    if (!row) return null;
    const cells = visibleCellsFor(row);
    const cell = cells[editingCol];
    return cell ? cell.getValue() : null;
  }
  function editingRowOriginal() {
    const rowList = rows || [];
    const row = rowList[editingRow];
    return row ? row.original : null;
  }
  function editingRowId() {
    const rowList = rows || [];
    const row = rowList[editingRow];
    return row ? row.id : null;
  }
  function focusEditorWhenReady(selectAll = true) {
    if (!gridRoot.current) return;
    // Editor-owns-focus contract: when the CURRENT focus target is a #editor drop-in, the host
    // does NOT reach into its DOM — the drop-in self-focuses via its own autofocus prop.
    if (editFocusColId != null && hasEditorSlot(editFocusColId)) return;
    let attempts = 0;
    const tryFocus = () => {
      const el = gridRoot.current ? gridRoot.current.querySelector('[data-editing-cell]') : null;
      // Do NOT stomp focus a later interaction already placed in a DIFFERENT column's editor of
      // this row: focusEditorWhenReady only needs to get focus INTO the (first) freshly-mounted
      // editor; if focus already sits in another editable cell, a late rAF re-focus would steal it
      // back to the first editor and break row-mode Tab containment (the non-deterministic B21
      // focus-theft). Compare the OWNING cell's data-col-index (NOT node identity) so a stale
      // SAME-column editor node on Solid's node-replacing re-render still resolves as the target —
      // a genuinely dropped focus is still recovered.
      const ae = gridRoot.current && gridRoot.current.getRootNode ? gridRoot.current.getRootNode().activeElement : null;
      if (ae && el && ae !== el && ae.closest && gridRoot.current.contains(ae) && ae.hasAttribute && ae.hasAttribute('data-editing-cell')) {
        const aeCell = ae.closest('[data-grid-cell]');
        const elCell = el.closest ? el.closest('[data-grid-cell]') : null;
        const aeCol = aeCell ? aeCell.getAttribute('data-col-index') : null;
        const elCol = elCell ? elCell.getAttribute('data-col-index') : null;
        if (aeCol != null && aeCol !== elCol) return;
      }
      if (el) {
        el.focus();
        if (selectAll && el.select) {
          try {
            el.select();
          } catch (e: any) {}
        }
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  }
  function columnIdAt(rowIndex: any, colIndex: any) {
    const rowList = rows || [];
    const row = rowList[rowIndex];
    if (!row) return null;
    const cells = visibleCellsFor(row);
    const cell = cells[colIndex];
    return cell && cell.column ? cell.column.id : null;
  }
  function cellValueAt(rowIndex: any, colIndex: any) {
    const rowList = rows || [];
    const row = rowList[rowIndex];
    if (!row) return null;
    const cells = visibleCellsFor(row);
    const cell = cells[colIndex];
    return cell ? cell.getValue() : null;
  }
  function beginEdit(rowIndex: any, colIndex: any, seed: any) {
    const colId = columnIdAt(rowIndex, colIndex);
    if (colId == null || !columnEditable(colId)) return;
    // A new edit session starts — reset the sync idempotency latch so THIS session's eventual
    // commit is not silently no-op'd by a PRIOR session's already-set latch.
    committedThisSession.current = false;
    setInvalid('');
    // Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
    // editor clears any row-edit state so isEditing never resolves both modes for one cell.
    setEditingRowIndex(null);
    setRowDraft({});
    setEditingRow(rowIndex);
    setEditingCol(colIndex);
    setDraftValue(seed != null ? seed : cellValueAt(rowIndex, colIndex));
    setActiveInControl(true);
    setEditVer(prev => prev + 1);
    // Editor-owns-focus contract (quick 260711-i5m): THIS cell's column is the current
    // focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope prop
    // from it. Cleared on endEdit.
    setEditFocusColId(colId);
    // B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
    // seeded char so subsequent typing appends instead of replacing it.
    focusEditorWhenReady(seed == null);
  }
  const focusCellWhenReady = useCallback((row: any, col: any) => {
    if (!gridRoot.current) return;
    let attempts = 0;
    const tryFocus = () => {
      const el = resolveCellEl(String(row), col);
      if (el) {
        el.focus();
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  }, [resolveCellEl]);
  const indexOfRowIn = useCallback((rows: any, rowOriginal: any, rowId: any) => {
    const list = rows || [];
    for (let i = 0; i < list.length; i++) {
      const r = list[i];
      if (!r) continue;
      if (rowId != null && r.id === rowId) return i;
      if (rowOriginal != null && r.original === rowOriginal) return i;
    }
    return -1;
  }, [rows]);
  function endEdit() {
    setEditingRow(-1);
    setEditingCol(-1);
    setDraftValue(null);
    setInvalidMsg('');
    setActiveInControl(false);
    setEditVer(prev => prev + 1);
    setEditFocusColId(null);
  }
  function endRowEdit() {
    setEditingRowIndex(null);
    setRowDraft({});
    setInvalidMsg('');
    setActiveInControl(false);
    setEditVer(prev => prev + 1);
    setEditFocusColId(null);
  }
  function editorAutofocusFor(colId: any, rowIndex: any) {
    if (editVer < 0) return false;
    if (editingRowIndex != null) {
      if (editingRowIndex !== rowIndex) return false;
    } else {
      if (editingRow !== rowIndex) return false;
    }
    return editFocusColId != null && editFocusColId === colId;
  }
  function coerceCellValue(colId: any, raw: any) {
    if (editorTypeOf(colId) !== 'number') return raw;
    if (raw == null) return null;
    if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
    const s = String(raw).trim();
    if (s === '') return null;
    const n = Number(s);
    return Number.isNaN(n) ? null : n;
  }
  function commitEdit(overrideValue = undefined, skipFocusReturn = false) {
    if (editingRow < 0) return false;
    // Sync idempotency latch (drop-in double cell-edit-commit fix): a second commitEdit call
    // within the SAME edit session — the deferred drop-in's unmount-blur re-entry, which on
    // React fires while $data.editingRow is still async-stale ≥ 0 — no-ops here instead of
    // re-validating/re-writing/re-emitting. Reset by beginEdit/beginRowEdit/editCell.
    if (committedThisSession.current) return false;
    const colId = editingColumnId();
    if (colId == null) {
      endEdit();
      return false;
    }
    const field = editingColumnField();
    const oldValue = editingCellValue();
    const rowOriginal = editingRowOriginal();
    const rowId = editingRowId();
    // B3: coerce by the column's editor type BEFORE validation + write so the validator
    // and the model both see the typed value (number/null), not the raw draft string.
    const rawValue = overrideValue !== undefined ? overrideValue : draftValue;
    const newValue = coerceCellValue(colId, rawValue);
    const err = runValidator(colId, newValue, rowOriginal);
    if (err !== true) {
      // D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
      setInvalid(err);
      focusEditorWhenReady();
      return false;
    }
    setInvalid('');
    // #5: a no-op commit (the coerced value is UNCHANGED — a bare Enter/Tab/blur that edited
    // nothing) must do NO model write, NO history record, and NO commit event: writeData →
    // recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row identity, so an
    // unconditional write on a no-op would destroy redo + spuriously re-render + emit a no-op
    // cell-edit-commit. Compute `changed` and gate the write/emit on it; ALWAYS close the editor.
    const changed = !Object.is(newValue, oldValue);
    // Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
    const focusRow = editingRow;
    const focusCol = editingCol;
    // Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
    // must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
    editTransition.current = true;
    // Sync idempotency latch: flip BEFORE writeData/endEdit so the async unmount-blur re-entry
    // (which fires AFTER this call returns, once editTransition is already back to false) finds
    // it set at the top-of-function guard above and no-ops. Set on BOTH paths so a no-op commit
    // is just as re-entry-safe as a real one.
    committedThisSession.current = true;
    if (changed) {
      const srcIndex = sourceIndexOfRow(editingRow);
      const next = replaceRowValue(currentData(), srcIndex, field, newValue);
      writeData(next);
      // Exactly one emit per commit, from this single call site (writeData does NOT emit).
      props.onCellEditCommit && props.onCellEditCommit({
        rowId,
        columnId: colId,
        oldValue,
        newValue
      });
    }
    endEdit();
    editTransition.current = false;
    if (changed) {
      // Defer the focus return so the display↔editor re-render commits first (async on
      // React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
      // editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
      // Tab-advance (the caller immediately opens the next editor and focuses THAT).
      // B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
      // RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
      // index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
      // consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
      // fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
      // active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
      // index → byte-behaviorally identical to before.
      if (skipFocusReturn !== true) pendingEditFollow.current = {
        rowOriginal,
        rowId,
        col: focusCol
      };
    } else if (skipFocusReturn !== true) {
      // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume
      // a pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
      // relocate (no write), so the B23 relocation hazard that forces the pendingEditFollow path on
      // a real commit does not apply here: the fixed (focusRow, focusCol) is correct and safe.
      focusCellWhenReady(focusRow, focusCol);
    }
    return true;
  }
  function toggleActiveBooleanCell() {
    const colId = columnIdAt(activeRow, activeColIndex);
    if (colId == null || !columnEditable(colId)) return;
    const rowList = rows || [];
    const row = rowList[activeRow];
    if (!row) return;
    const rowOriginal = row.original;
    const rowId = row.id;
    const oldValue = cellValueAt(activeRow, activeColIndex);
    const newValue = !oldValue;
    // D-01: same discipline as commitEdit — a rejecting validator blocks the toggle. There is
    // no editor to keep open here, so the toggle simply does not apply (no model write).
    const err = runValidator(colId, newValue, rowOriginal);
    if (err !== true) {
      setInvalid(err);
      return;
    }
    setInvalid('');
    const def = defFor(colId);
    const field = def && def.accessorKey != null ? def.accessorKey : colId;
    const srcIndex = sourceIndexOfRow(activeRow);
    // Sync idempotency latch: this toggle is a commit-equivalent (mirrors commitEdit's D-07
    // single-emit discipline) — flip it too so a stray re-entry after this toggle no-ops.
    committedThisSession.current = true;
    writeData(replaceRowValue(currentData(), srcIndex, field, newValue));
    // Exactly one emit per toggle, from this single call site (writeData does NOT emit) —
    // mirrors commitEdit's D-07 single-emit discipline.
    props.onCellEditCommit && props.onCellEditCommit({
      rowId,
      columnId: colId,
      oldValue,
      newValue
    });
    // Follow the toggled row's focus through a boolean sort/filter relocation AND a
    // fine-grained keyed-row replace (Solid) — the SAME recovery commitEdit relies on; even
    // with no editor to unmount, writeData's re-render can still drop focus.
    pendingEditFollow.current = {
      rowOriginal,
      rowId,
      col: activeColIndex
    };
  }
  function cancelEdit() {
    if (editingRow < 0) return;
    // CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
    // Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
    // an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
    // instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
    const focusRow = editingRow;
    const focusCol = editingCol;
    editTransition.current = true;
    endEdit();
    editTransition.current = false;
    focusCellWhenReady(focusRow, focusCol);
  }
  function editableColumnsForRow(rowIndex: any) {
    const rowList = rows || [];
    const row = rowList[rowIndex];
    if (!row) return [];
    const cells = visibleCellsFor(row);
    const out = [];
    for (let c = 0; c < cells.length; c++) {
      const cell = cells[c];
      const colId = cell && cell.column ? cell.column.id : null;
      if (colId == null || !columnEditable(colId)) continue;
      const d = defFor(colId);
      const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
      // colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
      // Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
      // can address a SPECIFIC editor by column, not just the first [data-editing-cell].
      out.push({
        colId,
        field,
        colIndex: c
      });
    }
    return out;
  }
  function focusRowEditorAt(rowIndex: any, colIndex: any) {
    if (!gridRoot.current) return;
    const colId = columnIdAt(rowIndex, colIndex);
    if (colId != null && hasEditorSlot(colId)) return;
    let attempts = 0;
    const tryFocus = () => {
      const cellEl = resolveCellEl(String(rowIndex), colIndex);
      const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
      if (ed) {
        ed.focus();
        if (ed.select) {
          try {
            ed.select();
          } catch (e: any) {}
        }
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  }
  function beginRowEdit(row: any) {
    const rowIndex = rowIndexOf(row);
    if (rowIndex < 0) return;
    const editable = editableColumnsForRow(rowIndex);
    if (editable.length === 0) return;
    // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
    committedThisSession.current = false;
    // Clear any single-cell editor first (mutual exclusivity).
    setEditingRow(-1);
    setEditingCol(-1);
    setDraftValue(null);
    setInvalid('');
    // Seed each editable cell's draft from its current value.
    const draft = {};
    const rowList = rows || [];
    const r = rowList[rowIndex];
    const orig = r ? r.original : null;
    for (let i = 0; i < editable.length; i++) {
      const ec = editable[i];
      draft[ec.colId] = orig ? orig[ec.field] : null;
    }
    setRowDraft(draft);
    setEditingRowIndex(rowIndex);
    setActiveInControl(true);
    setEditVer(prev => prev + 1);
    // Editor-owns-focus contract (quick 260711-i5m): the row's FIRST editable column is the
    // initial focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope
    // prop from it (a built-in column is also host-focused below via focusEditorWhenReady; a
    // drop-in column self-focuses via its own $onMount, gated off the host reach-in in Task 3).
    setEditFocusColId(editable[0].colId);
    focusEditorWhenReady();
  }
  function commitRow() {
    if (editingRowIndex == null) return false;
    const rowIndex = editingRowIndex;
    const editable = editableColumnsForRow(rowIndex);
    if (editable.length === 0) {
      endRowEdit();
      return false;
    }
    const rowList = rows || [];
    const r = rowList[rowIndex];
    const rowOriginal = r ? r.original : null;
    const rowId = r ? r.id : null;
    const draft = rowDraft || {};
    // Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
    // B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
    // 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
    // commitEdit already coerces via coerceCellValue; the row path silently committed strings →
    // a number column ended up holding '99'). Coerce once here so the validator and the model both
    // see the typed value, identical to the single-cell funnel.
    for (let i = 0; i < editable.length; i++) {
      const ec = editable[i];
      const err = runValidator(ec.colId, coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
      if (err !== true) {
        setInvalid(err);
        // Editor-owns-focus contract (quick 260711-i5m): the OFFENDING column becomes the new
        // reactive focus target BEFORE the host-focus call below — a #editor drop-in already
        // mounted (full-row edit opens every editable cell at once) picks this up via its own
        // lazy $watch on the `autofocus` scope prop flipping false→true. Bump editVer so the
        // coarse-render targets (React/Vue/Angular/Svelte) re-derive the slot binding (Solid's
        // fine-grained accessor re-runs without the bump, but the bump keeps all 6 in lockstep).
        setEditFocusColId(ec.colId);
        setEditVer(prev => prev + 1);
        // B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
        // unconditionally the first editor (focusEditorWhenReady resolves the first
        // [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
        // Gated (Task 3) so a #editor drop-in self-focuses instead of a host DOM reach-in.
        focusRowEditorAt(rowIndex, ec.colIndex);
        return false;
      }
    }
    setInvalid('');
    // Build the changes payload (only the columns whose value actually changed) + the field→
    // value map for the single row-object replace.
    const changes = [];
    const fieldValues = {};
    for (let i = 0; i < editable.length; i++) {
      const ec = editable[i];
      // B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
      // string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
      const newValue = coerceCellValue(ec.colId, draft[ec.colId]);
      const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
      fieldValues[ec.field] = newValue;
      if (oldValue !== newValue) changes.push({
        columnId: ec.colId,
        oldValue,
        newValue
      });
    }
    // Snapshot the active cell to return focus to (the whole row is in edit, so the active-cell
    // row/column is the roving focus target), BEFORE endRowEdit clears editing state.
    const focusRow = activeRow;
    const focusCol = activeColIndex;
    // #5: a no-op row commit (NO column's value actually changed — a bare Enter/save/outside-click
    // that edited nothing) must do NO model write, NO history record, NO row-edit-commit event:
    // writeData → recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row
    // identity, so an unconditional write on a no-op destroys redo + spuriously re-renders + emits
    // a no-op row-edit-commit. Gate the write/emit on `changes.length`; ALWAYS close the editor.
    const changed = changes.length > 0;
    editTransition.current = true;
    if (changed) {
      // ONE fresh-array replace of the SINGLE row object with all field values applied at once.
      const srcIndex = sourceIndexOfRow(rowIndex);
      const next = replaceRowValues(currentData(), srcIndex, fieldValues);
      writeData(next);
      // EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
      props.onRowEditCommit && props.onRowEditCommit({
        rowId,
        changes
      });
    }
    endRowEdit();
    editTransition.current = false;
    if (changed) {
      // WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
      // like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
      // col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
      // $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
      // DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
      // refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
      // display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
      // the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
      // poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
      pendingEditFollow.current = {
        rowOriginal,
        rowId,
        col: focusCol
      };
    } else {
      // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume a
      // pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
      // relocate (no write), so the B23 relocation hazard does not apply: (focusRow, focusCol) is safe.
      focusCellWhenReady(focusRow, focusCol);
    }
    return true;
  }
  function cancelRow() {
    if (editingRowIndex == null) return;
    const focusRow = activeRow;
    const focusCol = activeColIndex;
    editTransition.current = true;
    endRowEdit();
    editTransition.current = false;
    focusCellWhenReady(focusRow, focusCol);
  }
  function replaceRowValues(rows: any, rowIndex: any, fieldValues: any) {
    const src = rows || [];
    const fv = fieldValues || {};
    const out = [];
    for (let i = 0; i < src.length; i++) {
      if (i === rowIndex) {
        // WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
        // prototype-walking copy. Spread copies own enumerable props only.
        out.push({
          ...(src[i] || {}),
          ...fv
        });
      } else {
        out.push(src[i]);
      }
    }
    return out;
  }
  function nextEditableCell(fromRow: any, fromCol: any) {
    const rowList = rows || [];
    const rowCount = rowList.length;
    if (rowCount === 0) return null;
    let r = fromRow;
    let c = fromCol + 1;
    while (r < rowCount) {
      const row = rowList[r];
      const cells = row ? visibleCellsFor(row) : [];
      while (c < cells.length) {
        const cell = cells[c];
        const cid = cell && cell.column ? cell.column.id : null;
        if (cid != null && columnEditable(cid)) return {
          row: r,
          col: c
        };
        c = c + 1;
      }
      r = r + 1;
      c = 0;
    }
    return null;
  }
  function prevEditableCell(fromRow: any, fromCol: any) {
    const rowList = rows || [];
    const rowCount = rowList.length;
    if (rowCount === 0) return null;
    let r = fromRow;
    let c = fromCol - 1;
    while (r >= 0) {
      const row = rowList[r];
      const cells = row ? visibleCellsFor(row) : [];
      while (c >= 0) {
        const cell = cells[c];
        const cid = cell && cell.column ? cell.column.id : null;
        if (cid != null && columnEditable(cid)) return {
          row: r,
          col: c
        };
        c = c - 1;
      }
      r = r - 1;
      if (r >= 0) {
        const prow = rowList[r];
        const pcells = prow ? visibleCellsFor(prow) : [];
        c = pcells.length - 1;
      }
    }
    return null;
  }
  function inRowEdit() {
    return editingRowIndex != null;
  }
  function editorValueFor(colId: any) {
    return inRowEdit() ? rowDraft ? rowDraft[colId] : null : draftValue;
  }
  function editorCheckedFor(colId: any) {
    return !!(inRowEdit() ? rowDraft ? rowDraft[colId] : null : draftValue);
  }
  function editorCommitFor(colId: any) {
    return (value: any) => {
      if (inRowEdit()) {
        setRowDraft$local(colId, value);
        return;
      }
      commitEdit(value);
    };
  }
  function editorCancelFor() {
    return () => {
      if (inRowEdit()) {
        cancelRow();
        return;
      }
      cancelEdit();
    };
  }
  const onCellEditorInput = useCallback((colId: any, evt: any) => {
    const v = evt && evt.target ? evt.target.value : '';
    if (inRowEdit()) {
      setRowDraft$local(colId, v);
      return;
    }
    setDraftValue(v);
  }, [inRowEdit, setRowDraft$local]);
  const onCellEditorCheckbox = useCallback((colId: any, evt: any) => {
    const v = !!(evt && evt.target && evt.target.checked);
    if (inRowEdit()) {
      setRowDraft$local(colId, v);
      return;
    }
    setDraftValue(v);
  }, [inRowEdit, setRowDraft$local]);
  function setRowDraft$local(colId: any, value: any) {
    const src = rowDraft || {};
    const next = {};
    for (const k in src) next[k] = src[k];
    next[colId] = value;
    setRowDraft(next);
  }
  function rowEditTab(target: any, backward: any) {
    const rowIndex = editingRowIndex;
    if (rowIndex == null) return;
    const editable = editableColumnsForRow(rowIndex);
    if (editable.length === 0) return;
    const cols = editable.map((ec: any) => ec.colIndex);
    const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
    const curAttr = cell ? cell.getAttribute('data-col-index') : null;
    const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
    let pos = cols.indexOf(cur);
    if (pos < 0) pos = 0;
    const len = cols.length;
    const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
    // Editor-owns-focus contract (quick 260711-i5m): the Tab target becomes the new reactive
    // focus target BEFORE the host-focus call below, so Tab onto an already-mounted #editor
    // drop-in (row mode) also refocuses it via its own lazy $watch.
    setEditFocusColId(editable[nextPos].colId);
    setEditVer(prev => prev + 1);
    focusRowEditorAt(rowIndex, cols[nextPos]);
  }
  const onEditorKeyDown = useCallback((e: any) => {
    if (!e) return;
    const key = e.key;
    // Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
    // model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
    // row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
    // preventDefault it here.
    if (inRowEdit()) {
      if (key === 'Enter') {
        e.preventDefault();
        commitRow();
      } else if (key === 'Escape') {
        e.preventDefault();
        cancelRow();
      }
      // B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
      // editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
      // Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
      // first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
      // tab order across a Lit shadow boundary).
      else if (key === 'Tab') {
        e.preventDefault();
        rowEditTab(e.target, e.shiftKey);
      }
      return;
    }
    if (key === 'Enter') {
      e.preventDefault();
      commitEdit(undefined);
    } else if (key === 'Tab') {
      e.preventDefault();
      // Resolve the advance target from the EDITING pair (the cell that is open), not the
      // active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
      // moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
      // the editing pair BEFORE commit (commitEdit resets it to -1).
      const fromRow = editingRow;
      const fromCol = editingCol;
      const target = e.shiftKey ? prevEditableCell(fromRow, fromCol) : nextEditableCell(fromRow, fromCol);
      // skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
      // straight into the next editable cell's editor below. Use the RETURN value (not a
      // re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
      // failure returns false and keeps the editor open (the user must fix the value first).
      const committed = commitEdit(undefined, true);
      if (committed && target) {
        setActiveRow(target.row);
        setActiveColIndex(target.col);
        beginEdit(target.row, target.col, null);
      } else if (committed) {
        // B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
        // grid by returning it to the just-committed cell instead of letting it drop to <body>.
        focusCellWhenReady(fromRow, fromCol);
      }
    } else if (key === 'Escape') {
      e.preventDefault();
      cancelEdit();
    }
  }, [beginEdit, cancelEdit, cancelRow, commitEdit, commitRow, editingCol, editingRow, focusCellWhenReady, inRowEdit, nextEditableCell, prevEditableCell, rowEditTab]);
  const onEditorBlur = useCallback((e: any) => {
    // Full-row mode (req-6): a blur that stays WITHIN the row editor — Tab/click between the
    // row's OWN fields — is a normal focus move and must NOT commit (a per-cell blur-commit
    // would split the row into N writes + N events, violating the one-write/one-event contract).
    // But an OUTSIDE-click blur (#7) MUST commit the row: otherwise the model is never written
    // AND editingRowIndex stays set, freezing onGridKeyDown's editingRowIndex early-return so
    // arrow-nav is dead the moment the user clicks back into the grid. Mirror the single-cell
    // branch's relatedTarget shape to tell an in-row focus move from a genuine click-away.
    if (inRowEdit()) {
      // Guard the teardown blur: commitRow's writeData/endRowEdit re-render unmounts the row's
      // editors → a same-tick re-render blur must NOT re-enter commitRow (double row-edit-commit).
      // commitRow sets editTransition synchronously BEFORE writeData, so it is set here during the
      // teardown window (the async unmount-blur that fires after endRowEdit finds editingRowIndex
      // already null → inRowEdit() false → the single-cell tail's editingRow<0 guard returns).
      if (editTransition.current) return;
      const rowNext = e ? e.relatedTarget : null;
      const rowNextCell = rowNext && rowNext.closest ? rowNext.closest('[data-grid-cell]') : null;
      const rowNextRow = rowNextCell ? rowNextCell.getAttribute('data-row') : null;
      // Focus landing on a cell of the SAME editing row (Tab/click between the row's own fields) →
      // controlled in-row move, do NOT commit. Anything else — a null relatedTarget, another row,
      // a toolbar/widget, or outside the grid entirely — is an outside-click → commit the row as a
      // unit. commitRow clears editingRowIndex, releasing onGridKeyDown's early-return so nav
      // resumes; a no-op row (nothing changed) takes commitRow's clean #5 no-write/no-emit path.
      if (rowNextRow != null && rowNextRow === String(editingRowIndex)) return;
      commitRow();
      return;
    }
    if (editingRow < 0 || editTransition.current) return;
    const next = e ? e.relatedTarget : null;
    // A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
    // keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
    // a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
    // conservative null=skip behavior.
    if (next == null) return;
    // Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
    // the editor open on an invalid value).
    if (!(gridRoot.current && gridRoot.current.contains && gridRoot.current.contains(next))) {
      commitEdit(undefined);
      return;
    }
    // Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
    // keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
    // (which must commit + close so the grid is not wedged with an open editor).
    const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
    const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
    // Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
    // controlled move; skip. Also skip when either cell can't be resolved (an unmounting
    // editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
    if (!nextCell || !fromCell || nextCell === fromCell) return;
    // A Tab-advance already committed the old editor and opened the next one, so the live
    // editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
    // editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
    // double commit, WR-04).
    const fromRow = fromCell.getAttribute('data-row');
    const fromCol = fromCell.getAttribute('data-col-index');
    if (fromRow !== String(editingRow) || fromCol !== String(editingCol)) return;
    // Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
    // commit does NOT bounce focus back to the just-committed editing cell (which would fight
    // the click destination). The commit's writeData re-renders the table and can DROP DOM
    // focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
    // DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
    // (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
    // late, which would steal focus back from a subsequent navigation.
    const destRow = nextCell.getAttribute('data-row');
    const destCol = nextCell.getAttribute('data-col-index');
    commitEdit(undefined, true);
    const reseatDestFocus = () => {
      if (!gridRoot.current || destRow == null || destCol == null || destRow === '__header') return;
      const root = gridRoot.current.getRootNode ? gridRoot.current.getRootNode() : null;
      const act = root && root.activeElement ? root.activeElement : null;
      // Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
      if (act && gridRoot.current.contains && gridRoot.current.contains(act)) return;
      const el = resolveCellEl(destRow, parseInt(destCol, 10));
      if (el) el.focus();
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
  }, [commitEdit, commitRow, editingCol, editingRow, editingRowIndex, inRowEdit, resolveCellEl]);
  function editCell(rowIndex: any, colIndex: any) {
    const lastRow = bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const maxCol = visibleColCount() - 1;
    const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
    const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
    // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
    committedThisSession.current = false;
    setActiveIsHeader(false);
    setActiveRow(r);
    setActiveColIndex(c);
    beginEdit(r, c, null);
  }
  function commitEditing() {
    if (inRowEdit()) {
      commitRow();
      return;
    }
    if (editingRow >= 0) commitEdit(undefined);
  }
  function editRow(rowIndex: any) {
    const lastRow = bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
    const rowList = rows || [];
    const row = rowList[r];
    if (!row) return;
    setActiveIsHeader(false);
    setActiveRow(r);
    beginRowEdit(row);
  }
  function focusAbsCellWhenReady(absRow: any, localRow: any, col: any) {
    if (!gridRoot.current) return;
    let attempts = 0;
    const want = String(headerRowCount() + absRow + 1);
    // #9: capture the focus-intent epoch at arm time (AFTER focusCell's own bump at its top, so
    // this poll never aborts itself). A LATER focus intent — a click landing on a new cell
    // (syncActiveFromEvent) or another focusCell / keyboard nav — bumps the epoch, so this
    // paginated page-switch poll aborts instead of grabbing focus frames after the user moved on.
    const myEpoch = focusIntentEpoch.current;
    const tryFocus = () => {
      if (focusIntentEpoch.current !== myEpoch) return;
      const el = resolveCellEl(String(localRow), col);
      if (el) {
        const rowEl = el.closest ? el.closest('[role="row"]') : null;
        const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
        if (ari === want) {
          el.focus();
          return;
        }
      }
      attempts = attempts + 1;
      if (attempts >= 60) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  }
  function focusCell(rowIndex: any, colIndex: any) {
    // B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
    // is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
    // isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
    // instance does not leak a spurious activecell-change.
    if (!isGrid()) return;
    // #9: focusCell is a focus-INTENT entry point — bump the epoch BEFORE arming any poll (the
    // switched-page focusAbsCellWhenReady captures the post-bump value; the same-page / virtual
    // branches route through focusActiveCell, which bumps again — harmless). A subsequent focusCell
    // or user nav bumps again → a pending focusAbsCellWhenReady from THIS call aborts.
    focusIntentEpoch.current = focusIntentEpoch.current + 1;
    const maxCol = visibleColCount() - 1;
    const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
    // C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
    const absLast = prePaginationRowCount() - 1;
    const absRow = clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
    // B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
    // real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
    // active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
    const prevAbs = toAbsRow(activeRow);
    const prevIsHeader = activeIsHeader;
    if (props.virtual) {
      // Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
      // the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
      // focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
      setActiveIsHeader(false);
      setActiveInControl(false);
      setActiveRow(absRow);
      setActiveColIndex(c);
      focusActiveCell(absRow, c, false);
    } else {
      // Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
      // in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
      // body's data-row markers (and the roving tabindex) address.
      const size = pageSize();
      const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
      const localRow = absRow - targetPage * size;
      const switched = targetPage !== pageIndex();
      if (switched) setPage(targetPage);
      setActiveIsHeader(false);
      setActiveInControl(false);
      setActiveRow(localRow);
      setActiveColIndex(c);
      if (switched) {
        // The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
        // TARGET page's body aria-rowindex (headerRowCount + absRow + 1, #13) before focusing, so
        // the OLD page's same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only.
        focusAbsCellWhenReady(absRow, localRow, c);
      } else {
        // Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
        // Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
        // async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
        focusActiveCell(localRow, c, false);
      }
    }
    if (absRow !== prevAbs || prevIsHeader) {
      props.onActivecellChange && props.onActivecellChange({
        rowIndex: absRow,
        colIndex: c
      });
    }
  }
  function getActiveCell() {
    return activeIsHeader ? {
      rowIndex: null,
      colIndex: activeColIndex,
      isHeader: true
    } : {
      rowIndex: toAbsRow(activeRow),
      colIndex: activeColIndex,
      isHeader: false
    };
  }
  function clearActiveCell() {
    if (!isGrid()) return;
    setActiveIsHeader(false);
    setActiveInControl(false);
    setActiveRow(0);
    setActiveColIndex(0);
  }
  function toggleRowExpanded(rowId: any) {
    if (!table.current) return;
    const target = String(rowId);
    const flat = table.current.getCoreRowModel().flatRows;
    for (const r of flat as any) {
      if (r.id === target || r.original && String(r.original.id) === target) {
        r.toggleExpanded();
        return;
      }
    }
  }
  function expandAll() {
    if (!table.current) return;
    table.current.toggleAllRowsExpanded(true);
  }
  function collapseAll() {
    if (!table.current) return;
    table.current.resetExpanded(true);
  }
  function getExpandedRows() {
    if (!table.current) return [];
    const out = [];
    const flat = table.current.getCoreRowModel().flatRows;
    for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
    return out;
  }
  function applyGrouping(cols: any) {
    if (table.current) table.current.setGrouping(cols);
  }
  function clearGrouping() {
    if (table.current) table.current.setGrouping([]);
  }
  function getFacetedUniqueValues(colId: any) {
    if (tick() < 0 || !table.current) return [];
    const col = table.current.getColumn(colId);
    if (!col || !col.getFacetedUniqueValues) return [];
    const map = col.getFacetedUniqueValues(); // Map<any, number>
    return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
  }
  function getFacetedMinMaxValues(colId: any) {
    if (tick() < 0 || !table.current) return null;
    const col = table.current.getColumn(colId);
    if (!col || !col.getFacetedMinMaxValues) return null;
    return col.getFacetedMinMaxValues() || null; // [number, number] | null
  }

  const _clampActiveCellRef = useRef(clampActiveCell);
  _clampActiveCellRef.current = clampActiveCell;
  const _currentDataRef = useRef(currentData);
  _currentDataRef.current = currentData;
  const _currentStateRef = useRef(currentState);
  _currentStateRef.current = currentState;
  const _effectiveColumnFiltersRef = useRef(effectiveColumnFilters);
  _effectiveColumnFiltersRef.current = effectiveColumnFilters;
  const _effectiveGlobalFilterRef = useRef(effectiveGlobalFilter);
  _effectiveGlobalFilterRef.current = effectiveGlobalFilter;
  const _effectiveSortingRef = useRef(effectiveSorting);
  _effectiveSortingRef.current = effectiveSorting;
  const _focusCellWhenReadyRef = useRef(focusCellWhenReady);
  _focusCellWhenReadyRef.current = focusCellWhenReady;
  const _indexOfRowInRef = useRef(indexOfRowIn);
  _indexOfRowInRef.current = indexOfRowIn;
  const _isGridRef = useRef(isGrid);
  _isGridRef.current = isGrid;
  const _onColumnFiltersChangeCbRef = useRef(onColumnFiltersChangeCb);
  _onColumnFiltersChangeCbRef.current = onColumnFiltersChangeCb;
  const _onColumnOrderChangeCbRef = useRef(onColumnOrderChangeCb);
  _onColumnOrderChangeCbRef.current = onColumnOrderChangeCb;
  const _onColumnPinningChangeCbRef = useRef(onColumnPinningChangeCb);
  _onColumnPinningChangeCbRef.current = onColumnPinningChangeCb;
  const _onColumnSizingChangeCbRef = useRef(onColumnSizingChangeCb);
  _onColumnSizingChangeCbRef.current = onColumnSizingChangeCb;
  const _onColumnSizingInfoChangeCbRef = useRef(onColumnSizingInfoChangeCb);
  _onColumnSizingInfoChangeCbRef.current = onColumnSizingInfoChangeCb;
  const _onColumnVisibilityChangeCbRef = useRef(onColumnVisibilityChangeCb);
  _onColumnVisibilityChangeCbRef.current = onColumnVisibilityChangeCb;
  const _onExpandedChangeCbRef = useRef(onExpandedChangeCb);
  _onExpandedChangeCbRef.current = onExpandedChangeCb;
  const _onGlobalFilterChangeCbRef = useRef(onGlobalFilterChangeCb);
  _onGlobalFilterChangeCbRef.current = onGlobalFilterChangeCb;
  const _onGroupingChangeCbRef = useRef(onGroupingChangeCb);
  _onGroupingChangeCbRef.current = onGroupingChangeCb;
  const _onPaginationChangeCbRef = useRef(onPaginationChangeCb);
  _onPaginationChangeCbRef.current = onPaginationChangeCb;
  const _onRowSelectionChangeCbRef = useRef(onRowSelectionChangeCb);
  _onRowSelectionChangeCbRef.current = onRowSelectionChangeCb;
  const _onSortingChangeCbRef = useRef(onSortingChangeCb);
  _onSortingChangeCbRef.current = onSortingChangeCb;
  const _syncIndeterminateRef = useRef(syncIndeterminate);
  _syncIndeterminateRef.current = syncIndeterminate;
  const _tableColumnsRef = useRef(tableColumns);
  _tableColumnsRef.current = tableColumns;
  const _virtualizerOptionsRef = useRef(virtualizerOptions);
  _virtualizerOptionsRef.current = virtualizerOptions;
  const _windowSourceRef = useRef(windowSource);
  _windowSourceRef.current = windowSource;
  const _writePaginationRef = useRef(writePagination);
  _writePaginationRef.current = writePagination;
  useEffect(() => {
    const _onColumnFiltersChangeCbStable: typeof _onColumnFiltersChangeCbRef.current = (...args) => _onColumnFiltersChangeCbRef.current(...args);
    const _onColumnOrderChangeCbStable: typeof _onColumnOrderChangeCbRef.current = (...args) => _onColumnOrderChangeCbRef.current(...args);
    const _onColumnPinningChangeCbStable: typeof _onColumnPinningChangeCbRef.current = (...args) => _onColumnPinningChangeCbRef.current(...args);
    const _onColumnSizingChangeCbStable: typeof _onColumnSizingChangeCbRef.current = (...args) => _onColumnSizingChangeCbRef.current(...args);
    const _onColumnSizingInfoChangeCbStable: typeof _onColumnSizingInfoChangeCbRef.current = (...args) => _onColumnSizingInfoChangeCbRef.current(...args);
    const _onColumnVisibilityChangeCbStable: typeof _onColumnVisibilityChangeCbRef.current = (...args) => _onColumnVisibilityChangeCbRef.current(...args);
    const _onExpandedChangeCbStable: typeof _onExpandedChangeCbRef.current = (...args) => _onExpandedChangeCbRef.current(...args);
    const _onGlobalFilterChangeCbStable: typeof _onGlobalFilterChangeCbRef.current = (...args) => _onGlobalFilterChangeCbRef.current(...args);
    const _onGroupingChangeCbStable: typeof _onGroupingChangeCbRef.current = (...args) => _onGroupingChangeCbRef.current(...args);
    const _onPaginationChangeCbStable: typeof _onPaginationChangeCbRef.current = (...args) => _onPaginationChangeCbRef.current(...args);
    const _onRowSelectionChangeCbStable: typeof _onRowSelectionChangeCbRef.current = (...args) => _onRowSelectionChangeCbRef.current(...args);
    const _onSortingChangeCbStable: typeof _onSortingChangeCbRef.current = (...args) => _onSortingChangeCbRef.current(...args);
    const _syncIndeterminateStable: typeof _syncIndeterminateRef.current = (...args) => _syncIndeterminateRef.current(...args);
    // Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
    // edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
    // a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
    // the bound prop when controlled, this fallback otherwise.
    setDataDefault(_dataRef.current || []);
    // Build the table instance HERE so the closures below capture the live `table`.
    table.current = createTable({
      // Plain value (NOT a `get data()` getter): an object-literal getter rebinds
      // `this` to the options object, and the Angular/Lit emitters resolve $props via
      // `this.data` — so `get data() { return $props.data }` lowers to `this.data`
      // re-entering the getter → infinite recursion (max call stack). `data` is re-fed
      // on every change by the watch's setOptions below, exactly like columns/state, so
      // the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
      // currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
      // (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
      data: _currentDataRef.current(),
      columns: _tableColumnsRef.current(),
      state: _currentStateRef.current(),
      getCoreRowModel: getCoreRowModel(),
      getSortedRowModel: getSortedRowModel(),
      getFilteredRowModel: getFilteredRowModel(),
      getPaginationRowModel: getPaginationRowModel(),
      // Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
      // (mirrors the other models) — inert when `expanded` is empty + no getSubRows
      // (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
      // ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
      // (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
      // default `!!subRows.length` rule applies (only parents with children expand).
      getExpandedRowModel: getExpandedRowModel(),
      getSubRows: (_getSubRowsRef.current || undefined) as any,
      getRowCanExpand: _expandableRef.current === true && _getSubRowsRef.current == null ? () => true : undefined,
      onExpandedChange: _onExpandedChangeCbStable,
      // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
      // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
      // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
      // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
      // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
      // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
      // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
      // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
      // are unaffected (they fire regardless of this flag).
      autoResetExpanded: false,
      // Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
      // UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
      // (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
      // table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
      // members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
      // nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
      // (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
      getGroupedRowModel: getGroupedRowModel(),
      onGroupingChange: _onGroupingChangeCbStable,
      // Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
      // UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
      // column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
      // The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
      getFacetedRowModel: getFacetedRowModel(),
      getFacetedUniqueValues: makeFacetedUniqueValues(),
      getFacetedMinMaxValues: makeFacetedMinMaxValues(),
      // Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
      // rows verbatim (no client-side filter/sort/paginate) and only emits the change
      // events so the consumer can fetch the next page/filtered slice.
      manualPagination: _manualRef.current === true,
      manualFiltering: _manualRef.current === true,
      manualSorting: _manualRef.current === true,
      // Server-side page-count sources (#2): pass the consumer-supplied total row count and/or
      // explicit page count so table-core can compute getPageCount() under `manual` (where it
      // does not hold the full dataset). undefined when unset → table-core auto-derives from the
      // loaded data (client-pagination path byte-unchanged). Precedence is table-core's: explicit
      // pageCount wins, else ⌈rowCount / pageSize⌉, else auto. With a real count getCanNextPage()
      // becomes true, so a server-pagination consumer can leave page 0.
      rowCount: _rowCountRef.current ?? undefined,
      pageCount: _pageCountRef.current ?? undefined,
      // Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
      // (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
      // default, D-06 — NOT overridden).
      enableRowSelection: _selectionModeRef.current !== 'none',
      enableMultiRowSelection: _selectionModeRef.current === 'multiple',
      // PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
      // no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
      // $watch so React reads fresh currentState (the stale-closure fix, F6).
      onSortingChange: _onSortingChangeCbStable,
      onGlobalFilterChange: _onGlobalFilterChangeCbStable,
      onColumnFiltersChange: _onColumnFiltersChangeCbStable,
      onPaginationChange: _onPaginationChangeCbStable,
      onRowSelectionChange: _onRowSelectionChangeCbStable,
      onColumnVisibilityChange: _onColumnVisibilityChangeCbStable,
      onColumnSizingChange: _onColumnSizingChangeCbStable,
      onColumnOrderChange: _onColumnOrderChangeCbStable,
      onColumnPinningChange: _onColumnPinningChangeCbStable,
      onColumnSizingInfoChange: _onColumnSizingInfoChangeCbStable,
      // Resize mode: 'onChange' so the bound columnSizing model updates live during the
      // drag (the behavioral width-delta assertion observes the in-progress width). Column
      // resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
      columnResizeMode: 'onChange',
      enableColumnResizing: true,
      renderFallbackValue: null,
      // table-core's RESOLVED options type (TableOptionsResolved) requires a global
      // onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
      // callbacks above, so the global hook is a no-op. Present so the createTable() argument
      // satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
      onStateChange: () => {}
    });
    refreshRowModel.current = () => {
      if (!table.current) return;
      // Capture fresh locals; never write a $data key then re-read it in the same fn
      // (ROZ138 / React stale-read — setState is async on React, the closure binds the
      // PRE-write value).
      // windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
      // pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
      const nextRows = _windowSourceRef.current().slice();
      const nextGroups = table.current.getHeaderGroups().slice();
      setRows(nextRows);
      setHeaderGroups(nextGroups);
      setRowModelVer(prev => prev + 1);
      // Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
      // into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
      // NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
      // setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
      if (_virtualRef.current && virtualizer.current) {
        virtualizer.current.setOptions(_virtualizerOptionsRef.current());
        virtualizer.current._willUpdate();
      }
      // D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
      // clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
      // no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
      // B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
      // async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
      // the range corners on React too — never re-reading the pre-change model.
      const nextRowCount = nextRows.length;
      const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
      _clampActiveCellRef.current(nextRowCount, nextColCount);
      // #4: clamp a pageIndex that now points PAST the last page. When the consumer holds
      // pagination.pageIndex (controlled) and shrinks the data (filter / replace) so there are
      // fewer pages, the body renders blank ("Page 6 of 3" with Next disabled). Read table-core's
      // LIVE post-re-derive state: getPageCount() is the fresh count (now correct under `manual`
      // too, #2) and getState().pagination is the just-fed state. Funnel the correction through
      // writePagination (the single-emit + two-way-model funnel) so the consumer's controlled
      // pagination prop converges to the last valid page (page-change carries { pageIndex, pageSize }).
      //   • pc > 0 skips the manual-WITHOUT-count case (getPageCount() === -1) — never clamp toward
      //     an unknown total.
      //   • LOOP-GUARD: emit ONLY when the clamped index actually differs. After the consumer echoes
      //     the clamp back through the pagination prop, the re-feed re-enters here with
      //     pageIndex === pc - 1, so `pageIndex > pc - 1` is false → no re-emit; a consumer that
      //     ignores the event triggers no further re-feed, so it stays a single emit either way.
      //   • No fight with table-core's autoResetPageIndex: that reset only fires on table-core's OWN
      //     setX mutations, which this fully-controlled-state architecture never calls (filters/data
      //     flow through setOptions), so reading the live state here can only fire on a genuine
      //     overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
      //     writing paginationDefault, with no regression since table-core does not auto-clamp here).
      const pgState = table.current.getState().pagination;
      const pc = table.current.getPageCount();
      if (pc > 0 && pgState.pageIndex > pc - 1) {
        _writePaginationRef.current({
          pageIndex: pc - 1,
          pageSize: pgState.pageSize
        });
      }
      // B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
      // filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
      // indices), so resolve the committed row's NEW index by identity HERE (never from the React-
      // stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
      // gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
      // a no-relocation commit resolves the same index → byte-behaviorally identical to before.
      if (pendingEditFollow.current && _isGridRef.current()) {
        const follow = pendingEditFollow.current;
        pendingEditFollow.current = null;
        const followIdx = _indexOfRowInRef.current(nextRows, follow.rowOriginal, follow.rowId);
        if (followIdx >= 0) _focusCellWhenReadyRef.current(followIdx, follow.col);
      }
      // keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
      // selection state (bound :indeterminate is inert on 5/6 targets). The box persists
      // across selection changes; a microtask defer covers React's post-render DOM patch.
      _syncIndeterminateRef.current();
      if (typeof queueMicrotask !== 'undefined') queueMicrotask(_syncIndeterminateStable);else Promise.resolve().then(_syncIndeterminateStable);
    };

    // initial pull
    refreshRowModel.current();

    // ── Grid mode: capture the table root ──────────────────────────────────────────────
    // $el is the component root; the <table class="rozie-data-table"> is the grid root the
    // cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
    // (post-mount) so it is non-null and ROZ123-clean.
    gridRoot.current = __rozieRoot.current ? __rozieRoot.current!.querySelector('.rozie-data-table') : null;
    // WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
    // page load AND was non-deterministic on React/Solid (the entry cell may not be
    // committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
    // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
    // mount"); the consumer drives focus by Tabbing/clicking in, never the component.

    // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
    // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
    // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
    // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
    // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
    if (_virtualRef.current) {
      gridScrollEl.current = __rozieRoot.current ? __rozieRoot.current!.querySelector('.rdt-scroll') : null;
      virtualizer.current = new Virtualizer(_virtualizerOptionsRef.current());
      virtualizerCleanup.current = virtualizer.current._didMount();
      // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
      // evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
      // reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
      // non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
      // SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
      // only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
      // never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
      // re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
      // pulls getVirtualItems().
      setWindowVer(prev => prev + 1);
      // After the first window commits (next frame), refine heights + fire the dev-mode warns
      // ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
      // code and these warns can never fire there (req-1 byte-identical-off preserved).
      const afterFirstFrame = () => {
        // D-10: measure the rendered rows.
        remeasureWindow();
        // D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
        // bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
        // process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
        const h = gridScrollEl.current ? gridScrollEl.current.clientHeight : 0;
        if (!h) {
          console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
        }
        // D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
        // CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
        // virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
        // ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
        // detected as a pagination that DIFFERS from that default (a consumer who set a real page
        // size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
        // virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
        const pg = _paginationRef.current;
        const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
        if (_manualRef.current !== true && pgConfigured) {
          console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
        }
      };
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
    }

    // #14: seed the sort/filter announce baseline from the initial (post-mount) state so the LAZY
    // watch's first fire — a real user sort/filter — compares against the true starting values and
    // is classified correctly (a null sentinel would misread the first filter change as a sort change).
    announceState.sorting = _effectiveSortingRef.current();
    announceState.columnFilters = _effectiveColumnFiltersRef.current();
    announceState.globalFilter = _effectiveGlobalFilterRef.current();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    return () => {
      if (virtualizerCleanup.current) virtualizerCleanup.current();
      // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
      teardownFillDrag();
      // §6 (260709-3qt): remove any live drag-select document listeners on a mid-drag unmount.
      teardownRangeDrag();
    };
  }, []);
  useEffect(() => {
    maybeClearHistoryOnExternalSwap();
    if (!table.current) return;
    // Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
    // $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
    // bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
    // identical length) still re-feeds.
    const d = currentData() || [];
    if (d === lastData.current && d.length === lastDataLen.current) return;
    lastData.current = d;
    lastDataLen.current = d.length;
    reFeed();
  }, [currentData, lastData, lastDataLen, maybeClearHistoryOnExternalSwap, reFeed, table]);
  useEffect(() => {
    if (_watch0First.current) { _watch0First.current = false; return; }
    reFeed();
    maybeClearHistoryOnExternalSwap();
  }, [colReg, columnFilters, columnOrder, columnPinning, columnSizing, columnVisibility, data, dataDefault, expanded, globalFilter, grouping, pagination, props.columns, props.expandable, props.groupable, props.pageCount, props.rowCount, props.selectionMode, rowSelection, sorting]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (_watch1First.current) { _watch1First.current = false; return; }
    const msg = buildSortFilterAnnounce();
    if (msg) setLiveAnnounce(msg);
  }, [columnFilters, columnFiltersDefault, globalFilter, globalFilterDefault, sorting, sortingDefault]); // eslint-disable-line react-hooks/exhaustive-deps

  const _rozieExposeRef = useRef({ sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut, undo, redo, canUndo, canRedo, clearHistory });
  _rozieExposeRef.current = { sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut, undo, redo, canUndo, canRedo, clearHistory };
  useImperativeHandle(ref, () => ({ sortColumn: (...args: Parameters<typeof sortColumn>): ReturnType<typeof sortColumn> => _rozieExposeRef.current.sortColumn(...args), clearSorting: (...args: Parameters<typeof clearSorting>): ReturnType<typeof clearSorting> => _rozieExposeRef.current.clearSorting(...args), toggleRowExpanded: (...args: Parameters<typeof toggleRowExpanded>): ReturnType<typeof toggleRowExpanded> => _rozieExposeRef.current.toggleRowExpanded(...args), expandAll: (...args: Parameters<typeof expandAll>): ReturnType<typeof expandAll> => _rozieExposeRef.current.expandAll(...args), collapseAll: (...args: Parameters<typeof collapseAll>): ReturnType<typeof collapseAll> => _rozieExposeRef.current.collapseAll(...args), getExpandedRows: (...args: Parameters<typeof getExpandedRows>): ReturnType<typeof getExpandedRows> => _rozieExposeRef.current.getExpandedRows(...args), applyGrouping: (...args: Parameters<typeof applyGrouping>): ReturnType<typeof applyGrouping> => _rozieExposeRef.current.applyGrouping(...args), clearGrouping: (...args: Parameters<typeof clearGrouping>): ReturnType<typeof clearGrouping> => _rozieExposeRef.current.clearGrouping(...args), getFacetedUniqueValues: (...args: Parameters<typeof getFacetedUniqueValues>): ReturnType<typeof getFacetedUniqueValues> => _rozieExposeRef.current.getFacetedUniqueValues(...args), getFacetedMinMaxValues: (...args: Parameters<typeof getFacetedMinMaxValues>): ReturnType<typeof getFacetedMinMaxValues> => _rozieExposeRef.current.getFacetedMinMaxValues(...args), getColumnDefs: (...args: Parameters<typeof getColumnDefs>): ReturnType<typeof getColumnDefs> => _rozieExposeRef.current.getColumnDefs(...args), toggleAllRows: (...args: Parameters<typeof toggleAllRows>): ReturnType<typeof toggleAllRows> => _rozieExposeRef.current.toggleAllRows(...args), clearSelection: (...args: Parameters<typeof clearSelection>): ReturnType<typeof clearSelection> => _rozieExposeRef.current.clearSelection(...args), getSelectedRows: (...args: Parameters<typeof getSelectedRows>): ReturnType<typeof getSelectedRows> => _rozieExposeRef.current.getSelectedRows(...args), setPage: (...args: Parameters<typeof setPage>): ReturnType<typeof setPage> => _rozieExposeRef.current.setPage(...args), setRowsPerPage: (...args: Parameters<typeof setRowsPerPage>): ReturnType<typeof setRowsPerPage> => _rozieExposeRef.current.setRowsPerPage(...args), toggleColumnVisibility: (...args: Parameters<typeof toggleColumnVisibility>): ReturnType<typeof toggleColumnVisibility> => _rozieExposeRef.current.toggleColumnVisibility(...args), applyColumnOrder: (...args: Parameters<typeof applyColumnOrder>): ReturnType<typeof applyColumnOrder> => _rozieExposeRef.current.applyColumnOrder(...args), resetColumnSizing: (...args: Parameters<typeof resetColumnSizing>): ReturnType<typeof resetColumnSizing> => _rozieExposeRef.current.resetColumnSizing(...args), pinColumn: (...args: Parameters<typeof pinColumn>): ReturnType<typeof pinColumn> => _rozieExposeRef.current.pinColumn(...args), focusCell: (...args: Parameters<typeof focusCell>): ReturnType<typeof focusCell> => _rozieExposeRef.current.focusCell(...args), getActiveCell: (...args: Parameters<typeof getActiveCell>): ReturnType<typeof getActiveCell> => _rozieExposeRef.current.getActiveCell(...args), clearActiveCell: (...args: Parameters<typeof clearActiveCell>): ReturnType<typeof clearActiveCell> => _rozieExposeRef.current.clearActiveCell(...args), getRowIndexRelativeToPage: (...args: Parameters<typeof getRowIndexRelativeToPage>): ReturnType<typeof getRowIndexRelativeToPage> => _rozieExposeRef.current.getRowIndexRelativeToPage(...args), editCell: (...args: Parameters<typeof editCell>): ReturnType<typeof editCell> => _rozieExposeRef.current.editCell(...args), commitEditing: (...args: Parameters<typeof commitEditing>): ReturnType<typeof commitEditing> => _rozieExposeRef.current.commitEditing(...args), editRow: (...args: Parameters<typeof editRow>): ReturnType<typeof editRow> => _rozieExposeRef.current.editRow(...args), getSelectedRange: (...args: Parameters<typeof getSelectedRange>): ReturnType<typeof getSelectedRange> => _rozieExposeRef.current.getSelectedRange(...args), cut: (...args: Parameters<typeof cut>): ReturnType<typeof cut> => _rozieExposeRef.current.cut(...args), undo: (...args: Parameters<typeof undo>): ReturnType<typeof undo> => _rozieExposeRef.current.undo(...args), redo: (...args: Parameters<typeof redo>): ReturnType<typeof redo> => _rozieExposeRef.current.redo(...args), canUndo: (...args: Parameters<typeof canUndo>): ReturnType<typeof canUndo> => _rozieExposeRef.current.canUndo(...args), canRedo: (...args: Parameters<typeof canRedo>): ReturnType<typeof canRedo> => _rozieExposeRef.current.canRedo(...args), clearHistory: (...args: Parameters<typeof clearHistory>): ReturnType<typeof clearHistory> => _rozieExposeRef.current.clearHistory(...args) }), []);

  return (
    <__ctx_data_table_columns.Provider value={{
  registerColumn: (id: any, spec: any) => {
    if (id == null) return;
    const key = String(id);
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
    setColReg(prev => ({
      ...prev,
      [key]: spec
    }));
  },
  unregisterColumn: (id: any) => {
    if (id == null) return;
    const r = {
      ...colReg
    };
    delete r[String(id)];
    setColReg(r);
  }
}}>
    <>

    <div className={"rozie-data-table-wrap"} ref={__rozieRoot} data-rozie-s-d5dcab4c="">

    <div className={"rdt-column-defs"} style={{ display: "none" }} aria-hidden="true" data-rozie-s-d5dcab4c="">{(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}</div>

    {!!(!!invalidMsg) && <div className={"rdt-sr-live"} role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c="">{invalidMsg}</div>}{!!(!!pasteAnnounce) && <div className={"rdt-sr-live rdt-sr-paste"} data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c="">{pasteAnnounce}</div>}{!!(!!liveAnnounce) && <div className={"rdt-sr-live rdt-sr-sortfilter"} data-testid="sortfilter-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c="">{liveAnnounce}</div>}<div className={"rdt-toolbar"} data-rozie-s-d5dcab4c="">
      <input className={"rdt-global-filter"} type="text" role="searchbox" aria-label="Search table" value={globalFilterValue()} onInput={($event) => { onGlobalFilterInput($event); }} data-rozie-s-d5dcab4c="" />
      
      {!!(allLeafColumns().length) && <details className={"rdt-colvis"} data-rozie-s-d5dcab4c="">
        <summary className={"rdt-colvis-summary"} data-rozie-s-d5dcab4c="">Columns</summary>
        <div className={"rdt-colvis-menu"} role="group" aria-label="Toggle columns" data-rozie-s-d5dcab4c="">
          {allLeafColumns().map((lc) => <label key={lc.id} className={"rdt-colvis-item"} data-rozie-s-d5dcab4c="">
            <input type="checkbox" className={"rdt-colvis-checkbox"} checked={lc.visible} onChange={($event) => { onToggleVisibility(lc.id); }} data-rozie-s-d5dcab4c="" />
            <span className={"rdt-colvis-label"} data-rozie-s-d5dcab4c="">{rozieDisplay(lc.label)}</span>
          </label>)}
        </div>
      </details>}</div>


    {!!(props.groupable) && <div className={"rdt-group-bar-host"} data-rozie-s-d5dcab4c="">
      {(props.renderGroupBar ?? props.slots?.['groupBar']) ? ((props.renderGroupBar ?? props.slots?.['groupBar']) as Function)({ grouping: groupingKeys(), groupableColumns: groupableColumns(), applyGrouping, clearGrouping }) : groupingKeys().map((gk) => <span key={gk} className={"rdt-group-token"} data-group-token="" data-rozie-s-d5dcab4c="">{rozieDisplay(gk)}</span>)}
    </div>}{(props.virtual) ? <div className={"rdt-scroll"} style={parseInlineStyle(props.maxHeight ? 'max-height:' + props.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + props.maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c="">
    <table className={clsx("rozie-data-table", { "rdt-sticky": props.stickyHeader })} role={rozieAttr(tableRole())} aria-rowcount={gridAriaRowCount()} onKeyDown={($event) => { onGridKeyDown($event); }} onFocus={($event) => { syncActiveFromEvent($event); }} onBlur={($event) => { onGridFocusOut($event); }} onMouseDown={($event) => { onGridMouseDown($event); }} onDoubleClick={($event) => { onGridDblClick($event); }} onClick={($event) => { onGridClick($event); }} data-rozie-s-d5dcab4c="">
      <thead className={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
        {headerGroups.map((hg, hgLevel) => <tr key={hg.id} className={"rdt-tr"} role="row" aria-rowindex={hgLevel + 1} data-rozie-s-d5dcab4c="">
          {hg.headers.map((header) => <th key={header.id} className={clsx("rdt-th", { "rdt-select-th": isSelectColumn(header.column.id), "rdt-expander-th": isExpanderColumn(header.column.id), "rdt-th-resizing": columnIsResizing(header.column.id), "rdt-cell-active": isActiveCell('__header', headerColIndexOf(hg, header), hgLevel) })} role="columnheader" data-col={rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel)} colSpan={(header.colSpan > 1 ? header.colSpan : undefined) ?? undefined} data-col-index={rozieAttr(headerColIndexOf(hg, header))} tabIndex={cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)} aria-sort={rozieAttr(ariaSortFor(header.column.id))} style={parseInlineStyle(thStyle(header.column.id))} data-rozie-s-d5dcab4c="">
            {(isSelectColumn(header.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(props.renderSelectAll ?? props.slots?.['selectAll']) ? ((props.renderSelectAll ?? props.slots?.['selectAll']) as Function)({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }) : !!(props.selectionMode === 'multiple') && <input className={"rdt-select-all"} type="checkbox" aria-label="Select all rows" checked={isAllRowsSelected()} onChange={($event) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c="" />}
            </span> : (isExpanderColumn(header.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /> : <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(header.column.getCanSort && header.column.getCanSort()) ? <button type="button" className={"rdt-sort-btn"} onClick={($event) => { onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c="">
                <span className={"rdt-header-label"} data-rozie-s-d5dcab4c="">
                  {(props.renderColHeader ?? props.slots?.['colHeader']) ? ((props.renderColHeader ?? props.slots?.['colHeader']) as Function)({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) : rozieDisplay(headerLabel(header.column.id))}
                </span>
                <span className={"rdt-sort-ind"} aria-hidden="true" data-rozie-s-d5dcab4c="">{rozieDisplay(sortIndicator(header.column.id))}</span>
              </button> : <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                <span className={"rdt-header-label"} data-rozie-s-d5dcab4c="">
                  {(props.renderColHeader ?? props.slots?.['colHeader']) ? ((props.renderColHeader ?? props.slots?.['colHeader']) as Function)({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) : rozieDisplay(headerLabel(header.column.id))}
                </span>
              </span>}<Popover trigger="click" placement="bottom-end" strategy="fixed" offset={4} data-rozie-s-d5dcab4c="" renderAnchor={() => (<>
                  <button type="button" className={"rdt-col-menu-trigger"} aria-label={rozieAttr('Column options for ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c="">⋯</button>
                </>)} children={<><div className={"rdt-col-menu"} role="menu" data-rozie-s-d5dcab4c="">
                  <button type="button" role="menuitem" className={"rdt-col-menu-item"} aria-pressed={columnPinSide(header.column.id) === 'left'} onClick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">Pin left</button>
                  <button type="button" role="menuitem" className={"rdt-col-menu-item"} aria-pressed={columnPinSide(header.column.id) === 'right'} onClick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">Pin right</button>
                  <button type="button" role="menuitem" className={"rdt-col-menu-item"} aria-pressed={!columnPinSide(header.column.id)} onClick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c="">Unpin</button>
                  <hr className={"rdt-col-menu-sep"} data-rozie-s-d5dcab4c="" />
                  <button type="button" role="menuitem" className={"rdt-col-menu-item"} onClick={($event) => { onHideColumn(header.column.id, $event); }} data-rozie-s-d5dcab4c="">Hide column</button>
                </div></>} />
              <button type="button" className={"rdt-resize-handle"} aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} onPointerDown={($event) => { onResizeStart(header.column.id, $event); }} onTouchStart={($event) => { onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c=""><span className={"rdt-resize-grip"} aria-hidden="true" data-rozie-s-d5dcab4c="" /></button>
            </span>}</th>)}
        </tr>)}
        
        {!!(hasAnyFilterableColumn()) && <tr className={"rdt-filter-row"} data-rozie-s-d5dcab4c="">
          {headerGroups[headerGroups.length - 1].headers.map((header) => <th key={header.id} className={"rdt-filter-cell"} role="presentation" style={parseInlineStyle(pinStyle(header.column.id))} data-rozie-s-d5dcab4c="">
            {(isSelectColumn(header.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /> : (isExpanderColumn(header.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /> : <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {!!(columnIsFilterable(header.column.id) && !hasFilterSlot()) && <input className={"rdt-col-filter"} type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} value={columnFilterValue(header.column.id)} onInput={($event) => { onColumnFilterInput(header.column.id, $event); }} onClick={($event) => { stopEvent($event); }} data-rozie-s-d5dcab4c="" />}{!!(columnIsFilterable(header.column.id)) && <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(props.renderFilter ?? props.slots?.['filter'])?.({ columnId: header.column.id, value: columnFilterValue(header.column.id), uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}
              </span>}</span>}</th>)}
        </tr>}</thead>

      <tbody className={"rdt-tbody"} role="rowgroup" data-rozie-s-d5dcab4c="">
        
        <tr className={"rdt-spacer"} aria-hidden="true" data-rozie-s-d5dcab4c="">
          <td colSpan={visibleColCount()} style={parseInlineStyle('height:' + padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
        </tr>
        
        {windowedRows().map((wr) => <Fragment key={wr.row.id}>
        <tr key={wr.row.id} className={clsx("rdt-tr", { "rdt-group-header": rowIsGrouped(wr.row), "rdt-row-pinned": wr.pinned })} role="row" data-row={rozieAttr(wr.vi.index)} aria-rowindex={headerRowCount() + wr.vi.index + 1} data-index={rozieAttr(wr.vi.index)} data-pinned={rozieAttr(wr.pinned ? 'true' : undefined)} data-depth={rozieAttr(wr.row.depth)} data-group-header={rozieAttr(rowIsGrouped(wr.row) ? wr.row.id : undefined)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(wr.row) ? wr.row.id : undefined)} aria-expanded={(rowIsGrouped(wr.row) ? !!rowIsExpanded(wr.row) : undefined) ?? undefined} aria-selected={(props.selectionMode !== 'none' ? !!rowIsSelected(wr.row) : undefined) ?? undefined} aria-level={(groupingActive() ? wr.row.depth + 1 : undefined) ?? undefined} data-rozie-s-d5dcab4c="">
          {visibleCellsFor(wr.row).map((cell) => <td key={cell.id} className={clsx("rdt-td", { "rdt-select-td": isSelectColumn(cell.column.id), "rdt-expander-td": isExpanderColumn(cell.column.id), "rdt-in-range": inRange(wr.vi.index, colIndexOf(wr.row, cell)), "rdt-cell-active": isActiveCell(String(wr.vi.index), colIndexOf(wr.row, cell)) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cell.column.id)} data-grid-cell="" data-row={rozieAttr(wr.vi.index)} data-col-index={rozieAttr(colIndexOf(wr.row, cell))} tabIndex={cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cell))} style={parseInlineStyle(bodyCellStyle(wr.row, cell.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cell)))} data-in-range={rozieAttr(inRange(wr.vi.index, colIndexOf(wr.row, cell)) ? 'true' : undefined)} data-agg-cell={rozieAttr(cellIsAggregated(cell) ? cell.column.id : undefined)} data-rozie-s-d5dcab4c="">
            
            {(isExpanderColumn(cell.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {!!(rowCanExpand(wr.row)) && <button type="button" className={"rdt-expander"} data-expander="" aria-expanded={!!rowIsExpanded(wr.row)} aria-label={rozieAttr(rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row')} onClick={($event) => { onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸')}</button>}</span> : (isSelectColumn(cell.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(props.renderSelectCell ?? props.slots?.['selectCell']) ? ((props.renderSelectCell ?? props.slots?.['selectCell']) as Function)({ row: wr.row.original, checked: rowIsSelected(wr.row), toggle: e => onToggleRow(wr.row, e) }) : <input className={"rdt-select-row"} type="checkbox" aria-label="Select row" checked={rowIsSelected(wr.row)} onChange={($event) => { onToggleRow(wr.row, $event); }} data-rozie-s-d5dcab4c="" />}
            </span> : (cellIsGrouped(cell)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              <button type="button" className={"rdt-expander rdt-group-toggle"} data-expander="" aria-expanded={!!rowIsExpanded(wr.row)} aria-label={rozieAttr(rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group')} onClick={($event) => { onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸')}</button>
              <span className={"rdt-group-value"} data-rozie-s-d5dcab4c="">
                {(props.renderCell ?? props.slots?.['cell']) ? ((props.renderCell ?? props.slots?.['cell']) as Function)({ columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue() }) : rozieDisplay(cell.getValue())}
              </span>
              <span className={"rdt-group-count"} data-rozie-s-d5dcab4c="">{rozieDisplay('(' + groupSubRowCount(wr.row) + ')')}</span>
            </span> : (isEditing(wr.vi.index, colIndexOf(wr.row, cell))) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(hasEditorSlot(cell.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(props.renderEditor ?? props.slots?.['editor'])?.({ columnId: cell.column.id, column: cell.column, row: wr.row.original, value: editorValueFor(cell.column.id), commit: editorCommitFor(cell.column.id), cancel: editorCancelFor(), autofocus: editorAutofocusFor(cell.column.id, wr.vi.index) })}
              </span> : (editorTypeOf(cell.column.id) === 'number') ? <input className={"rdt-cell-editor"} type="number" data-editing-cell="" value={editorValueFor(cell.column.id)} onInput={($event) => { onCellEditorInput(cell.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /> : (editorTypeOf(cell.column.id) === 'select') ? <select className={"rdt-cell-editor"} data-editing-cell="" value={editorValueFor(cell.column.id)} onChange={($event) => { onCellEditorInput(cell.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
                {editorOptionsOf(cell.column.id).map((opt) => <option key={opt.value} value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt.label)}</option>)}
              </select> : (editorTypeOf(cell.column.id) === 'checkbox') ? <input className={"rdt-cell-editor"} type="checkbox" data-editing-cell="" checked={editorCheckedFor(cell.column.id)} onChange={($event) => { onCellEditorCheckbox(cell.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /> : <input className={"rdt-cell-editor"} type="text" data-editing-cell="" value={editorValueFor(cell.column.id)} onInput={($event) => { onCellEditorInput(cell.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}</span> : (cellIsPlaceholder(cell)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /> : <span className={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
              {(props.renderCell ?? props.slots?.['cell']) ? ((props.renderCell ?? props.slots?.['cell']) as Function)({ columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue() }) : rozieDisplay(cell.getValue())}
            </span>}{!!(isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cell))) && <span className={"rdt-fill-handle"} data-fill-handle="" data-testid="fill-handle" aria-hidden="true" onPointerDown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c="" />}</td>)}
        </tr>
        
        {!!(rowShowsDetail(wr.row)) && <tr key={wr.row.id} className={"rdt-detail-row"} role="row" data-detail-row={rozieAttr(wr.row.id)} data-rozie-s-d5dcab4c="">
          <td className={"rdt-detail-cell"} colSpan={visibleColCount()} data-rozie-s-d5dcab4c="">
            {(props.renderDetail ?? props.slots?.['detail'])?.({ row: wr.row.original })}
          </td>
        </tr>}</Fragment>)}
        
        <tr className={"rdt-spacer"} aria-hidden="true" data-rozie-s-d5dcab4c="">
          <td colSpan={visibleColCount()} style={parseInlineStyle('height:' + padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
        </tr>
      </tbody>
    </table>
    </div> : <table className={clsx("rozie-data-table", { "rdt-sticky": props.stickyHeader })} role={rozieAttr(tableRole())} aria-rowcount={gridAriaRowCount()} onKeyDown={($event) => { onGridKeyDown($event); }} onFocus={($event) => { syncActiveFromEvent($event); }} onBlur={($event) => { onGridFocusOut($event); }} onMouseDown={($event) => { onGridMouseDown($event); }} onDoubleClick={($event) => { onGridDblClick($event); }} onClick={($event) => { onGridClick($event); }} data-rozie-s-d5dcab4c="">
      <thead className={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
        {headerGroups.map((hg, hgLevel) => <tr key={hg.id} className={"rdt-tr"} role="row" aria-rowindex={hgLevel + 1} data-rozie-s-d5dcab4c="">
          {hg.headers.map((header) => <th key={header.id} className={clsx("rdt-th", { "rdt-select-th": isSelectColumn(header.column.id), "rdt-expander-th": isExpanderColumn(header.column.id), "rdt-th-resizing": columnIsResizing(header.column.id), "rdt-cell-active": isActiveCell('__header', headerColIndexOf(hg, header), hgLevel) })} role="columnheader" data-col={rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel)} colSpan={(header.colSpan > 1 ? header.colSpan : undefined) ?? undefined} data-col-index={rozieAttr(headerColIndexOf(hg, header))} tabIndex={cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)} aria-sort={rozieAttr(ariaSortFor(header.column.id))} style={parseInlineStyle(thStyle(header.column.id))} data-rozie-s-d5dcab4c="">
            
            
            {(isSelectColumn(header.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(props.renderSelectAll ?? props.slots?.['selectAll']) ? ((props.renderSelectAll ?? props.slots?.['selectAll']) as Function)({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }) : !!(props.selectionMode === 'multiple') && <input className={"rdt-select-all"} type="checkbox" aria-label="Select all rows" checked={isAllRowsSelected()} onChange={($event) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c="" />}
            </span> : (isExpanderColumn(header.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /> : <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              
              {(header.column.getCanSort && header.column.getCanSort()) ? <button type="button" className={"rdt-sort-btn"} onClick={($event) => { onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c="">
                
                <span className={"rdt-header-label"} data-rozie-s-d5dcab4c="">
                  {(props.renderColHeader ?? props.slots?.['colHeader']) ? ((props.renderColHeader ?? props.slots?.['colHeader']) as Function)({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) : rozieDisplay(headerLabel(header.column.id))}
                </span>
                <span className={"rdt-sort-ind"} aria-hidden="true" data-rozie-s-d5dcab4c="">{rozieDisplay(sortIndicator(header.column.id))}</span>
              </button> : <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                <span className={"rdt-header-label"} data-rozie-s-d5dcab4c="">
                  {(props.renderColHeader ?? props.slots?.['colHeader']) ? ((props.renderColHeader ?? props.slots?.['colHeader']) as Function)({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }) : rozieDisplay(headerLabel(header.column.id))}
                </span>
              </span>}<Popover trigger="click" placement="bottom-end" strategy="fixed" offset={4} data-rozie-s-d5dcab4c="" renderAnchor={() => (<>
                  <button type="button" className={"rdt-col-menu-trigger"} aria-label={rozieAttr('Column options for ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c="">⋯</button>
                </>)} children={<><div className={"rdt-col-menu"} role="menu" data-rozie-s-d5dcab4c="">
                  <button type="button" role="menuitem" className={"rdt-col-menu-item"} aria-pressed={columnPinSide(header.column.id) === 'left'} onClick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">Pin left</button>
                  <button type="button" role="menuitem" className={"rdt-col-menu-item"} aria-pressed={columnPinSide(header.column.id) === 'right'} onClick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">Pin right</button>
                  <button type="button" role="menuitem" className={"rdt-col-menu-item"} aria-pressed={!columnPinSide(header.column.id)} onClick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c="">Unpin</button>
                  <hr className={"rdt-col-menu-sep"} data-rozie-s-d5dcab4c="" />
                  <button type="button" role="menuitem" className={"rdt-col-menu-item"} onClick={($event) => { onHideColumn(header.column.id, $event); }} data-rozie-s-d5dcab4c="">Hide column</button>
                </div></>} />
              
              <button type="button" className={"rdt-resize-handle"} aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} onPointerDown={($event) => { onResizeStart(header.column.id, $event); }} onTouchStart={($event) => { onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c=""><span className={"rdt-resize-grip"} aria-hidden="true" data-rozie-s-d5dcab4c="" /></button>
            </span>}</th>)}
        </tr>)}
        
        {!!(hasAnyFilterableColumn()) && <tr className={"rdt-filter-row"} data-rozie-s-d5dcab4c="">
          {headerGroups[headerGroups.length - 1].headers.map((header) => <th key={header.id} className={"rdt-filter-cell"} role="presentation" style={parseInlineStyle(pinStyle(header.column.id))} data-rozie-s-d5dcab4c="">
            {(isSelectColumn(header.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /> : (isExpanderColumn(header.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /> : <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {!!(columnIsFilterable(header.column.id) && !hasFilterSlot()) && <input className={"rdt-col-filter"} type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} value={columnFilterValue(header.column.id)} onInput={($event) => { onColumnFilterInput(header.column.id, $event); }} onClick={($event) => { stopEvent($event); }} data-rozie-s-d5dcab4c="" />}{!!(columnIsFilterable(header.column.id)) && <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(props.renderFilter ?? props.slots?.['filter'])?.({ columnId: header.column.id, value: columnFilterValue(header.column.id), uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}
              </span>}</span>}</th>)}
        </tr>}</thead>

      <tbody className={"rdt-tbody"} role="rowgroup" data-rozie-s-d5dcab4c="">
        
        {rows.map((row) => <Fragment key={row.id}>
        <tr key={row.id} className={clsx("rdt-tr", { "rdt-group-header": rowIsGrouped(row) })} role="row" data-depth={rozieAttr(row.depth)} aria-rowindex={bodyAriaRowIndex(row)} data-group-header={rozieAttr(rowIsGrouped(row) ? row.id : undefined)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(row) ? row.id : undefined)} aria-expanded={(rowIsGrouped(row) ? !!rowIsExpanded(row) : undefined) ?? undefined} aria-selected={(props.selectionMode !== 'none' ? !!rowIsSelected(row) : undefined) ?? undefined} aria-level={(groupingActive() ? row.depth + 1 : undefined) ?? undefined} data-rozie-s-d5dcab4c="">
          {visibleCellsFor(row).map((cell) => <td key={cell.id} className={clsx("rdt-td", { "rdt-select-td": isSelectColumn(cell.column.id), "rdt-expander-td": isExpanderColumn(cell.column.id), "rdt-in-range": inRange(rowIndexOf(row), colIndexOf(row, cell)), "rdt-cell-active": isActiveCell(String(rowIndexOf(row)), colIndexOf(row, cell)) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cell.column.id)} data-grid-cell="" data-row={rozieAttr(rowIndexOf(row))} data-col-index={rozieAttr(colIndexOf(row, cell))} tabIndex={cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cell))} style={parseInlineStyle(bodyCellStyle(row, cell.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cell)))} data-in-range={rozieAttr(inRange(rowIndexOf(row), colIndexOf(row, cell)) ? 'true' : undefined)} data-agg-cell={rozieAttr(cellIsAggregated(cell) ? cell.column.id : undefined)} data-rozie-s-d5dcab4c="">
            
            {(isExpanderColumn(cell.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {!!(rowCanExpand(row)) && <button type="button" className={"rdt-expander"} data-expander="" aria-expanded={!!rowIsExpanded(row)} aria-label={rozieAttr(rowIsExpanded(row) ? 'Collapse row' : 'Expand row')} onClick={($event) => { onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(row) ? '▾' : '▸')}</button>}</span> : (isSelectColumn(cell.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(props.renderSelectCell ?? props.slots?.['selectCell']) ? ((props.renderSelectCell ?? props.slots?.['selectCell']) as Function)({ row: row.original, checked: rowIsSelected(row), toggle: e => onToggleRow(row, e) }) : <input className={"rdt-select-row"} type="checkbox" aria-label="Select row" checked={rowIsSelected(row)} onChange={($event) => { onToggleRow(row, $event); }} data-rozie-s-d5dcab4c="" />}
            </span> : (cellIsGrouped(cell)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              <button type="button" className={"rdt-expander rdt-group-toggle"} data-expander="" aria-expanded={!!rowIsExpanded(row)} aria-label={rozieAttr(rowIsExpanded(row) ? 'Collapse group' : 'Expand group')} onClick={($event) => { onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(row) ? '▾' : '▸')}</button>
              <span className={"rdt-group-value"} data-rozie-s-d5dcab4c="">
                {(props.renderCell ?? props.slots?.['cell']) ? ((props.renderCell ?? props.slots?.['cell']) as Function)({ columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue() }) : rozieDisplay(cell.getValue())}
              </span>
              <span className={"rdt-group-count"} data-rozie-s-d5dcab4c="">{rozieDisplay('(' + groupSubRowCount(row) + ')')}</span>
            </span> : (isEditing(rowIndexOf(row), colIndexOf(row, cell))) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(hasEditorSlot(cell.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(props.renderEditor ?? props.slots?.['editor'])?.({ columnId: cell.column.id, column: cell.column, row: row.original, value: editorValueFor(cell.column.id), commit: editorCommitFor(cell.column.id), cancel: editorCancelFor(), autofocus: editorAutofocusFor(cell.column.id, rowIndexOf(row)) })}
              </span> : (editorTypeOf(cell.column.id) === 'number') ? <input className={"rdt-cell-editor"} type="number" data-editing-cell="" value={editorValueFor(cell.column.id)} onInput={($event) => { onCellEditorInput(cell.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /> : (editorTypeOf(cell.column.id) === 'select') ? <select className={"rdt-cell-editor"} data-editing-cell="" value={editorValueFor(cell.column.id)} onChange={($event) => { onCellEditorInput(cell.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
                {editorOptionsOf(cell.column.id).map((opt) => <option key={opt.value} value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt.label)}</option>)}
              </select> : (editorTypeOf(cell.column.id) === 'checkbox') ? <input className={"rdt-cell-editor"} type="checkbox" data-editing-cell="" checked={editorCheckedFor(cell.column.id)} onChange={($event) => { onCellEditorCheckbox(cell.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /> : <input className={"rdt-cell-editor"} type="text" data-editing-cell="" value={editorValueFor(cell.column.id)} onInput={($event) => { onCellEditorInput(cell.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}</span> : (cellIsPlaceholder(cell)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /> : <span className={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
              {(props.renderCell ?? props.slots?.['cell']) ? ((props.renderCell ?? props.slots?.['cell']) as Function)({ columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue() }) : rozieDisplay(cell.getValue())}
            </span>}{!!(isFillHandleCell(rowIndexOf(row), colIndexOf(row, cell))) && <span className={"rdt-fill-handle"} data-fill-handle="" data-testid="fill-handle" aria-hidden="true" onPointerDown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c="" />}</td>)}
        </tr>
        
        {!!(rowShowsDetail(row)) && <tr key={row.id} className={"rdt-detail-row"} role="row" data-detail-row={rozieAttr(row.id)} data-rozie-s-d5dcab4c="">
          <td className={"rdt-detail-cell"} colSpan={visibleColCount()} data-rozie-s-d5dcab4c="">
            {(props.renderDetail ?? props.slots?.['detail'])?.({ row: row.original })}
          </td>
        </tr>}</Fragment>)}
      </tbody>
    </table>}{!!(!props.virtual) && <div className={"rdt-pagination"} role="group" aria-label="Pagination" data-rozie-s-d5dcab4c="">
      <button type="button" className={"rdt-page-btn rdt-page-prev"} disabled={!canPrevPage()} onClick={($event) => { onPrevPage(); }} data-rozie-s-d5dcab4c="">Prev</button>
      <span className={"rdt-page-status"} aria-live="polite" data-rozie-s-d5dcab4c="">
        {rozieDisplay('Page ' + (pageIndex() + 1) + ' of ' + displayPageCount())}
      </span>
      <button type="button" className={"rdt-page-btn rdt-page-next"} disabled={!canNextPage()} onClick={($event) => { onNextPage(); }} data-rozie-s-d5dcab4c="">Next</button>
      <select className={"rdt-page-size"} aria-label="Rows per page" value={pageSize()} onChange={($event) => { onPageSizeChange($event); }} data-rozie-s-d5dcab4c="">
        <option value={10} data-rozie-s-d5dcab4c="">10</option>
        <option value={25} data-rozie-s-d5dcab4c="">25</option>
        <option value={50} data-rozie-s-d5dcab4c="">50</option>
        <option value={100} data-rozie-s-d5dcab4c="">100</option>
      </select>
    </div>}</div>
    </>
    </__ctx_data_table_columns.Provider>
  );
});
export default DataTable;
vue
<template>


<div class="rozie-data-table-wrap" ref="__rozieRootRef">

<div class="rdt-column-defs" style="display:none" aria-hidden="true"><slot></slot></div>

<div v-if="!!invalidMsg" class="rdt-sr-live" role="status" aria-live="polite" aria-atomic="true">{{ invalidMsg }}</div><div v-if="!!pasteAnnounce" class="rdt-sr-live rdt-sr-paste" data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true">{{ pasteAnnounce }}</div><div v-if="!!liveAnnounce" class="rdt-sr-live rdt-sr-sortfilter" data-testid="sortfilter-announce" role="status" aria-live="polite" aria-atomic="true">{{ liveAnnounce }}</div><div class="rdt-toolbar">
  <input class="rdt-global-filter" type="text" role="searchbox" aria-label="Search table" :value="globalFilterValue()" @input="onGlobalFilterInput($event)" />
  
  <details v-if="allLeafColumns().length" class="rdt-colvis">
    <summary class="rdt-colvis-summary">Columns</summary>
    <div class="rdt-colvis-menu" role="group" aria-label="Toggle columns">
      <label v-for="lc in allLeafColumns()" :key="lc.id" class="rdt-colvis-item">
        <input type="checkbox" class="rdt-colvis-checkbox" :checked="lc.visible" @change="onToggleVisibility(lc.id)" />
        <span class="rdt-colvis-label">{{ lc.label }}</span>
      </label>
    </div>
  </details></div>


<div v-if="props.groupable" class="rdt-group-bar-host">
  <slot name="groupBar" :grouping="groupingKeys()" :groupableColumns="groupableColumns()" :applyGrouping="applyGrouping" :clearGrouping="clearGrouping">
    <span v-for="gk in groupingKeys()" :key="gk" class="rdt-group-token" data-group-token="">{{ gk }}</span>
  </slot>
</div><div v-if="props.virtual" class="rdt-scroll" :style="props.maxHeight ? 'max-height:' + props.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + props.maxHeight : 'overflow:auto'">
<table :class="['rozie-data-table', { 'rdt-sticky': props.stickyHeader }]" :role="tableRole()" :aria-rowcount="(gridAriaRowCount()) ?? undefined" @keydown="onGridKeyDown($event)" @focusin="syncActiveFromEvent($event)" @focusout="onGridFocusOut($event)" @mousedown="onGridMouseDown($event)" @dblclick="onGridDblClick($event)" @click="onGridClick($event)">
  <thead class="rdt-thead" role="rowgroup">
    <tr v-for="(hg, hgLevel) in headerGroups" :key="hg.id" class="rdt-tr" role="row" :aria-rowindex="hgLevel + 1">
      <th v-for="header in hg.headers" :key="header.id" :class="['rdt-th', { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-expander-th': isExpanderColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg, header), hgLevel) }]" role="columnheader" :data-col="header.column.id" data-grid-cell="" data-row="__header" :data-header-level="hgLevel" :colspan="(header.colSpan > 1 ? header.colSpan : undefined) ?? undefined" :data-col-index="headerColIndexOf(hg, header)" :tabindex="(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)) ?? undefined" :aria-sort="ariaSortFor(header.column.id)" :style="thStyle(header.column.id)">
        <span v-if="isSelectColumn(header.column.id)" style="display:contents">
          <slot name="selectAll" :checked="isAllRowsSelected()" :indeterminate="isSomeRowsSelected()" :toggle="onToggleAllRows">
            <input v-if="props.selectionMode === 'multiple'" class="rdt-select-all" type="checkbox" aria-label="Select all rows" :checked="isAllRowsSelected()" @change="onToggleAllRows($event)" /></slot>
        </span><span v-else-if="isExpanderColumn(header.column.id)" style="display:contents"></span><span v-else style="display:contents">
          <button v-if="header.column.getCanSort && header.column.getCanSort()" type="button" class="rdt-sort-btn" @click="onHeaderSort(header.column.id, $event)">
            <span class="rdt-header-label">
              <slot name="colHeader" :columnId="header.column.id" :column="header.column" :label="headerLabel(header.column.id)">{{ headerLabel(header.column.id) }}</slot>
            </span>
            <span class="rdt-sort-ind" aria-hidden="true">{{ sortIndicator(header.column.id) }}</span>
          </button><span v-else style="display:contents">
            <span class="rdt-header-label">
              <slot name="colHeader" :columnId="header.column.id" :column="header.column" :label="headerLabel(header.column.id)">{{ headerLabel(header.column.id) }}</slot>
            </span>
          </span><Popover trigger="click" placement="bottom-end" strategy="fixed" :offset="4"><template #anchor>
              <button type="button" class="rdt-col-menu-trigger" :aria-label="'Column options for ' + headerLabel(header.column.id)">⋯</button>
            </template><div class="rdt-col-menu" role="menu">
              <button type="button" role="menuitem" class="rdt-col-menu-item" :aria-pressed="columnPinSide(header.column.id) === 'left'" @click="onPinColumn(header.column.id, 'left', $event)">Pin left</button>
              <button type="button" role="menuitem" class="rdt-col-menu-item" :aria-pressed="columnPinSide(header.column.id) === 'right'" @click="onPinColumn(header.column.id, 'right', $event)">Pin right</button>
              <button type="button" role="menuitem" class="rdt-col-menu-item" :aria-pressed="!columnPinSide(header.column.id)" @click="onPinColumn(header.column.id, false, $event)">Unpin</button>
              <hr class="rdt-col-menu-sep" />
              <button type="button" role="menuitem" class="rdt-col-menu-item" @click="onHideColumn(header.column.id, $event)">Hide column</button>
            </div></Popover>
          <button type="button" class="rdt-resize-handle" :aria-label="'Resize ' + headerLabel(header.column.id)" @pointerdown="onResizeStart(header.column.id, $event)" @touchstart="onResizeStart(header.column.id, $event)"><span class="rdt-resize-grip" aria-hidden="true"></span></button>
        </span></th>
    </tr>
    
    <tr v-if="hasAnyFilterableColumn()" class="rdt-filter-row">
      <th v-for="header in headerGroups[headerGroups.length - 1].headers" :key="header.id" class="rdt-filter-cell" role="presentation" :style="pinStyle(header.column.id)">
        <span v-if="isSelectColumn(header.column.id)" style="display:contents"></span><span v-else-if="isExpanderColumn(header.column.id)" style="display:contents"></span><span v-else style="display:contents">
          <input v-if="columnIsFilterable(header.column.id) && !hasFilterSlot()" class="rdt-col-filter" type="text" :aria-label="'Filter ' + headerLabel(header.column.id)" :value="columnFilterValue(header.column.id)" @input="onColumnFilterInput(header.column.id, $event)" @click="stopEvent($event)" /><span v-if="columnIsFilterable(header.column.id)" style="display:contents">
            <slot name="filter" :columnId="header.column.id" :value="columnFilterValue(header.column.id)" :uniqueValues="getFacetedUniqueValues(header.column.id)" :minMax="getFacetedMinMaxValues(header.column.id)" :setFilter="setColumnFilter"></slot>
          </span></span></th>
    </tr></thead>

  <tbody class="rdt-tbody" role="rowgroup">
    
    <tr class="rdt-spacer" aria-hidden="true">
      <td :colspan="(visibleColCount()) ?? undefined" :style="'height:' + padTop() + 'px;padding:0;border:0'"></td>
    </tr>
    
    <template v-for="wr in windowedRows()" :key="wr.row.id">
    <tr :class="['rdt-tr', { 'rdt-group-header': rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned }]" role="row" :data-row="wr.vi.index" :aria-rowindex="headerRowCount() + wr.vi.index + 1" :data-index="wr.vi.index" :data-pinned="wr.pinned ? 'true' : undefined" :data-depth="wr.row.depth" :data-group-header="rowIsGrouped(wr.row) ? wr.row.id : undefined" :data-group-leaf="groupingActive() && !rowIsGrouped(wr.row) ? wr.row.id : undefined" :aria-expanded="(rowIsGrouped(wr.row) ? !!rowIsExpanded(wr.row) : undefined) ?? undefined" :aria-selected="(props.selectionMode !== 'none' ? !!rowIsSelected(wr.row) : undefined) ?? undefined" :aria-level="(groupingActive() ? wr.row.depth + 1 : undefined) ?? undefined">
      <td v-for="cell in visibleCellsFor(wr.row)" :key="cell.id" :class="['rdt-td', { 'rdt-select-td': isSelectColumn(cell.column.id), 'rdt-expander-td': isExpanderColumn(cell.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cell)), 'rdt-cell-active': isActiveCell(String(wr.vi.index), colIndexOf(wr.row, cell)) }]" :role="cellRole()" :data-col="cell.column.id" data-grid-cell="" :data-row="wr.vi.index" :data-col-index="colIndexOf(wr.row, cell)" :tabindex="(cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cell))) ?? undefined" :style="bodyCellStyle(wr.row, cell.column.id)" :aria-invalid="(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cell))) ?? undefined" :data-in-range="inRange(wr.vi.index, colIndexOf(wr.row, cell)) ? 'true' : undefined" :data-agg-cell="cellIsAggregated(cell) ? cell.column.id : undefined">
        
        <span v-if="isExpanderColumn(cell.column.id)" style="display:contents">
          <button v-if="rowCanExpand(wr.row)" type="button" class="rdt-expander" data-expander="" :aria-expanded="!!rowIsExpanded(wr.row)" :aria-label="rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row'" @click="onToggleExpand(wr.row, $event)">{{ rowIsExpanded(wr.row) ? '▾' : '▸' }}</button></span><span v-else-if="isSelectColumn(cell.column.id)" style="display:contents">
          <slot name="selectCell" :row="wr.row.original" :checked="rowIsSelected(wr.row)" :toggle="e => onToggleRow(wr.row, e)">
            <input class="rdt-select-row" type="checkbox" aria-label="Select row" :checked="rowIsSelected(wr.row)" @change="onToggleRow(wr.row, $event)" />
          </slot>
        </span><span v-else-if="cellIsGrouped(cell)" style="display:contents">
          <button type="button" class="rdt-expander rdt-group-toggle" data-expander="" :aria-expanded="!!rowIsExpanded(wr.row)" :aria-label="rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group'" @click="onToggleExpand(wr.row, $event)">{{ rowIsExpanded(wr.row) ? '▾' : '▸' }}</button>
          <span class="rdt-group-value">
            <slot name="cell" :columnId="cell.column.id" :column="cell.column" :row="wr.row.original" :value="cell.getValue()">{{ cell.getValue() }}</slot>
          </span>
          <span class="rdt-group-count">{{ '(' + groupSubRowCount(wr.row) + ')' }}</span>
        </span><span v-else-if="isEditing(wr.vi.index, colIndexOf(wr.row, cell))" style="display:contents">
          <span v-if="hasEditorSlot(cell.column.id)" style="display:contents">
            <slot name="editor" :columnId="cell.column.id" :column="cell.column" :row="wr.row.original" :value="editorValueFor(cell.column.id)" :commit="editorCommitFor(cell.column.id)" :cancel="editorCancelFor()" :autofocus="editorAutofocusFor(cell.column.id, wr.vi.index)"></slot>
          </span><input v-else-if="editorTypeOf(cell.column.id) === 'number'" class="rdt-cell-editor" type="number" data-editing-cell="" :value="editorValueFor(cell.column.id)" @input="onCellEditorInput(cell.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><select v-else-if="editorTypeOf(cell.column.id) === 'select'" class="rdt-cell-editor" data-editing-cell="" :value="editorValueFor(cell.column.id)" @change="onCellEditorInput(cell.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)">
            <option v-for="opt in editorOptionsOf(cell.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
          </select><input v-else-if="editorTypeOf(cell.column.id) === 'checkbox'" class="rdt-cell-editor" type="checkbox" data-editing-cell="" :checked="editorCheckedFor(cell.column.id)" @change="onCellEditorCheckbox(cell.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><input v-else class="rdt-cell-editor" type="text" data-editing-cell="" :value="editorValueFor(cell.column.id)" @input="onCellEditorInput(cell.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /></span><span v-else-if="cellIsPlaceholder(cell)" style="display:contents"></span><span v-else class="rdt-cell-value">
          <slot name="cell" :columnId="cell.column.id" :column="cell.column" :row="wr.row.original" :value="cell.getValue()">{{ cell.getValue() }}</slot>
        </span><span v-if="isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cell))" class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" @pointerdown="onFillHandlePointerDown($event)"></span></td>
    </tr>
    
    <tr v-if="rowShowsDetail(wr.row)" class="rdt-detail-row" role="row" :data-detail-row="wr.row.id">
      <td class="rdt-detail-cell" :colspan="(visibleColCount()) ?? undefined">
        <slot name="detail" :row="wr.row.original"></slot>
      </td>
    </tr></template>
    
    <tr class="rdt-spacer" aria-hidden="true">
      <td :colspan="(visibleColCount()) ?? undefined" :style="'height:' + padBottom() + 'px;padding:0;border:0'"></td>
    </tr>
  </tbody>
</table>
</div><table v-else :class="['rozie-data-table', { 'rdt-sticky': props.stickyHeader }]" :role="tableRole()" :aria-rowcount="(gridAriaRowCount()) ?? undefined" @keydown="onGridKeyDown($event)" @focusin="syncActiveFromEvent($event)" @focusout="onGridFocusOut($event)" @mousedown="onGridMouseDown($event)" @dblclick="onGridDblClick($event)" @click="onGridClick($event)">
  <thead class="rdt-thead" role="rowgroup">
    <tr v-for="(hg, hgLevel) in headerGroups" :key="hg.id" class="rdt-tr" role="row" :aria-rowindex="hgLevel + 1">
      <th v-for="header in hg.headers" :key="header.id" :class="['rdt-th', { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-expander-th': isExpanderColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg, header), hgLevel) }]" role="columnheader" :data-col="header.column.id" data-grid-cell="" data-row="__header" :data-header-level="hgLevel" :colspan="(header.colSpan > 1 ? header.colSpan : undefined) ?? undefined" :data-col-index="headerColIndexOf(hg, header)" :tabindex="(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel)) ?? undefined" :aria-sort="ariaSortFor(header.column.id)" :style="thStyle(header.column.id)">
        
        
        <span v-if="isSelectColumn(header.column.id)" style="display:contents">
          <slot name="selectAll" :checked="isAllRowsSelected()" :indeterminate="isSomeRowsSelected()" :toggle="onToggleAllRows">
            
            <input v-if="props.selectionMode === 'multiple'" class="rdt-select-all" type="checkbox" aria-label="Select all rows" :checked="isAllRowsSelected()" @change="onToggleAllRows($event)" /></slot>
        </span><span v-else-if="isExpanderColumn(header.column.id)" style="display:contents"></span><span v-else style="display:contents">
          
          <button v-if="header.column.getCanSort && header.column.getCanSort()" type="button" class="rdt-sort-btn" @click="onHeaderSort(header.column.id, $event)">
            
            <span class="rdt-header-label">
              <slot name="colHeader" :columnId="header.column.id" :column="header.column" :label="headerLabel(header.column.id)">{{ headerLabel(header.column.id) }}</slot>
            </span>
            <span class="rdt-sort-ind" aria-hidden="true">{{ sortIndicator(header.column.id) }}</span>
          </button><span v-else style="display:contents">
            <span class="rdt-header-label">
              <slot name="colHeader" :columnId="header.column.id" :column="header.column" :label="headerLabel(header.column.id)">{{ headerLabel(header.column.id) }}</slot>
            </span>
          </span><Popover trigger="click" placement="bottom-end" strategy="fixed" :offset="4"><template #anchor>
              <button type="button" class="rdt-col-menu-trigger" :aria-label="'Column options for ' + headerLabel(header.column.id)">⋯</button>
            </template><div class="rdt-col-menu" role="menu">
              <button type="button" role="menuitem" class="rdt-col-menu-item" :aria-pressed="columnPinSide(header.column.id) === 'left'" @click="onPinColumn(header.column.id, 'left', $event)">Pin left</button>
              <button type="button" role="menuitem" class="rdt-col-menu-item" :aria-pressed="columnPinSide(header.column.id) === 'right'" @click="onPinColumn(header.column.id, 'right', $event)">Pin right</button>
              <button type="button" role="menuitem" class="rdt-col-menu-item" :aria-pressed="!columnPinSide(header.column.id)" @click="onPinColumn(header.column.id, false, $event)">Unpin</button>
              <hr class="rdt-col-menu-sep" />
              <button type="button" role="menuitem" class="rdt-col-menu-item" @click="onHideColumn(header.column.id, $event)">Hide column</button>
            </div></Popover>
          
          <button type="button" class="rdt-resize-handle" :aria-label="'Resize ' + headerLabel(header.column.id)" @pointerdown="onResizeStart(header.column.id, $event)" @touchstart="onResizeStart(header.column.id, $event)"><span class="rdt-resize-grip" aria-hidden="true"></span></button>
        </span></th>
    </tr>
    
    <tr v-if="hasAnyFilterableColumn()" class="rdt-filter-row">
      <th v-for="header in headerGroups[headerGroups.length - 1].headers" :key="header.id" class="rdt-filter-cell" role="presentation" :style="pinStyle(header.column.id)">
        <span v-if="isSelectColumn(header.column.id)" style="display:contents"></span><span v-else-if="isExpanderColumn(header.column.id)" style="display:contents"></span><span v-else style="display:contents">
          <input v-if="columnIsFilterable(header.column.id) && !hasFilterSlot()" class="rdt-col-filter" type="text" :aria-label="'Filter ' + headerLabel(header.column.id)" :value="columnFilterValue(header.column.id)" @input="onColumnFilterInput(header.column.id, $event)" @click="stopEvent($event)" /><span v-if="columnIsFilterable(header.column.id)" style="display:contents">
            <slot name="filter" :columnId="header.column.id" :value="columnFilterValue(header.column.id)" :uniqueValues="getFacetedUniqueValues(header.column.id)" :minMax="getFacetedMinMaxValues(header.column.id)" :setFilter="setColumnFilter"></slot>
          </span></span></th>
    </tr></thead>

  <tbody class="rdt-tbody" role="rowgroup">
    
    <template v-for="row in rows" :key="row.id">
    <tr :class="['rdt-tr', { 'rdt-group-header': rowIsGrouped(row) }]" role="row" :data-depth="row.depth" :aria-rowindex="(bodyAriaRowIndex(row)) ?? undefined" :data-group-header="rowIsGrouped(row) ? row.id : undefined" :data-group-leaf="groupingActive() && !rowIsGrouped(row) ? row.id : undefined" :aria-expanded="(rowIsGrouped(row) ? !!rowIsExpanded(row) : undefined) ?? undefined" :aria-selected="(props.selectionMode !== 'none' ? !!rowIsSelected(row) : undefined) ?? undefined" :aria-level="(groupingActive() ? row.depth + 1 : undefined) ?? undefined">
      <td v-for="cell in visibleCellsFor(row)" :key="cell.id" :class="['rdt-td', { 'rdt-select-td': isSelectColumn(cell.column.id), 'rdt-expander-td': isExpanderColumn(cell.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cell)), 'rdt-cell-active': isActiveCell(String(rowIndexOf(row)), colIndexOf(row, cell)) }]" :role="cellRole()" :data-col="cell.column.id" data-grid-cell="" :data-row="rowIndexOf(row)" :data-col-index="colIndexOf(row, cell)" :tabindex="(cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cell))) ?? undefined" :style="bodyCellStyle(row, cell.column.id)" :aria-invalid="(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cell))) ?? undefined" :data-in-range="inRange(rowIndexOf(row), colIndexOf(row, cell)) ? 'true' : undefined" :data-agg-cell="cellIsAggregated(cell) ? cell.column.id : undefined">
        
        <span v-if="isExpanderColumn(cell.column.id)" style="display:contents">
          <button v-if="rowCanExpand(row)" type="button" class="rdt-expander" data-expander="" :aria-expanded="!!rowIsExpanded(row)" :aria-label="rowIsExpanded(row) ? 'Collapse row' : 'Expand row'" @click="onToggleExpand(row, $event)">{{ rowIsExpanded(row) ? '▾' : '▸' }}</button></span><span v-else-if="isSelectColumn(cell.column.id)" style="display:contents">
          <slot name="selectCell" :row="row.original" :checked="rowIsSelected(row)" :toggle="e => onToggleRow(row, e)">
            <input class="rdt-select-row" type="checkbox" aria-label="Select row" :checked="rowIsSelected(row)" @change="onToggleRow(row, $event)" />
          </slot>
        </span><span v-else-if="cellIsGrouped(cell)" style="display:contents">
          <button type="button" class="rdt-expander rdt-group-toggle" data-expander="" :aria-expanded="!!rowIsExpanded(row)" :aria-label="rowIsExpanded(row) ? 'Collapse group' : 'Expand group'" @click="onToggleExpand(row, $event)">{{ rowIsExpanded(row) ? '▾' : '▸' }}</button>
          <span class="rdt-group-value">
            <slot name="cell" :columnId="cell.column.id" :column="cell.column" :row="row.original" :value="cell.getValue()">{{ cell.getValue() }}</slot>
          </span>
          <span class="rdt-group-count">{{ '(' + groupSubRowCount(row) + ')' }}</span>
        </span><span v-else-if="isEditing(rowIndexOf(row), colIndexOf(row, cell))" style="display:contents">
          <span v-if="hasEditorSlot(cell.column.id)" style="display:contents">
            <slot name="editor" :columnId="cell.column.id" :column="cell.column" :row="row.original" :value="editorValueFor(cell.column.id)" :commit="editorCommitFor(cell.column.id)" :cancel="editorCancelFor()" :autofocus="editorAutofocusFor(cell.column.id, rowIndexOf(row))"></slot>
          </span><input v-else-if="editorTypeOf(cell.column.id) === 'number'" class="rdt-cell-editor" type="number" data-editing-cell="" :value="editorValueFor(cell.column.id)" @input="onCellEditorInput(cell.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><select v-else-if="editorTypeOf(cell.column.id) === 'select'" class="rdt-cell-editor" data-editing-cell="" :value="editorValueFor(cell.column.id)" @change="onCellEditorInput(cell.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)">
            <option v-for="opt in editorOptionsOf(cell.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
          </select><input v-else-if="editorTypeOf(cell.column.id) === 'checkbox'" class="rdt-cell-editor" type="checkbox" data-editing-cell="" :checked="editorCheckedFor(cell.column.id)" @change="onCellEditorCheckbox(cell.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><input v-else class="rdt-cell-editor" type="text" data-editing-cell="" :value="editorValueFor(cell.column.id)" @input="onCellEditorInput(cell.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /></span><span v-else-if="cellIsPlaceholder(cell)" style="display:contents"></span><span v-else class="rdt-cell-value">
          <slot name="cell" :columnId="cell.column.id" :column="cell.column" :row="row.original" :value="cell.getValue()">{{ cell.getValue() }}</slot>
        </span><span v-if="isFillHandleCell(rowIndexOf(row), colIndexOf(row, cell))" class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" @pointerdown="onFillHandlePointerDown($event)"></span></td>
    </tr>
    
    <tr v-if="rowShowsDetail(row)" class="rdt-detail-row" role="row" :data-detail-row="row.id">
      <td class="rdt-detail-cell" :colspan="(visibleColCount()) ?? undefined">
        <slot name="detail" :row="row.original"></slot>
      </td>
    </tr></template>
  </tbody>
</table><div v-if="!props.virtual" class="rdt-pagination" role="group" aria-label="Pagination">
  <button type="button" class="rdt-page-btn rdt-page-prev" :disabled="!canPrevPage()" @click="onPrevPage()">Prev</button>
  <span class="rdt-page-status" aria-live="polite">
    {{ 'Page ' + (pageIndex() + 1) + ' of ' + displayPageCount() }}
  </span>
  <button type="button" class="rdt-page-btn rdt-page-next" :disabled="!canNextPage()" @click="onNextPage()">Next</button>
  <select class="rdt-page-size" aria-label="Rows per page" :value="pageSize()" @change="onPageSizeChange($event)">
    <option :value="10">10</option>
    <option :value="25">25</option>
    <option :value="50">50</option>
    <option :value="100">100</option>
  </select>
</div></div>

</template>

<script setup lang="ts">
import Popover from '@rozie-ui/popover-vue';

import { onBeforeUnmount, onMounted, onUpdated, provide, ref, useSlots, watch } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * Config-array column fallback (lower precedence than `<Column>` children). Each entry: `{ id?, field, header?, sortable?, filterable?, pinned?, width? }`. Columns may come from this array, from `<Column>` children, or both (id-keyed last-write-wins union).
     */
    columns?: any[];
    /**
     * Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
     */
    selectionMode?: string;
    /**
     * Server-side hook: sets `manualPagination` / `manualFiltering` / `manualSorting` so table-core trusts the consumer-supplied rows and only emits the change events (the consumer fetches each page).
     */
    manual?: boolean;
    /**
     * Total server-side row count for `manual` pagination; lets the table compute page count when it doesn't hold the full dataset.
     */
    rowCount?: number | null;
    /**
     * Explicit total page count for `manual` pagination; overrides rowCount-derived count.
     */
    pageCount?: number | null;
    /**
     * Opt-in **expandable rows**. When `true`, a leading chevron expander column auto-injects (after the select column) and `getExpandedRowModel` activates; default `false` is byte-identical-off. Every row can expand to reveal a `#detail` panel unless `getSubRows` is supplied (then only rows with children expand). Bind `:expandable="true"` (a bare attr only coerces on Vue+Lit).
     */
    expandable?: boolean;
    /**
     * Table-level child-row accessor `(originalRow, index) => TData[] | undefined` that drives nested sub-rows. When supplied (with `expandable`), table-core flattens the hierarchy and the expand seam reveals depth-indented child rows. Null → the `#detail` scoped slot is the expand mode.
     */
    getSubRows?: ((...args: any[]) => any) | null;
    /**
     * Opt-in gate for the **headless `#groupBar`** host region. Default `false` is byte-identical-off. `getGroupedRowModel` is wired unconditionally (inert when `grouping` is empty), so grouping is driven by the `grouping` model; this flag only gates the consumer-facing group-bar surface (the component ships **no** built-in drag UI).
     */
    groupable?: boolean;
    /**
     * Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
     */
    stickyHeader?: boolean;
    /**
     * `'table'` (default, row-oriented, byte-behaviorally identical to a plain accessible table) | `'grid'` (GA since Phase 63) — lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)**: `role="grid"`, a roving single tab-stop, 2-D APG arrow-key cell navigation, range selection, and clipboard support.
     */
    interactionMode?: string;
    /**
     * Grid mode only. When `true`, a plain click on an **editable** cell opens its editor immediately (single-click-to-edit) instead of just activating the cell. Default `false` keeps click-to-activate (double-click opens the editor). Shift+click (range selection) and clicks on non-editable cells are unaffected.
     */
    singleClickEdit?: boolean;
    /**
     * Grid mode. When `true`, every committed data mutation (cell/row edit, paste, fill, cut, clear) becomes one undo step: Ctrl/Cmd+Z undoes, Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z redoes. Default `false` records no history and Ctrl+Z/Y are inert.
     */
    undoable?: boolean;
    /**
     * The maximum number of undo steps retained (oldest evicted past this depth). Only consulted when `undoable` is `true`.
     */
    undoLimit?: number;
    /**
     * Opt-in vertical **row windowing**. When `true`, only the visible slice of rows renders inside a bounded `rdt-scroll` container (with leading/trailing spacer rows preserving total scroll height), windowing over the full filtered + sorted (pre-pagination) model and suppressing the client pagination chrome. Default `false` is byte-identical to a non-virtual table.
     */
    virtual?: boolean;
    /**
     * Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
     */
    estimateRowHeight?: number;
    /**
     * A CSS length string bounding the `rdt-scroll` container when `virtual` is on (e.g. `'400px'`). Mirrored to the `--rozie-data-table-max-height` custom property; the prop wins, the token is the fallback.
     */
    maxHeight?: string;
  }>(),
  { columns: () => [], selectionMode: 'none', manual: false, rowCount: null, pageCount: null, expandable: false, getSubRows: null, groupable: false, stickyHeader: false, interactionMode: 'table', singleClickEdit: false, undoable: false, undoLimit: 100, virtual: false, estimateRowHeight: 40, maxHeight: '' }
);

/**
 * The row data — `model: true`, so a committed cell/row edit writes a **fresh** array back through `r-model:data` (uncontrolled fallback `dataDefault`). A stable reference per Rozie's setup-once model — fed directly into table-core (never map/cloned in the watcher).
 * @example
 * <DataTable r-model:data="rows" :columns="cols" />
 */
const data = defineModel<any[]>('data', { required: true });
/**
 * `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
 */
const sorting = defineModel<any[]>('sorting', { default: () => [] });
/**
 * The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
 */
const globalFilter = defineModel<string>('globalFilter', { default: '' });
/**
 * `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
 */
const columnFilters = defineModel<any[]>('columnFilters', { default: () => [] });
/**
 * `{ pageIndex, pageSize }`. Defaults to `{ pageIndex: 0, pageSize: 10 }`; feeds the prev/next + page-size chrome (and `getPaginationRowModel()`). Two-way: funnels a fresh object through `page-change`.
 */
const pagination = defineModel<Record<string, any>>('pagination', { default: () => ({
  pageIndex: 0,
  pageSize: 10
}) });
/**
 * `ExpandedState` — `{ [rowId]: true }`, or the `true` literal after `expandAll` (declared `type: [Object, Boolean]`). Multi-expand (multiple rows open at once). Surfaces through `expand-change`; uncontrolled fallback (`$data.expandedDefault`) when unbound — the default is `null` so the uncontrolled fallback AND the grouping auto-expand default are reachable (a non-null default would short-circuit them). When grouping is active and `expanded` is untouched, group subtrees auto-expand.
 */
const expanded = defineModel<Record<string, any> | boolean | null>('expanded', { default: null });
/**
 * `GroupingState` — an ordered `string[]` of column ids (multi-column → nested groups, e.g. `['region','category']`). An empty/unbound list is ungrouped (byte-identical-off). Group-header rows are collapsible (they ride the expand model). Surfaces through `group-change`; uncontrolled fallback (`$data.groupingDefault`, default `[]`) when unbound — the default is `null` (mirroring `expanded`) so the uncontrolled fallback is reachable and the grouping auto-expand default can activate when a consumer applies grouping without binding `r-model:grouping` (a non-null `[]` default would short-circuit it). All reads are null-guarded, so table-core still receives an array.
 */
const grouping = defineModel<any[] | null>('grouping', { default: null });
/**
 * `RowSelectionState` — `{ [rowId]: true }`. Checkbox-only toggle (the row body does not select). Driven by the `selectionMode` chrome. Two-way: fires `selection-change` regardless of binding.
 */
const rowSelection = defineModel<Record<string, any>>('rowSelection', { default: () => ({}) });
/**
 * `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
 */
const columnVisibility = defineModel<Record<string, any>>('columnVisibility', { default: () => ({}) });
/**
 * `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
 */
const columnSizing = defineModel<Record<string, any>>('columnSizing', { default: () => ({}) });
/**
 * `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
 */
const columnOrder = defineModel<any[]>('columnOrder', { default: () => [] });
/**
 * `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
 */
const columnPinning = defineModel<Record<string, any>>('columnPinning', { default: () => ({
  left: [],
  right: []
}) });

const emit = defineEmits<{
  'sort-change': [...args: any[]];
  'expand-change': [...args: any[]];
  'group-change': [...args: any[]];
  'filter-change': [...args: any[]];
  'page-change': [...args: any[]];
  'selection-change': [...args: any[]];
  'visibility-change': [...args: any[]];
  'resize-change': [...args: any[]];
  'reorder-change': [...args: any[]];
  'pin-change': [...args: any[]];
  'history-change': [...args: any[]];
  'activecell-change': [...args: any[]];
  'range-change': [...args: any[]];
  'cell-edit-commit': [...args: any[]];
  'row-edit-commit': [...args: any[]];
}>();

defineSlots<{
  default(props: {  }): any;
  groupBar(props: { grouping: any; groupableColumns: any; applyGrouping: any; clearGrouping: any }): any;
  selectAll(props: { checked: any; indeterminate: any; toggle: any }): any;
  colHeader(props: { columnId: any; column: any; label: any }): any;
  colHeader(props: { columnId: any; column: any; label: any }): any;
  filter(props: { columnId: any; value: any; uniqueValues: any; minMax: any; setFilter: any }): any;
  selectCell(props: { row: any; checked: any; toggle: any }): any;
  cell(props: { columnId: any; column: any; row: any; value: any }): any;
  editor(props: { columnId: any; column: any; row: any; value: any; commit: any; cancel: any; autofocus: any }): any;
  cell(props: { columnId: any; column: any; row: any; value: any }): any;
  detail(props: { row: any }): any;
  selectAll(props: { checked: any; indeterminate: any; toggle: any }): any;
  colHeader(props: { columnId: any; column: any; label: any }): any;
  colHeader(props: { columnId: any; column: any; label: any }): any;
  filter(props: { columnId: any; value: any; uniqueValues: any; minMax: any; setFilter: any }): any;
  selectCell(props: { row: any; checked: any; toggle: any }): any;
  cell(props: { columnId: any; column: any; row: any; value: any }): any;
  editor(props: { columnId: any; column: any; row: any; value: any; commit: any; cancel: any; autofocus: any }): any;
  cell(props: { columnId: any; column: any; row: any; value: any }): any;
  detail(props: { row: any }): any;
}>();

const slots = useSlots();

const dataDefault = ref<any[]>([]);
const sortingDefault = ref<any[]>([]);
const globalFilterDefault = ref('');
const columnFiltersDefault = ref<any[]>([]);
const paginationDefault = ref({
  pageIndex: 0,
  pageSize: 10
});
const rowSelectionDefault = ref({});
const expandedDefault = ref({});
const groupingDefault = ref<any[]>([]);
const columnVisibilityDefault = ref({});
const columnSizingDefault = ref({});
const columnOrderDefault = ref<any[]>([]);
const columnPinningDefault = ref({
  left: [],
  right: []
});
const columnSizingInfo = ref({
  startOffset: null,
  startSize: null,
  deltaOffset: null,
  deltaPercentage: null,
  isResizingColumn: false,
  columnSizingStart: []
});
const colReg = ref({});
const rows = ref<any[]>([]);
const headerGroups = ref<any[]>([]);
const rowModelVer = ref(0);
const windowVer = ref(0);
const activeRow = ref(0);
const activeColIndex = ref(0);
const activeIsHeader = ref(false);
const activeHeaderLevel = ref(0);
const activeInControl = ref(false);
const editingRow = ref(-1);
const editingCol = ref(-1);
const draftValue = ref<any>(null);
const invalidMsg = ref('');
const editVer = ref(0);
const editFocusColId = ref<any>(null);
const editingRowIndex = ref<any>(null);
const rowDraft = ref({});
const rangeAnchor = ref<any>(null);
const rangeFocus = ref<any>(null);
const pasteAnnounce = ref('');
const liveAnnounce = ref('');

const __rozieRootRef = ref<HTMLElement>();

import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
let table: any = null;
// ── Vertical row windowing instance state (phase 53) ──────────────────────────────────
// Mutable top-level instances (the `let table` precedent — React hoists to useRef; do NOT
// const). NULL until $onMount, and ONLY constructed when $props.virtual. virtualizerCleanup
// holds the _didMount() teardown for $onUnmount; gridScrollEl is the captured .rdt-scroll div
// the virtualizer observes.
let virtualizer: any = null;
let virtualizerCleanup: any = null;
let gridScrollEl: any = null;
// CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
// rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
// pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
// callbacks were what broke the Solid scroll-then-focus seam (D-12 focusActiveCell →
// scrollToIndex → double-rAF focus): a stray remeasure firing inside that focus deferral
// disrupted the focus landing. The sweep ALSO bails while virtual-core is mid-scroll
// (virtualizer.isScrolling), so a measure can't run during scrollToIndex; the next settled
// onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
// once motion settles between scroll steps) is unaffected.
let remeasurePending = false;
// ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
// Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
// via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
// later change is a one-line edit.
const GRID_PAGE_STEP = 10;
// The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
// $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
// shadow-safe because the query runs from INSIDE the component's own scope (the listbox
// querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
// computed/template binding (ROZ123).
let gridRoot: any = null;
// Echo-guard: while WE are writing a slice back, the re-feed watcher must not re-enter
// the funnel. A counter (not a boolean) so nested writes are safe.
let programmatic = 0;
// Focus-intent epoch (#9) — a monotonic counter bumped at every focus-INTENT entry point
// (focusActiveCell / focusCell+focusAbsCellWhenReady arm / a genuine active-cell-moving
// focusin in syncActiveFromEvent). The two async focus-recovery polls (focusWhenReady for the
// virtual off-window scroll, focusAbsCellWhenReady for the paginated page-switch) CAPTURE this
// value at arm time (AFTER their own bump) and abort at the top of each iteration if it has since
// changed — so a LATER user nav (ArrowKey / click) supersedes a stale poll instead of the poll
// yanking focus back frames later. A naive guardMoved "abort if focus moved" check is WRONG here:
// both polls arm while focus still sits on the OLD/being-left cell BY DESIGN (scroll-to /
// page-switch), so an epoch — bumped only by a NEWER intent — is the correct abort signal.
let focusIntentEpoch = 0;
// ── Grid-wide undo/redo (260709-8ct) — history STATE lives in top-level `let` (mirroring
// `programmatic` above), NOT $data: recording a snapshot on every keystroke must not trigger
// a reactive re-render. React hoists each to useRef. undoStack/redoStack hold `data` array
// REFERENCES (never deep copies — see undoHistory.rzts's header comment on the shared-row
// invariant). restoringHistory suppresses re-recording while an undo()/redo() replay is
// in flight.
//
// The external-swap history reset keys on data ORIGIN, not a timing window. Every internal
// writeback stamps its fresh `data` array with a durable, non-enumerable marker under
// DATA_WRITE_TOKEN_KEY (see writeData in writeFunnels.rzts); the reset (maybeClearHistoryOnExternal
// Swap, below) clears history ONLY when a newly-supplied `$props.data` carries no marker — it did
// not come from us, so it is a genuine external dataset swap. Presence of the marker ⟺ "descends
// from one of our writes", and it survives EVERYTHING that defeated the four flag/timer variants:
//   1. A raw-reference latch (`lastWrittenData === currentData()`) — Vue `reactive()` / Svelte 5
//      `$state` / Solid store re-wrap a written array in a NEW Proxy on its way back through props,
//      so `===` never holds. (A non-enumerable own PROPERTY, by contrast, is forwarded through
//      every target's reactive Proxy via `Reflect.get` — readable through the wrap.)
//   2. A single-consume boolean — the re-feed watch fires MULTIPLE times per write; the first pass
//      consumed the flag, a later pass wrongly cleared.
//   3. A content signature (`JSON.stringify`) — the watch can fire with a TRANSIENTLY STALE
//      `currentData()` mid-settle (Solid/Lit), a real-but-older value → false mismatch.
//   4. A deferred settle-window flag (rAF, then a 96ms macrotask) — a slow re-feed on a LARGE
//      controlled table OUTRAN the window (#8); no fixed timeout can be correct (re-feed latency
//      scales with dataset size).
// A STRING key (not a JS Symbol) is deliberate: it is stable BY VALUE on all six targets with ZERO
// caching, whereas a `Symbol()` needs a per-instance memo to hold one identity — and Lit lowers
// `$computed(() => Symbol())` to a plain getter that RE-MINTS the Symbol on every read, so writeData
// and the reset would stamp/read DIFFERENT symbols and the marker would never match. Non-enumerable
// → invisible to JSON.stringify / spread / Object.keys (the consumer's data stays clean); namespaced
// so a consumer array never collides.
const DATA_WRITE_TOKEN_KEY = '__rozieDataWriteToken';
let undoStack: unknown[] = [];
let redoStack: unknown[] = [];
let restoringHistory: boolean = false;
// Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
// has not bound `expanded` and has not yet toggled any group, group-header rows default to
// EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
// roundout-VR leaf-visible baseline). The FIRST group/row toggle sets this true (in
// writeExpanded), after which the user's expanded state wins. Stays false (untouched) on the
// non-grouping path → byte-identical-off (the `expanded` slice resolves to $data.expandedDefault
// exactly as before, both for the plain table AND the expandable-rows feature).
let expandedTouched = false;
// groupingActiveDefault(): is grouping currently engaged (a non-empty ordered key list)? Reads
// the same source order as currentState().grouping ($props.grouping ?? $data.groupingDefault) so
// the expanded auto-default below tracks the live grouping state on every target.
const groupingActiveDefault = () => ((grouping.value != null ? grouping.value : groupingDefault.value) || []).length > 0;
// effectiveColumnPinning(): the auto-injected select/expander chrome columns are a STRUCTURAL
// left-pinned rail — they ALWAYS lead the pinned-left group so the checkbox/chevron stay the
// leftmost body cells in EVERY case (fresh, pinned, AND grouped). Two forces would otherwise
// push a data column ahead of the checkbox:
//   1. Pinning — a consumer pinning `name` left makes it left-pinned; getVisibleCells() returns
//      [left-pinned, center, right-pinned], so an unpinned (center) checkbox renders AFTER it.
//   2. Grouping — table-core's groupedColumnMode defaults to 'reorder', which moves a grouped
//      column to the FRONT of the order, ahead of an unpinned center checkbox.
// Pinning the rail left beats BOTH: the left group always precedes the (grouped-reordered)
// center group. We prepend SELECT_COL_ID then EXPANDER_COL_ID (matching the tableColumns
// injection order [select, expander, ...userCols]) ahead of any consumer left-pins.
// REQUIRES: the chrome column defs carry an explicit `size` (columnBuilders.rzts) — pinStyle's
// sticky offset is col.getStart('left') = Σ preceding pinned SIZES, so a size-less chrome column
// (table-core's 150px default) would inflate every real pinned column's `left` and overlap.
// The consumer's columnPinning model never sees these ids: writeColumnPinning() strips them
// on the way back out (writeFunnels.rzts). Note: this ALWAYS-pin makes the default (no-pin)
// checkbox a sticky-left rail — an intentional baseline change (VR/snapshot baselines drift).
const effectiveColumnPinning = (): any => {
  const base = columnPinning.value != null ? columnPinning.value : columnPinningDefault.value;
  const rail: string[] = [];
  if (selectionEnabled()) rail.push(SELECT_COL_ID);
  if (props.expandable === true) rail.push(EXPANDER_COL_ID);
  if (rail.length === 0) return base;
  const left = base && base.left ? base.left : [];
  const deduped = left.filter((id: string) => id !== SELECT_COL_ID && id !== EXPANDER_COL_ID);
  return {
    ...base,
    left: rail.concat(deduped)
  };
};
// Assemble the live state object from bound r-model slices (?? uncontrolled fallback).
// All NINE slices are wired (each ?? its own $data.<slice>Default). table-core reads
// this whole object as `state`. Return type annotated `any`: the inferred object-literal
// type does not structurally match table-core's `Partial<TableState>` under the strict
// bundled-leaf tsc (the columnSizingInfo/pagination shapes widen to Record) — the
// runtime shape is correct; `any` sidesteps the over-strict structural check (the
// deferred-items strict-tsc #2 / leaf-output-strict-typecheck close).
const currentState = (): any => ({
  sorting: sorting.value != null ? sorting.value : sortingDefault.value,
  globalFilter: globalFilter.value != null ? globalFilter.value : globalFilterDefault.value,
  columnFilters: columnFilters.value != null ? columnFilters.value : columnFiltersDefault.value,
  pagination: pagination.value != null ? pagination.value : paginationDefault.value,
  rowSelection: rowSelection.value != null ? rowSelection.value : rowSelectionDefault.value,
  // expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
  // literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
  // guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
  // GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
  // `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
  // literal so the grouped subtree is visible by default; the first toggle latches
  // expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
  // unchanged → byte-identical-off (the table + the expandable-rows feature both keep
  // $data.expandedDefault).
  expanded: expanded.value != null ? expanded.value : groupingActiveDefault() && !expandedTouched ? true : expandedDefault.value,
  // grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
  // to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
  // is inert when this is empty (byte-identical-off, req-10).
  grouping: grouping.value != null ? grouping.value : groupingDefault.value,
  columnVisibility: columnVisibility.value != null ? columnVisibility.value : columnVisibilityDefault.value,
  columnSizing: columnSizing.value != null ? columnSizing.value : columnSizingDefault.value,
  columnOrder: columnOrder.value != null ? columnOrder.value : columnOrderDefault.value,
  columnPinning: effectiveColumnPinning(),
  // columnSizingInfo: table-core's transient resize-gesture state. We pass an
  // EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
  // `column.getIsResizing()` / `getResizeHandler()` read
  // `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
  // absent. Seed the default shape (matches table-core's
  // getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
  // every render. Not a two-way model slice (transient gesture state, not consumer
  // state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
  columnSizingInfo: columnSizingInfo.value
});
// The live row data (Phase 51 req-4): the bound `data` prop when controlled, else the
// uncontrolled $data.dataDefault fallback (mirrors currentState's per-slice ?? pattern).
// A committed edit funnels a FRESH array through writeData, which writes BOTH sinks; the
// re-feed sources here so editing works whether or not the consumer binds r-model:data.
const currentData = (): any => data.value != null ? data.value : dataDefault.value;
// Prototype-safe id-keyed column resolution (T-48-PP): the `:columns` config array is
// applied FIRST (lower precedence), then the <Column> registry OVERRIDES by id (LWW).
// byId is a null-prototype object so a consumer column id of "__proto__"/"constructor"
// cannot pollute Object.prototype. Returns the table-core ColumnDef[]. (No per-column
// render callbacks — cells render via the single #cell/#header scoped slot on this
// component, dispatched by columnId; <Column> carries metadata only.)
const isSafeKey = (k: any) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
// wrapAggregationFn (phase 50 req-5, D-05, threat T-50-04): resolve a per-column
// aggregationFn straight onto the ColumnDef (no component-side switch — RESEARCH
// anti-pattern). A built-in NAME string ('sum'/'min'/'max'/'extent'/'mean'/'median'/
// 'unique'/'uniqueCount'/'count') passes through verbatim — table-core resolves it from its
// built-in `aggregationFns` map. A CUSTOM function `(columnId, leafRows, childRows) => any`
// is DEFENSIVELY WRAPPED (the runValidator precedent): a consumer fn runs per group, so a
// throw is coerced to `undefined` and can never crash getGroupedRowModel (DoS guard).
// Anything else → undefined (no aggregation; the cell renders as a placeholder).
const wrapAggregationFn = (fn: any) => {
  if (typeof fn === 'string') return fn;
  if (typeof fn !== 'function') return undefined;
  return (columnId: any, leafRows: any, childRows: any) => {
    try {
      return fn(columnId, leafRows, childRows);
    } catch (err: any) {
      return undefined;
    }
  };
};
// Build the table-core ColumnDef for ONE config-array entry. A LEAF entry
// ({ id?, field, header?, … }) maps to an accessor ColumnDef; a GROUP entry
// ({ id?, header, columns: [...] }) maps to a multi-level header GROUP column
// whose children are built recursively (B12 — grouped/multi-level column headers).
// Returns null for an unusable entry (no id/field, unsafe key, empty group).
const buildConfigDef = (c: any) => {
  if (!c) return null;
  // Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
  // getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
  // header spans its leaf children (B12). The group id falls back to its header text so it
  // stays addressable (no accessor; group columns carry no data).
  if (Array.isArray(c.columns)) {
    const kids = [];
    for (const child of c.columns as any) {
      const cd = buildConfigDef(child);
      if (cd) kids.push(cd);
    }
    if (!kids.length) return null;
    // Group id: an explicit c.id wins. Otherwise synthesize a STABLE UNIQUE id from the child
    // column ids (which are unique per leaf accessor / recursively-synthesized per nested group)
    // — NOT the header text. Falling back to c.header collided two same-titled groups (e.g. both
    // "Details") into one by-id map key, so the columnDefs LWW merge silently dropped one whole
    // group column + its children. The child-id derivation is deterministic (stable across
    // renders — no Math.random/Date). A group with neither id nor header (nor derivable children)
    // stays dropped as before.
    let gid = c.id;
    if (gid == null) gid = c.header != null ? '__grp_' + kids.map((k: any) => k.id).join('_') : null;
    if (gid == null) return null;
    const id = String(gid);
    if (!isSafeKey(id)) return null;
    return {
      id,
      header: c.header != null ? c.header : id,
      columns: kids
    };
  }
  const rawId = c.id != null ? c.id : c.field;
  if (rawId == null) return null;
  const id = String(rawId);
  if (!isSafeKey(id)) return null;
  return {
    id,
    accessorKey: c.field != null ? c.field : id,
    header: c.header != null ? c.header : id,
    enableSorting: c.sortable === true,
    // per-column filter opt-in (req-5). table-core gates the filter input + value
    // funnel on enableColumnFilter; a column with filterable !== true cannot be
    // filtered (and renders no per-column filter input in the chrome below).
    enableColumnFilter: c.filterable === true,
    filterable: c.filterable === true,
    // Expandable-rows reserved per-column metadata (phase 50, D-04).
    expandable: c.expandable === true,
    // Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
    // so every data column is offered to the headless #groupBar by default; the per-column
    // aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
    // a custom fn defensively wrapped (T-50-04).
    groupable: c.groupable !== false,
    aggregationFn: wrapAggregationFn(c.aggregationFn),
    pinned: c.pinned != null ? c.pinned : '',
    width: c.width != null ? c.width : '',
    // Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
    // metadata carrier the display↔editor branch + runValidator read. Off by default.
    meta: {
      editable: c.editable === true,
      editor: c.editor != null ? c.editor : 'text',
      editorOptions: c.editorOptions != null ? c.editorOptions : [],
      validate: typeof c.validate === 'function' ? c.validate : null
    }
  };
};
const columnDefs = () => {
  const byId = Object.create(null);
  const order = [];
  const cfg = props.columns || [];
  for (const c of cfg as any) {
    const def = buildConfigDef(c);
    if (!def) continue;
    const id = def.id;
    if (!(id in byId)) order.push(id);
    byId[id] = def;
  }
  const reg = colReg.value || {};
  for (const id in reg) {
    if (!isSafeKey(id)) continue;
    const spec = reg[id];
    if (!spec) continue;
    if (!(id in byId)) order.push(id);
    byId[id] = {
      id,
      accessorKey: spec.field != null ? spec.field : id,
      header: spec.header != null ? spec.header : id,
      enableSorting: spec.sortable === true,
      enableColumnFilter: spec.filterable === true,
      filterable: spec.filterable === true,
      // Expandable-rows reserved per-column metadata (phase 50, D-04).
      expandable: spec.expandable === true,
      // Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
      groupable: spec.groupable !== false,
      aggregationFn: wrapAggregationFn(spec.aggregationFn),
      pinned: spec.pinned != null ? spec.pinned : '',
      width: spec.width != null ? spec.width : '',
      // Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
      meta: {
        editable: spec.editable === true,
        editor: spec.editor != null ? spec.editor : 'text',
        editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
        validate: typeof spec.validate === 'function' ? spec.validate : null
      }
    };
  }
  const out = [];
  for (const id of order as any) if (byId[id]) out.push(byId[id]);
  return out;
};
// The constant id of the auto-injected leading checkbox column (D-04). Distinct from
// any consumer column id (the registry/config guard never produces a leading "__").
const SELECT_COL_ID = '__rdt_select';
// The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
// Distinct from any consumer column id (the registry/config guard never produces a leading
// "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
const EXPANDER_COL_ID = '__rdt_expander';
// The table-core ColumnDef set actually fed to createTable / setOptions: the resolved
// user columns, PLUS a LEADING checkbox column when selectionMode is 'single' OR
// 'multiple' (D-04). The select column carries enableSorting/enableColumnFilter:false
// and an isSelectColumn marker the template uses to render checkbox chrome (NOT an
// accessor value). 'none' injects nothing. In 'single' mode the per-row checkbox
// renders but the select-all HEADER checkbox is suppressed (selecting a row caps at
// ≤1 via enableMultiRowSelection:false) — a single-select needs a per-row control,
// not a select-all, so without injecting the column single mode would expose NO
// selection UI at all.
const selectionEnabled = () => props.selectionMode === 'single' || props.selectionMode === 'multiple';
const tableColumns = () => {
  const cols = columnDefs();
  // Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
  // isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
  // value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
  // → byte-identical-off (req-10).
  let withExpander = cols;
  if (props.expandable === true) {
    const expanderCol = {
      id: EXPANDER_COL_ID,
      enableSorting: false,
      enableColumnFilter: false,
      filterable: false,
      isExpanderColumn: true,
      pinned: '',
      width: '',
      // Explicit narrow size so table-core's getSize()/getStart('left') match the RENDERED
      // width. Without it table-core assumes its 150px default, which is fine for an UNPINNED
      // chrome column (a CSS `width:1%` trick shrinks it visually) but breaks the moment the
      // column joins the left-pinned rail: pinStyle's sticky offset is Σ preceding pinned
      // SIZES, so a phantom 150px would push every real pinned column ~150px too far right and
      // overlap. Keep this in sync with the `--rdt-expander-col-width` CSS default (40px).
      size: 40
    };
    withExpander = [expanderCol].concat(cols);
  }
  if (selectionEnabled()) {
    const selectCol = {
      id: SELECT_COL_ID,
      enableSorting: false,
      enableColumnFilter: false,
      filterable: false,
      isSelectColumn: true,
      pinned: '',
      width: '',
      // Explicit narrow size so table-core's sticky-offset math (getStart('left')) matches the
      // rendered checkbox width once this column joins the left-pinned rail — see the expander
      // note above. Keep in sync with the `--rdt-select-col-width` CSS default (44px).
      size: 44
    };
    return [selectCol].concat(withExpander);
  }
  return withExpander;
};
// ── sorting slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<SortingState> = value | (old)=>new; the onSortingChange
// callback applies it against the CURRENT sorting, then this funnel writes a FRESH
// array to the uncontrolled default + the two-way model + fires the change event
// REGARDLESS of binding. STATIC key (`$data.sortingDefault` / `$model.sorting`) — a
// dynamic-key funnel is ROZ106 on all six. The remaining 8 slices each get their own
// such funnel in Plans 04/05.
const writeSorting = (next: any) => {
  if (programmatic) return;
  programmatic++;
  sortingDefault.value = next; // fresh array only (never in-place)
  sorting.value = next; // two-way emit if bound (no-op-diff if not)
  emit('sort-change', next);
  programmatic--;
};
const applyUpdater = (updater: any, current: any) => typeof updater === 'function' ? updater(current) : updater;
// ── expanded slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<ExpandedState> = value | (old)=>new; onExpandedChange
// applies it against the CURRENT expanded, then this funnel writes a FRESH value to the
// uncontrolled default + the two-way model + fires `expanded-change` REGARDLESS of binding.
// `next` may be the `true` expand-all literal OR a { [rowId]: true } object — written
// verbatim (Pitfall 2). One emit per change (the shared `programmatic` guard dedups the
// React multi-render re-entry, D-07). STATIC key ($data.expandedDefault / $model.expanded).
const writeExpanded = (next: any) => {
  if (programmatic) return;
  programmatic++;
  // Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
  // the user now owns the expanded state, so currentState() stops defaulting grouped rows to
  // the `true` expand-all literal and honors $data.expandedDefault from here on.
  expandedTouched = true;
  expandedDefault.value = next; // fresh value only (never in-place)
  expanded.value = next; // two-way emit if bound (no-op-diff if not)
  // Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
  // prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
  // interface, and an `expanded-change` event would camelCase to the SAME identifier
  // → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
  // sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
  // sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
  emit('expand-change', next);
  programmatic--;
};
// ── grouping slice: STATIC-KEY fresh-array echo-guarded write funnel (phase 50 reqs 4-7) ──
// table-core hands an Updater<GroupingState> = value | (old)=>new; onGroupingChange applies it
// against the CURRENT grouping, then this funnel writes a FRESH ordered array to the
// uncontrolled default + the two-way model + fires `group-change` REGARDLESS of binding. One
// emit per change (the shared `programmatic` guard dedups the React multi-render re-entry, D-07).
// STATIC key ($data.groupingDefault / $model.grouping). Event stem is `group-change`, NOT
// `grouping-change`: the model:true `grouping` prop auto-generates an `onGroupingChange` callback
// on the React/Solid flat Props interface, and a `grouping-change` event would camelCase to the
// SAME identifier → duplicate-identifier TS2300 (the model-prop==emit-name collision class 50-02
// hit with expanded/expanded-change → expand-change). Every sibling slice stems off a DISTINCT
// name (sorting→sort-change, rowSelection→selection-change); grouping→group-change follows suit.
const writeGrouping = (next: any) => {
  if (programmatic) return;
  programmatic++;
  groupingDefault.value = next; // fresh ordered array only (never in-place push)
  grouping.value = next; // two-way emit if bound (no-op-diff if not)
  emit('group-change', next);
  programmatic--;
};
// ── globalFilter slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────
// A fresh string (primitive) to the uncontrolled default + the two-way model + fires
// `filter-change` REGARDLESS of binding.
const writeGlobalFilter = (next: any) => {
  if (programmatic) return;
  programmatic++;
  globalFilterDefault.value = next;
  globalFilter.value = next;
  emit('filter-change', {
    globalFilter: next
  });
  programmatic--;
};
// ── columnFilters slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ─────
// table-core hands ColumnFiltersState = [{ id, value }]; write a FRESH array (never
// in-place push) + fire `filter-change`. globalFilter + columnFilters both surface
// through `filter-change` (per the plan: filter-change fires regardless of binding).
const writeColumnFilters = (next: any) => {
  if (programmatic) return;
  programmatic++;
  columnFiltersDefault.value = next;
  columnFilters.value = next;
  emit('filter-change', {
    columnFilters: next
  });
  programmatic--;
};
// ── pagination slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ───────
// table-core hands { pageIndex, pageSize }; write a FRESH object + fire `page-change`.
const writePagination = (next: any) => {
  if (programmatic) return;
  programmatic++;
  paginationDefault.value = next;
  pagination.value = next;
  emit('page-change', next);
  programmatic--;
};
// ── rowSelection slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands RowSelectionState = { [rowId]: true }; write a FRESH object (never
// in-place key-set) + fire `selection-change` REGARDLESS of binding.
const writeRowSelection = (next: any) => {
  if (programmatic) return;
  programmatic++;
  rowSelectionDefault.value = next;
  rowSelection.value = next;
  emit('selection-change', next);
  programmatic--;
};
// ── columnVisibility slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──
// table-core hands VisibilityState = { [colId]: boolean }; write a FRESH object (never
// in-place key-set) + fire `visibility-change` REGARDLESS of binding.
const writeColumnVisibility = (next: any) => {
  if (programmatic) return;
  programmatic++;
  columnVisibilityDefault.value = next;
  columnVisibility.value = next;
  emit('visibility-change', next);
  programmatic--;
};
// ── columnSizing slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────
// table-core hands ColumnSizingState = { [colId]: number }; the pointer-drag resize
// handle funnels a FRESH sizing object + fires `resize-change` REGARDLESS of binding.
const writeColumnSizing = (next: any) => {
  if (programmatic) return;
  programmatic++;
  columnSizingDefault.value = next;
  columnSizing.value = next;
  emit('resize-change', next);
  programmatic--;
};
// ── columnOrder slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ────────
// table-core hands ColumnOrderState = string[]; write a FRESH order array (never an
// in-place splice) + fire `reorder-change` REGARDLESS of binding.
const writeColumnOrder = (next: any) => {
  if (programmatic) return;
  programmatic++;
  columnOrderDefault.value = next;
  columnOrder.value = next;
  emit('reorder-change', next);
  programmatic--;
};
// ── columnPinning slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands ColumnPinningState = { left: string[], right: string[] }; write a
// FRESH object (never in-place push into left/right) + fire `pin-change` REGARDLESS of
// binding.
const writeColumnPinning = (next: any) => {
  if (programmatic) return;
  // effectiveColumnPinning() forces the auto-injected chrome ids (select/expander) into the
  // table-core `left` rail, so table-core hands them back here on every pin change. Strip them
  // before persisting: the CONSUMER's columnPinning model + the pin-change event must reflect
  // only their own columns, never our internal rail ids (which re-inject each render anyway).
  const strip = (ids: any) => (ids || []).filter((id: any) => id !== SELECT_COL_ID && id !== EXPANDER_COL_ID);
  const clean = {
    ...next,
    left: strip(next && next.left),
    right: strip(next && next.right)
  };
  programmatic++;
  columnPinningDefault.value = clean;
  columnPinning.value = clean;
  emit('pin-change', clean);
  programmatic--;
};
// ── data slice: STATIC-KEY fresh-array echo-guarded write funnel (Phase 51 req-4) ──
// A committed cell/row edit (or paste/fill in a later wave) replaces ONE row object in
// a FRESH array and funnels it here. Writes the uncontrolled default + the two-way
// model so editing works controlled OR uncontrolled. CRITICAL: writeData does NOT emit —
// unlike the 9 state slices (each has one change event fired inside its funnel), the
// `data` slice's commit event (`cell-edit-commit`) carries a PER-CELL payload and fires
// from the SINGLE commitEdit call site so the count stays exactly one per commit (React
// multi-emit dedup, D-07). Echo-guarded by the shared `programmatic` counter so the
// re-feed watch never re-enters mid-write.
//
// 260709-8ct (grid-wide undo/redo): record the PRE-mutation snapshot BEFORE writing, but
// ONLY when `$props.undoable` is on AND we are not mid-replay (`!restoringHistory` — an
// undo()/redo() call routes back through THIS SAME writeData to reuse the two-way model +
// re-feed watch; without the guard the replay would re-record itself and corrupt the
// stack). `emitHistoryChangeIfEdged` fires `history-change` only when canUndo/canRedo
// availability actually flipped (a long streak of edits that doesn't change availability
// must not spam consumers).
//
// External-swap origin marker: stamp EVERY array we write (undoable or not, incl. an undo/
// redo replay) with the durable, non-enumerable marker under DATA_WRITE_TOKEN_KEY. The reset
// (maybeClearHistoryOnExternalSwap in DataTable.rozie) clears history only when a new $props.data
// lacks the marker → it did not come from us → a genuine external swap. This replaces the
// `dataWriteSettling` settle-window flag that a slow re-feed on a large controlled table outran
// (#8) — see DATA_WRITE_TOKEN_KEY's declaration in DataTable.rozie for the four flag/timer
// variants it supersedes and why the marker is timing-independent. Stamped on the fresh `next`
// array (never the consumer's original), non-enumerable so JSON.stringify / spread / Object.keys
// never see it. We write a FRESH RAW shallow copy (`fresh`) so the marker lands on an UNWRAPPED
// array: an undo/redo replay reuses a snapshot that, in controlled mode, is a framework reactive
// PROXY (svelte `$state`, vue `reactive`) — and `Object.defineProperty` does NOT reliably stick
// through a proxy's trap, so stamping the raw copy (never the possibly-proxied `next`) keeps the
// marker readable. Normal edits already pass a fresh array; the copy shares row references (cheap).
// `try` guards the (never-expected) frozen/sealed-array case.
const writeData = (next: any) => {
  if (programmatic) return;
  if (props.undoable && !restoringHistory) {
    const prevU = canUndo();
    const prevR = canRedo();
    recordSnapshot(currentData());
    emitHistoryChangeIfEdged(prevU, prevR);
  }
  const fresh = Array.isArray(next) ? next.slice() : next;
  try {
    Object.defineProperty(fresh, DATA_WRITE_TOKEN_KEY, {
      value: true,
      enumerable: false,
      configurable: true,
      writable: true
    });
  } catch (_e: any) {/* a frozen/sealed array can't be stamped — our fresh arrays never are */}
  programmatic++;
  dataDefault.value = fresh; // fresh raw array only (never in-place, never a proxy)
  data.value = fresh; // two-way emit if bound (no-op-diff if not)
  programmatic--;
};
// Read the live columnFilters value for a given column id (string-safe; drives the
// per-column filter input's bound value). Reads currentState() (NOT a $data re-read
// of a just-written key → React stale-read safe).
const columnFilterValue = (colId: any) => {
  const cf = currentState().columnFilters || [];
  for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
  return '';
};
// Apply a per-column filter value: build a FRESH ColumnFiltersState array (drop the
// column's prior entry, append the new one unless empty) and funnel it. Never mutate
// the existing array in place (silent on React/Solid/Angular/Lit).
const setColumnFilter = (colId: any, value: any) => {
  const prev = currentState().columnFilters || [];
  const next = [];
  for (const f of prev as any) if (f && f.id !== colId) next.push(f);
  if (value != null && value !== '') next.push({
    id: colId,
    value
  });
  writeColumnFilters(next);
};
// ── grid-wide undo/redo (260709-8ct) — snapshot-stack history engine ──────────────────────
// Per the approved design (docs/superpowers/specs/2026-07-09-data-table-undo-history-design.md,
// decisions 1-6, LOCKED). A pure, unit-testable buffer over the FOUR history lets declared
// top-level in DataTable.rozie beside `let programmatic = 0` (undoStack/redoStack/
// restoringHistory/lastPropsData — NOT $data, so recording an edit causes no reactive
// re-render churn on every keystroke). This module holds the FUNCTIONS only; it references
// those component-scope lets + `$props`, `$emit`, `writeData`, `currentData` BARE (by name,
// zero ES imports) — the SAME inlined-partial pattern writeFunnels.rzts uses for
// `programmatic`/`$data`/`$model` (DataTable.rozie is the ONLY place that ES-imports across
// .rzts partials; a cross-import between writeFunnels and undoHistory would create an ES
// cycle and/or a TDZ on the inlined lets).
//
// Collision-safe (ROZ121/124/137): none of undo/redo/canUndo/canRedo/clearHistory are
// HTMLElement methods, model props, or React auto-generated setters.

// Push the PRE-mutation snapshot (a `data` array reference — never a deep copy; unchanged
// rows are shared across every retained snapshot because every write funnel already builds a
// fresh array reusing unchanged row references, per the design's Memory analysis). Evict the
// oldest snapshot once the stack exceeds `undoLimit` (default 100 — DataTable.rozie prop).
// Any NEW recording invalidates the redo stack (standard undo semantics).
const recordSnapshot = (current: any) => {
  undoStack.push(current);
  const limit = props.undoLimit != null ? props.undoLimit : 100;
  while (undoStack.length > limit) undoStack.shift();
  redoStack = [];
};
const canUndo = () => undoStack.length > 0;
const canRedo = () => redoStack.length > 0;
// Both stacks empty — the external-swap latch (DataTable.rozie reFeed) and the
// clearHistory() $expose verb share this single implementation.
const clearHistory = () => {
  undoStack = [];
  redoStack = [];
};
// `$emit('history-change', { canUndo, canRedo })` — the imperative/keyboard $expose verb
// contract. Unconditional (used by undo()/redo() themselves, which always fire exactly once
// per call per the design — NOT edge-gated there; only the writeData-triggered recording path
// below is edge-gated, since a routine sequence of edits would otherwise spam the event).
const emitHistoryChange = () => {
  emit('history-change', {
    canUndo: canUndo(),
    canRedo: canRedo()
  });
};
// Fire `history-change` ONLY when canUndo/canRedo availability flipped since `prevU`/`prevR`
// were captured (BEFORE recordSnapshot ran). Called from writeData's recording hook so a
// long streak of edits that doesn't change availability (canUndo already true, redo already
// empty) does not spam consumers with a no-op event per keystroke.
const emitHistoryChangeIfEdged = (prevU: any, prevR: any) => {
  const nextU = canUndo();
  const nextR = canRedo();
  if (nextU !== prevU || nextR !== prevR) emitHistoryChange();
};
// undo(): pop the most recent pre-mutation snapshot, push the CURRENT data onto the redo
// stack (so redo can restore it), then replay the popped snapshot through the SAME writeData
// seam — under `restoringHistory = true` so writeData's own recording hook does not
// re-capture this replay (which would corrupt the stack). Replaying through writeData
// (rather than writing $data/$model directly) is deliberate: the two-way $model.data
// writeback, the re-feed $watch, and the echo guard all keep working with zero new code.
const undo = () => {
  if (!canUndo()) return;
  const prev = undoStack.pop();
  redoStack.push(currentData());
  restoringHistory = true;
  writeData(prev);
  restoringHistory = false;
  emitHistoryChange();
};
// redo(): symmetric — pop the redo stack, push the CURRENT data back onto the undo stack,
// replay through the same guarded writeData seam.
const redo = () => {
  if (!canRedo()) return;
  const next = redoStack.pop();
  undoStack.push(currentData());
  restoringHistory = true;
  writeData(next);
  restoringHistory = false;
  emitHistoryChange();
};
// Re-read the row model + header groups into $data (fresh arrays → the template
// re-renders). A plain fn (NOT a $computed — getRowModel() must be pulled AFTER a
// setOptions re-feed, imperatively). Defined inside $onMount so it captures the live
// `table`.
let refreshRowModel: any = null;
// PER-SLICE callbacks hoisted to top-level consts (NOT inlined in createTable) so the
// re-feed $watch can re-pass them on every setOptions. On React the createTable
// callbacks would otherwise capture the MOUNT-render's currentState() closure (table
// instance is built once in $onMount); table-core's setOptions keeps the prior
// callbacks unless new ones are supplied, so a stale callback applied each updater
// against the mount-time empty slice → the sort cycle never advances + multi-row
// selection collapses to the last row (React stale-closure, F6). Re-passing these
// fresh (recreated each render on React, reading fresh currentState) in the re-feed
// keeps the Updater base value current. No-op cost on the other five.
const onSortingChangeCb = (updater: any) => {
  writeSorting(applyUpdater(updater, currentState().sorting));
};
const onExpandedChangeCb = (updater: any) => {
  writeExpanded(applyUpdater(updater, currentState().expanded));
};
const onGroupingChangeCb = (updater: any) => {
  writeGrouping(applyUpdater(updater, currentState().grouping));
};
const onGlobalFilterChangeCb = (updater: any) => {
  writeGlobalFilter(applyUpdater(updater, currentState().globalFilter));
};
const onColumnFiltersChangeCb = (updater: any) => {
  writeColumnFilters(applyUpdater(updater, currentState().columnFilters));
};
const onPaginationChangeCb = (updater: any) => {
  writePagination(applyUpdater(updater, currentState().pagination));
};
const onRowSelectionChangeCb = (updater: any) => {
  writeRowSelection(applyUpdater(updater, currentState().rowSelection));
};
const onColumnVisibilityChangeCb = (updater: any) => {
  writeColumnVisibility(applyUpdater(updater, currentState().columnVisibility));
};
const onColumnSizingChangeCb = (updater: any) => {
  writeColumnSizing(applyUpdater(updater, currentState().columnSizing));
};
const onColumnOrderChangeCb = (updater: any) => {
  writeColumnOrder(applyUpdater(updater, currentState().columnOrder));
};
const onColumnPinningChangeCb = (updater: any) => {
  writeColumnPinning(applyUpdater(updater, currentState().columnPinning));
};
const onColumnSizingInfoChangeCb = (updater: any) => {
  const next = applyUpdater(updater, columnSizingInfo.value);
  columnSizingInfo.value = next != null ? next : columnSizingInfo.value;
};
// ══ Vertical row windowing (phase 53, req-1/2/3/6/9/10) — the virtual-core bridge ════════
// virtual-core is a pure state machine EXACTLY like table-core: constructed once in $onMount
// (ONLY when $props.virtual), its imperative onChange push converted to per-target reactivity
// via the SEPARATE $data.windowVer tick, re-fed via setOptions()+_willUpdate() in the
// refreshRowModel path (NEVER a render helper — Pitfall 1). Every runtime reference is guarded
// so the virtual=false emitted path is dead (req-1).
//
// Phase 64 (D-04): the PURE windowing math (windowedRows / padTop / padBottom / pmIndexInWindow /
// rowIsOutsideWindow / virtualizerOptions / virtualItemKey) now lives in the shared, target-agnostic
// `@rozie-ui/headless-core/windowing.rzts` partial and is re-exported below — this file is now the
// thin DATA-TABLE HOST SHELL holding only the impure, per-consumer pieces (the table-bound row
// source + the DOM/refs/virtualizer-instance machinery + the D-05 edit-pinning hook). The math
// dissolves in via inlineScriptPartials() byte-identically; behavior is unchanged (the B13 specs +
// dist-parity are the net). The host satisfies the windowing.rzts contract by convention:
// windowSource() (the row source), pinnedEditIndex()/pinnedMeasurement() (the D-05 pin hook),
// scheduleRemeasure(), and the gridScrollEl/virtualizer/virtual-core-fn references.

// windowSource(): the rows fed to the virtualizer AND held in $data.rows — the windowing.rzts
// host-contract source. When virtual, the FULL filtered+sorted PRE-PAGINATION model
// (A2-verified table.getPrePaginationRowModel()) so windowing REPLACES client pagination (req-9);
// else the normal (paginated) row model — the non-virtual path is byte-unchanged.
const windowSource = () => {
  if (!table) return [];
  if (props.virtual) return table.getPrePaginationRowModel().rows;
  return table.getRowModel().rows;
};
// Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
// BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
// test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
// (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
// polls for its target cell, so it no longer depends on remeasure timing).
//
// TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
//   - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
//     tick that the windowVer bump triggers, so the recycled nodes already exist by the next
//     microtask — measuring there observes them while they are still connected, BEFORE the next
//     fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
//     fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
//     frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
//     total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
//   - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
//     committed yet), so the rAF pass is what observes React's recycled rows.
// Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
const scheduleRemeasure = () => {
  if (remeasurePending) return;
  remeasurePending = true;
  let ranMicro = false;
  const microPass = () => {
    remeasureWindow();
  };
  const rafPass = () => {
    remeasurePending = false;
    remeasureWindow();
  };
  if (typeof queueMicrotask !== 'undefined') {
    ranMicro = true;
    queueMicrotask(microPass);
  }
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else setTimeout(rafPass, 0);
};
// pinnedEditIndex(): the FULL-MODEL row index of the row currently in edit (D-02 pin-row),
// or -1 when no editor is open. Under virtualization `$data.rows` is the FULL pre-pagination
// model, so editingRow (single-cell) / editingRowIndex (full-row) — both in that index space —
// ARE the full-model index. The pinned row must never recycle while editing (req-9): it is
// unioned into the windowed slice when it scrolls off-window and its height is subtracted from
// the appropriate spacer so the total stays exactly getTotalSize() (the 51-01-proven mechanism).
// This is the data-table half of the D-05 windowing.rzts pin-extension hook (listbox provides none).
const pinnedEditIndex = () => {
  if (editingRow.value >= 0) return editingRow.value;
  if (editingRowIndex.value != null) return editingRowIndex.value;
  return -1;
};
// pinnedMeasurement(pin): the virtual-core measurement { index, start, size, end, key } for the
// pinned full-model index — its measured (or estimated) height + offset, used to (a) decide
// whether it sits above/below the rendered window and (b) subtract its height from the right
// spacer. Null when out of range / not virtual.
const pinnedMeasurement = (pin: any) => {
  if (!virtualizer || pin < 0) return null;
  const ms = virtualizer.getMeasurements();
  return ms && ms[pin] ? ms[pin] : null;
};
// measureElement sweep (D-10 / CR-01): refine estimated heights to MEASURED ones. The off-root
// querySelector idiom (chartjs/cropper/embla precedent — no per-row callback ref). Each rendered
// <tr> MUST be handed to virtualizer.measureElement on every window commit for it to be observed:
// virtual-core does NOT auto-register rendered rows — measureElement is the SOLE caller of its
// internal ResizeObserver's observe() (virtual-core@3.17.1 dist/esm/index.js:794-817), keyed by
// getItemKey. So this sweep must run not just once at mount but on every onChange tick (via
// scheduleRemeasure), or recycled rows keep the estimateRowHeight seed forever. measureElement is
// idempotent on an already-observed node (the `prevNode !== node` guard), so re-sweeping the
// visible window each commit is cheap and loop-free.
const remeasureWindow = () => {
  if (!virtualizer || !gridRoot) return;
  // Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
  // exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
  // null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
  // scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
  // resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
  // regression); the next settled onChange re-measures the stable window. Manual-scroll
  // recycling (the CR-01 case) has scrollState === null, so it measures normally.
  if (virtualizer.scrollState) return;
  const trs = gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
  for (const tr of trs as any) virtualizer.measureElement(tr);
};
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
//   - windowSource(): T[]   — the full list to window (the KEY generalization; the DataTable host
//                             returns its pre-pagination row model, listbox/combobox return the
//                             filtered options). This partial MUST NOT reach into the host data engine
//                             directly — rows arrive ONLY through windowSource().
//   - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
//   - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
//   - gridScrollEl              — the scroll-container element handle.
//   - virtualizer               — the host virtual-core instance (built in $onMount from the ref).
//   - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
//   - scheduleRemeasure()       — the host's rAF/microtask remeasure defer.
//   - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
//                             defaulting to no-op): the DataTable host passes its edit-pinning hooks;
//                             listbox passes nothing. Routing pinning through this host hook (NOT
//                             inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.

// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
const virtualItemKey = (i: any) => {
  const src = windowSource();
  return src && src[i] ? src[i].id : undefined;
};
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
const virtualizerOptions = (): any => ({
  count: windowSource().length,
  getScrollElement: () => gridScrollEl,
  estimateSize: () => props.estimateRowHeight,
  observeElementRect,
  observeElementOffset,
  scrollToFn: elementScroll,
  measureElement,
  overscan: 8,
  getItemKey: virtualItemKey,
  onChange: () => {
    windowVer.value = windowVer.value + 1;
    // CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
    // virtual-core only observe()s a node you explicitly hand to measureElement (it does
    // NOT auto-discover rendered rows — measureElement is the SOLE caller of
    // observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
    // into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
    // estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
    // so the new <tr> set is in the DOM before we measure. Safe from an infinite
    // measure→onChange→measure loop: measureElement is idempotent on an already-observed
    // node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
    // measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
    // re-measure is a no-op.
    scheduleRemeasure();
  }
});
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
const pinMeasurement = (pin: number): {
  start: number;
  size: number;
  index: number;
  end: number;
} | null => pinnedMeasurement(pin);
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
const windowedRows = () => {
  // SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
  // early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
  // which happens at initial render while `virtualizer` is still null (it is built in $onMount,
  // after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
  // BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
  // signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
  // blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
  // placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
  // first re-run that picks up the now-non-null virtualizer.
  // ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
  // pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
  void windowVer.value;
  void editVer.value;
  if (!virtualizer) {
    // Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
    // but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
    // the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
    // rows appear on the first onChange after _didMount.
    if (!props.virtual) {
      const rowList = rows.value || [];
      return rowList.map((r: any) => ({
        vi: null,
        row: r
      }));
    }
    return [];
  }
  const items = virtualizer.getVirtualItems();
  const rowList = rows.value || [];
  // WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
  // shrink window where the virtualizer count is stale relative to $data.rows on the async
  // onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
  // throw "Cannot read properties of undefined"; filter it here so the template never sees it.
  const out = items.map((vi: any) => ({
    vi,
    row: rowList[vi.index]
  })).filter((wr: any) => wr.row);
  // ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
  // window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
  // into another full-model row), LEADING the slice when it sits above the window and TRAILING
  // it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
  // padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
  // into the real windowing.
  const pin = pinnedEditIndex();
  if (pin >= 0 && rowList[pin]) {
    let inWindow = false;
    for (let i = 0; i < items.length; i++) {
      if (items[i].index === pin) {
        inWindow = true;
        break;
      }
    }
    if (!inWindow) {
      const pm = pinMeasurement(pin);
      const firstStart = items.length ? items[0].start : 0;
      const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
      const pinnedEntry = {
        vi: pm != null ? pm : {
          index: pin
        },
        row: rowList[pin],
        pinned: true
      };
      if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
    }
  }
  return out;
};
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
const padTop = () => {
  // SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
  // spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
  // and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
  void windowVer.value;
  void editVer.value;
  if (!props.virtual || !virtualizer) return 0;
  const items = virtualizer.getVirtualItems();
  let pad = items.length ? items[0].start : 0;
  // D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
  // in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
  // that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
  const pin = pinnedEditIndex();
  if (pin >= 0) {
    const pm = pinMeasurement(pin);
    const inWindow = pmIndexInWindow(items, pin);
    if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
  }
  return pad < 0 ? 0 : pad;
};
const padBottom = () => {
  // subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
  // return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
  // on pin/unpin.
  void windowVer.value;
  void editVer.value;
  if (!props.virtual || !virtualizer) return 0;
  const items = virtualizer.getVirtualItems();
  if (!items.length) return 0;
  let pad = virtualizer.getTotalSize() - items[items.length - 1].end;
  // D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
  // in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
  const pin = pinnedEditIndex();
  if (pin >= 0) {
    const pm = pinMeasurement(pin);
    const inWindow = pmIndexInWindow(items, pin);
    // WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
    // measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
    // index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
    // The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
    // the offset comparison only if the measurement lacks an index (defensive).
    const lastItemIdx = items[items.length - 1].index;
    const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
    if (pm && !inWindow && below) {
      // below the window → it trailed the slice; subtract its height from the trailing spacer.
      if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
    }
  }
  return pad < 0 ? 0 : pad;
};
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
const pmIndexInWindow = (items: any, idx: any) => {
  for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
  return false;
};
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
const rowIsOutsideWindow = (r: any) => {
  if (!props.virtual || !virtualizer) return false;
  const items = virtualizer.getVirtualItems();
  for (const it of items as any) if (it.index === r) return false;
  return true;
};
// ── Sort/filter live-announcement (#14) ─────────────────────────────────────────────
// A polite aria-live announcement whenever the consumer changes sorting or filtering, so a
// screen-reader user hears that the rows were reordered / narrowed (which is otherwise silent).
// announceState holds the last-seen references so the lazy watch below can tell WHICH slice
// changed (sort vs filter) and pick the message. It is a top-level mutable const → stabilized
// once per instance on all six targets (React useMemo-wraps a mutable instance; the others run
// setup once), so it PERSISTS across renders — unlike a top-level `let`, which React resets per
// render. Seeded from the initial state in $onMount so the first (post-mount) change compares
// against the true starting values, not a null sentinel.
// Typed as `unknown` members: these hold opaque last-seen references compared only by
// identity (!==) below, never read in a typed context — the annotation keeps the null seed
// from narrowing the members to `null` (which would reject the real reassignments under
// strictNullChecks in the emitted leaves).
const announceState: {
  sorting: unknown;
  columnFilters: unknown;
  globalFilter: unknown;
} = {
  sorting: null,
  columnFilters: null,
  globalFilter: null
};
// Effective (controlled-or-uncontrolled) reads of the sort/filter slices: the bound prop when
// the consumer bound the matching r-model, else the uncontrolled $data default (mirrors currentState()).
const effectiveSorting = () => sorting.value != null ? sorting.value : sortingDefault.value;
const effectiveColumnFilters = () => columnFilters.value != null ? columnFilters.value : columnFiltersDefault.value;
const effectiveGlobalFilter = () => globalFilter.value != null ? globalFilter.value : globalFilterDefault.value;
// Build the polite message for a sort/filter change and advance announceState. Sort takes
// precedence when the sorting reference changed; otherwise a filter changed → the post-filter
// result count (the FILTERED total via totalRowCount(), NOT the page slice). Returns '' when
// neither actually changed (a no-op watch tick — do not re-announce).
const buildSortFilterAnnounce = () => {
  const nextSorting = effectiveSorting();
  const nextColumnFilters = effectiveColumnFilters();
  const nextGlobalFilter = effectiveGlobalFilter();
  const sortChanged = nextSorting !== announceState.sorting;
  const filterChanged = nextColumnFilters !== announceState.columnFilters || nextGlobalFilter !== announceState.globalFilter;
  announceState.sorting = nextSorting;
  announceState.columnFilters = nextColumnFilters;
  announceState.globalFilter = nextGlobalFilter;
  if (sortChanged) {
    const active = nextSorting && nextSorting.length ? nextSorting[0] : null;
    if (!active) return 'Sorting cleared';
    const rawLabel = headerLabel(active.id);
    const label = typeof rawLabel === 'string' && rawLabel ? rawLabel : active.id;
    return 'Sorted by ' + label + ', ' + (active.desc ? 'descending' : 'ascending');
  }
  if (filterChanged) {
    return totalRowCount() + ' results';
  }
  return '';
};
// Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
// re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
const reFeed = () => {
  if (!table) return;
  // NOTE: the external-swap history reset does NOT live here. reFeed() fires on EVERY watched
  // change — including our OWN synchronous internal `$data.dataDefault` write — so a clear keyed
  // on a `currentData()` read here would (on fine-grained targets) fire mid-round-trip against a
  // TRANSIENTLY-STALE `$props.data` and wrongly wipe a just-recorded edit's history. The reset is
  // keyed on the `$props.data` REFERENCE actually changing instead — see the $onUpdate backstop
  // below (`maybeClearHistoryOnExternalSwap`), which runs on all six targets.
  table.setOptions((prev: any) => ({
    ...prev,
    data: currentData(),
    columns: tableColumns(),
    state: currentState(),
    enableRowSelection: props.selectionMode !== 'none',
    enableMultiRowSelection: props.selectionMode === 'multiple',
    // Re-pass the server-side page-count sources (#2) so a RUNTIME rowCount/pageCount change
    // takes effect: setOptions REPLACES via `...prev`, which holds the value captured at
    // createTable time, so an omitted key would freeze the mount-time count. The re-feed
    // $watch keys on both props below.
    rowCount: props.rowCount ?? undefined,
    pageCount: props.pageCount ?? undefined,
    // Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
    // setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
    // onExpandedChange callback must re-capture fresh currentState each cycle, F6).
    getExpandedRowModel: getExpandedRowModel(),
    getSubRows: (props.getSubRows || undefined) as any,
    getRowCanExpand: props.expandable === true && props.getSubRows == null ? () => true : undefined,
    onExpandedChange: onExpandedChangeCb,
    // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
    // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
    // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
    // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
    // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
    // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
    // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
    // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
    // are unaffected (they fire regardless of this flag).
    autoResetExpanded: false,
    // Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
    // omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
    // fresh currentState each cycle, F6).
    getGroupedRowModel: getGroupedRowModel(),
    onGroupingChange: onGroupingChangeCb,
    // Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
    // drop the model on re-feed; on React the faceted closures must re-capture so exposed
    // unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: makeFacetedUniqueValues(),
    getFacetedMinMaxValues: makeFacetedMinMaxValues(),
    // Re-pass the per-slice callbacks so React captures fresh currentState each cycle
    // (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
    onSortingChange: onSortingChangeCb,
    onGlobalFilterChange: onGlobalFilterChangeCb,
    onColumnFiltersChange: onColumnFiltersChangeCb,
    onPaginationChange: onPaginationChangeCb,
    onRowSelectionChange: onRowSelectionChangeCb,
    onColumnVisibilityChange: onColumnVisibilityChangeCb,
    onColumnSizingChange: onColumnSizingChangeCb,
    onColumnOrderChange: onColumnOrderChangeCb,
    onColumnPinningChange: onColumnPinningChangeCb,
    onColumnSizingInfoChange: onColumnSizingInfoChangeCb
  }));
  if (refreshRowModel) refreshRowModel();
};
// LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
// `data` PROPERTY): the re-feed $watch reads `(this.data||[]).length` inside a
// preact-signals effect, but `data` is a Lit @property (not a signal) so the effect
// never re-runs when the consumer pushes new rows post-mount (the sticky demo seeds 20
// rows in its own $onMount AFTER the child mounted empty → the body stayed at 0). The
// slice models DO re-pull (their $data.<slice>Default signals are effect-tracked), so
// only a raw `data` reference/length change slips through. $onUpdate (Lit updated())
// fires on ANY property change incl `data`; guard with a stored last-seen data ref +
// length so it re-feeds ONLY on a real data change (no churn). On the coarse-render
// targets the watch already covers it; this is a cheap idempotent backstop.
// External-swap history reset (grid-wide undo/redo, 260709-8ct; #8 fix). Keyed on the CONTROLLED
// `$props.data` REFERENCE changing — deliberately NOT on `currentData()` inside reFeed. An internal
// writeback changes `$data.dataDefault` SYNCHRONOUSLY and only LATER round-trips into `$props.data`;
// keying on `$props.data`'s OWN change means we never observe the transient window where a fine-
// grained target's reFeed reads a stale, unstamped `$props.data` mid-write and wrongly wipes a
// just-recorded edit's history (the stale-read false-clear — the SAME failure that broke the
// content-signature variant — that regressed Solid/Lit when this clear lived in reFeed). When
// `$props.data` genuinely changes: a new array carrying DATA_WRITE_TOKEN round-tripped from one of
// OUR writes → keep; one without it is a dataset the consumer handed us → external swap → clear. A
// non-data tick (sort/filter/pagination) never touches `$props.data` → never clears. Called from
// BOTH the coarse re-feed watch AND the $onUpdate backstop (Lit's @property `data` the effect-
// tracked watch can't observe); both are ref-gated so the redundant call is an idempotent no-op.
let lastPropsData: unknown = null;
const maybeClearHistoryOnExternalSwap = () => {
  const pd = data.value;
  if (pd === lastPropsData) return; // $props.data did not change → not an external swap
  lastPropsData = pd;
  if (!props.undoable) return;
  if (pd != null && (pd as any)[DATA_WRITE_TOKEN_KEY] != null) return; // descends from our write → keep
  clearHistory();
};
let lastData: any = null;
let lastDataLen = -1;
// Header click → toggle sort. Shift-click → ADD a secondary sort (multi-sort). Driven
// through table-core's column API so the onSortingChange funnel emits the fresh state.
const onHeaderSort = (colId: any, evt: any) => {
  if (!table) return;
  const col = table.getColumn(colId);
  if (!col || !col.getCanSort()) return;
  const multi = !!(evt && evt.shiftKey);
  // toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
  col.toggleSorting(undefined, multi);
};
// aria-sort string for a column header: 'ascending' | 'descending' | 'none'. Reads
// Reactive tick: read $data.rowModelVer (bumped by every refreshRowModel) so a
// template binding that calls a table-READING chrome helper (pagination/sort/pin/
// visibility predicates below) re-evaluates when the row model changes. On the
// coarse-render targets (Vue/React/Angular) the whole template re-runs anyway so this
// is a no-op; on the FINE-GRAINED targets (Solid/Lit) a helper that only reads the
// non-reactive `table` let would be computed ONCE (when table is still null → the
// default branch) and never update — pagination would read "Page 1 of 1" forever,
// aria-sort never flips, the pin position never sticks. Touching rowModelVer puts each
// helper in the reactive scope. The chrome helpers prefix `tick()` in their guard.
const tick = () => rowModelVer.value;
// the live sort direction off the table-core column (string-safe — never a bound
// boolean, the listbox aria lesson).
const ariaSortFor = (colId: any) => {
  if (tick() < 0 || !table) return 'none';
  const col = table.getColumn(colId);
  if (!col) return 'none';
  const dir = col.getIsSorted();
  if (dir === 'asc') return 'ascending';
  if (dir === 'desc') return 'descending';
  return 'none';
};
// A small sort-direction glyph for the header (▲/▼/empty). Decorative — aria-hidden.
const sortIndicator = (colId: any) => {
  if (tick() < 0 || !table) return '';
  const col = table.getColumn(colId);
  if (!col) return '';
  const dir = col.getIsSorted();
  if (dir === 'asc') return '▲';
  if (dir === 'desc') return '▼';
  return '';
};
// Template helpers reading the resolved column-def metadata by id (plain fns — used
// in template predicates + interpolation; uniform on all 6, no $computed alias trap).
const defFor = (colId: any) => {
  const defs = columnDefs();
  for (const d of defs as any) if (d.id === colId) return d;
  return null;
};
// Per-row visible cells for the body loop. table-core memoizes row objects by id,
// so a re-pull after a column change (visibility/reorder/pin, or the late <Column>
// registry on first mount) returns the SAME row references with a different cell
// set. On Solid the row loop keeps the existing <tr> across that pull (`:key="row.id"`
// is stable, so the emitter's `<Key>` reconciler holds the node), and Solid will NOT
// re-run a child loop whose `each` reads no signal — so a bare `row.getVisibleCells()`
// goes stale (header reorders, cells don't). Reading `$data.rowModelVer` (bumped by every
// refreshRowModel) inside the `each` puts the inner loop in the reactive scope, so it
// re-derives the cells on every row-model change. No-op on the coarse-render targets.
const visibleCellsFor = (row: any) => rowModelVer.value >= 0 ? row.getVisibleCells() : [];
// ── Editable-cell column-meta accessors (phase 51 req-1/2/5) ───────────────────────
// editMetaOf: the resolved ColumnDef.meta for a column id (the editable config carried
// from <Column>/`:columns` via columnDefs). Null-safe — an unknown/non-editable column
// returns null and every predicate below short-circuits to the read-only path.
const editMetaOf = (colId: any) => {
  const d = defFor(colId);
  return d && d.meta ? d.meta : null;
};
// columnEditable: whether this column opted into editing (req-1). Drives every editor
// gate; false → the cell stays the read-only #cell display (byte-identical-off).
const columnEditable = (colId: any) => {
  const m = editMetaOf(colId);
  return !!(m && m.editable === true);
};
// editorTypeOf: the built-in editor kind ('text'|'number'|'select'|'checkbox') OR
// 'custom' (the #editor scoped-slot escape hatch, req-2). Defaults to 'text'.
const editorTypeOf = (colId: any) => {
  const m = editMetaOf(colId);
  return m && m.editor != null ? m.editor : 'text';
};
// editorOptionsOf: the select-editor options ([{ value, label }]) for editor='select'.
const editorOptionsOf = (colId: any) => {
  const m = editMetaOf(colId);
  return m && m.editorOptions != null ? m.editorOptions : [];
};
// hasEditorSlot: this column routes through the consumer's #editor scoped slot (req-2)
// — true only when the column declared editor='custom' AND the consumer actually
// provided an #editor slot. Falls through to the built-in editor otherwise (e.g. a
// column marked 'custom' with no slot supplied degrades to the text editor, never blank).
const hasEditorSlot = (colId: any) => editorTypeOf(colId) === 'custom' && !!slots.editor;
// hasFilterSlot: the consumer supplied a #filter scoped slot, so it OWNS the per-column
// filter UI (re-added in 72-05 alongside the dedicated filter row's `<slot name="filter">`
// host — see the 72-03 removal note in that plan's SUMMARY for why this was briefly gone).
const hasFilterSlot = () => !!slots.filter;
const columnIsFilterable = (colId: any) => {
  const d = defFor(colId);
  return !!(d && d.filterable);
};
const headerLabel = (colId: any) => {
  const d = defFor(colId);
  return d ? d.header : colId;
};
// ── Column-management chrome (req-8/9/10/11) ────────────────────────────────────────
// Live header width (px) for a column — drives the <th> :style width binding. Reads the
// table-core column size (post-mount) with a fallback to undefined (auto width).
const headerWidth = (colId: any) => {
  if (tick() < 0 || !table) return null;
  const col = table.getColumn(colId);
  if (!col) return null;
  const w = col.getSize();
  return w != null && w > 0 ? w + 'px' : null;
};
// Pointer-drag resize handler for a resizable header — table-core's getResizeHandler()
// returns a function bound to a pointerdown/touchstart event that drives the column
// size through onColumnSizingChange (our writeColumnSizing funnel) under
// columnResizeMode:'onChange'. Pure delegation; no scratch gesture state held in a
// top-level const (the React fragile-binding rule — table-core owns the gesture state).
const onResizeStart = (colId: any, evt: any) => {
  // stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
  if (evt && evt.stopPropagation) evt.stopPropagation();
  if (!table) return;
  const header = findHeader(colId);
  if (!header || !header.getResizeHandler) return;
  const handler = header.getResizeHandler();
  if (handler) handler(evt);
};
// Find the live header object for a column id across the rendered header groups.
const findHeader = (colId: any) => {
  const groups = headerGroups.value || [];
  for (const hg of groups as any) {
    const hs = hg.headers || [];
    for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
  }
  return null;
};
const columnIsResizing = (colId: any) => {
  if (tick() < 0 || !table) return false;
  const header = findHeader(colId);
  return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
};
// Visibility toggle (req-8) — drive table-core's column.toggleVisibility so the
// onColumnVisibilityChange funnel emits the fresh state.
const columnIsVisible = (colId: any) => {
  if (tick() < 0 || !table) return true;
  const col = table.getColumn(colId);
  return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
};
const onToggleVisibility = (colId: any) => {
  if (!table) return;
  const col = table.getColumn(colId);
  if (col && col.toggleVisibility) col.toggleVisibility();
};
// The full set of leaf columns (for the visibility-toggle menu) — id + header label +
// current visibility. Excludes the auto-injected CHROME columns (select + expander) —
// neither is a data column: they carry no header label (so they'd surface their raw
// internal id, e.g. '__rdt_expander') and their presence is governed by the
// selectionMode/expandable props, not user-toggleable visibility.
const allLeafColumns = () => {
  if (tick() < 0 || !table) return [];
  const cols = table.getAllLeafColumns ? table.getAllLeafColumns() : [];
  const out = [];
  for (const c of cols as any) {
    if (!c || c.id === SELECT_COL_ID || c.id === EXPANDER_COL_ID) continue;
    out.push({
      id: c.id,
      label: headerLabel(c.id),
      visible: !!(c.getIsVisible && c.getIsVisible())
    });
  }
  return out;
};
// Pinning (req-11) — drive table-core's column.pin('left'|'right'|false) so the
// onColumnPinningChange funnel emits a fresh state. Sticky offsets read the live column
// start/after positions (table-core computes them from the pinned column sizes).
const columnPinSide = (colId: any) => {
  if (tick() < 0 || !table) return false;
  const col = table.getColumn(colId);
  if (!col || !col.getIsPinned) return false;
  return col.getIsPinned();
};
// NOTE: the event is stopped HERE (evt.stopPropagation()) rather than via a `.stop`
// template modifier. The Angular emitter, hoisting a `.stop`-modified handler that
// lives INSIDE an `@for` loop into a class-field wrapper, drops the component `this.`
// qualifier (→ `onPinColumn(...)` bare ReferenceError) and fails to capture the loop
// var — so a `@click.stop="onPinColumn(...)"` inside the header `@for` breaks on
// Angular (F5). Stopping inside the handler sidesteps the broken hoist on all six.
const onPinColumn = (colId: any, side: any, evt: any) => {
  if (evt && evt.stopPropagation) evt.stopPropagation();
  if (!table) return;
  const col = table.getColumn(colId);
  if (col && col.pin) col.pin(side);
};
// Sticky inline style for a pinned header/cell — position:sticky + the computed left or
// right offset. Returns '' (no sticky) for unpinned columns. Returned as a STRING (the
// :style binding is value-driven — never an eval'd attr).
//
// `zIndex` (phase 72 fix, default 1 — body <td> / filter-row <th> layer): an INLINE style
// ALWAYS wins over the stylesheet's `.rozie-data-table.rdt-sticky .rdt-thead .rdt-th
// { z-index: var(--rdt-sticky-z, 2) }` rule, so a pinned header cell that unconditionally
// got `z-index:1` here (same as the pinned body/filter-row cells) silently DOWNGRADED the
// intended sticky-header stacking level from 2 to 1 — tying it with the dedicated filter
// row's own pinned <th> (72-05), which sits LATER in DOM order (a sibling <tr> beneath the
// header row) and therefore visually/interactively covers the header's ⋯ menu (phase 72,
// z-index:1000 relative to Popover's OWN local stacking context — capped by the pinned
// header <th>'s z-index, since a `position:fixed` descendant does not escape an ancestor's
// stacking context, only its layout containing block) whenever that SAME column is both
// pinned and filterable. thStyle() (the header caller) passes zIndex=2 so the header layer
// always wins ties against the filter-row/body layers, which keep the default of 1.
const pinStyle = (colId: any, zIndex = 1) => {
  if (tick() < 0 || !table) return '';
  const col = table.getColumn(colId);
  if (!col || !col.getIsPinned) return '';
  const side = col.getIsPinned();
  if (side === 'left') {
    const left = col.getStart ? col.getStart('left') : 0;
    return 'position:sticky;left:' + left + 'px;z-index:' + zIndex + ';';
  }
  if (side === 'right') {
    const right = col.getAfter ? col.getAfter('right') : 0;
    return 'position:sticky;right:' + right + 'px;z-index:' + zIndex + ';';
  }
  return '';
};
// Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
// uniform on all 6, no bound-object trap. zIndex=2 (see pinStyle) so a pinned header cell
// — which hosts the ⋯ menu's floating content — always stacks above the pinned filter-row
// cell for the same column (zIndex=1, its own default).
const thStyle = (colId: any) => {
  let s = '';
  const w = headerWidth(colId);
  if (w) s += 'width:' + w + ';';
  s += pinStyle(colId, 2);
  return s;
};
// ── Filter chrome handlers ─────────────────────────────────────────────────────────
// Global search input → funnel through table-core's setGlobalFilter so the
// onGlobalFilterChange callback fires the echo-guarded writer. Capture the fresh local
// value (never re-read a just-written $data key — React stale-read).
const onGlobalFilterInput = (evt: any) => {
  const value = evt && evt.target ? evt.target.value : '';
  if (table) {
    table.setGlobalFilter(value);
    return;
  }
  writeGlobalFilter(value);
};
// Per-column filter input → setColumnFilter (fresh-array funnel).
const onColumnFilterInput = (colId: any, evt: any) => {
  const value = evt && evt.target ? evt.target.value : '';
  setColumnFilter(colId, value);
};
// The live global filter value (bound to the search <input>, value-driven NOT eval'd).
const globalFilterValue = () => {
  const v = currentState().globalFilter;
  return v != null ? v : '';
};
// ── Pagination chrome ────────────────────────────────────────────────────────────
// Read the live pagination state off table-core (post-mount) with a currentState()
// fallback (pre-mount / SSR). All string-safe (no bound booleans).
const pageIndex = () => {
  if (tick() >= 0 && table) return table.getState().pagination.pageIndex;
  const p = currentState().pagination;
  return p && p.pageIndex != null ? p.pageIndex : 0;
};
const pageSize = () => {
  if (tick() >= 0 && table) return table.getState().pagination.pageSize;
  const p = currentState().pagination;
  return p && p.pageSize != null ? p.pageSize : 10;
};
// Renamed from `pageCount` → `displayPageCount`: `pageCount` is now a public prop
// (server-side manual pagination), and a same-named top-level helper collides with the
// destructured prop on Svelte and the @Input/@property class field on Angular/Lit. This
// reader is internal (drives the "Page X of Y" chrome) and reads table-core's live
// getPageCount(), which now reflects rowCount/pageCount when manual.
const displayPageCount = () => {
  if (tick() < 0 || !table) return 1;
  const c = table.getPageCount();
  return c != null && c > 0 ? c : 1;
};
const canPrevPage = () => !!(tick() >= 0 && table && table.getCanPreviousPage());
const canNextPage = () => !!(tick() >= 0 && table && table.getCanNextPage());
const onPrevPage = () => {
  if (table) table.previousPage();
};
const onNextPage = () => {
  if (table) table.nextPage();
};
const onPageSizeChange = (evt: any) => {
  if (!table) return;
  const v = evt && evt.target ? evt.target.value : '';
  const n = parseInt(v, 10);
  table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
};
// ── Row-selection chrome (req-7) ───────────────────────────────────────────────────
// Detect the auto-injected leading checkbox column by its constant id (template uses
// this to render checkbox chrome instead of an accessor value).
const isSelectColumn = (colId: any) => colId === SELECT_COL_ID;
// ── Expandable-rows template helpers (phase 50, D-04) ──────────────────────────────
// isExpanderColumn: the auto-injected leading chevron column predicate (mirrors
// isSelectColumn). rowIsExpanded / rowCanExpand read table-core row handles THROUGH the
// reactive tick (rowModelVer) so the chevron glyph + aria-expanded + the #detail r-if
// re-derive on a re-pull on the fine-grained targets (Solid/Lit) — same discipline as
// visibleCellsFor. `!!`-coerced so a bound aria-expanded emits an UNWRAPPED boolean (the
// listbox aria lesson — never a rozieAttr string → TS2322 on React/Solid).
const isExpanderColumn = (colId: any) => colId === EXPANDER_COL_ID;
// rowCanExpand gates ONLY the leading expander-column detail chevron. Group-header rows
// are excluded (`!getIsGrouped`): with `expandable` + grouping, getRowCanExpand returns
// `() => true` for EVERY flattened row, so without this a group header rendered TWO
// chevrons — the group-toggle in its grouped cell AND a redundant detail chevron in the
// leading column (both fire onToggleExpand on the shared expanded state). A group row's
// expand affordance is the group-toggle; the leading-column chevron is detail-only.
const rowCanExpand = (row: any) => !!(tick() >= 0 && row && row.getCanExpand && row.getCanExpand() && !(row.getIsGrouped && row.getIsGrouped()));
const rowIsExpanded = (row: any) => !!(tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
// rowShowsDetail: the #detail <tr> renders ONLY in #detail mode (no getSubRows) when the
// row is expanded AND is NOT a group-header row. With getSubRows the children arrive as
// ordinary depth-indented rows in $data.rows (table-core flattens) — NO additive detail
// row, NO nested r-for (Pitfall 1). The `!rowIsGrouped` guard is load-bearing: grouping
// and detail-expand share table-core's SINGLE `expanded` state, so a group-header row is
// `getIsExpanded()===true` the moment its group opens; without this guard that expanded
// group row also satisfied `getSubRows==null && rowIsExpanded`, painting a spurious
// #detail panel under every opened group (the group-toggle looked "linked" to detail).
const rowShowsDetail = (row: any) => props.getSubRows == null && !rowIsGrouped(row) && rowIsExpanded(row);
// Toggle a row's expanded state through table-core so onExpandedChange → writeExpanded
// fires exactly one expanded-change. Used by the chevron @click (native <button> handles
// Enter/Space → click, so NO explicit @keydown.enter/.space — that would DOUBLE-toggle on
// a real button; the grid @keydown is inert in 'table' mode, isGrid()-gated).
const onToggleExpand = (row: any, evt: any) => {
  if (!row || !row.toggleExpanded) return;
  // Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
  // expanded-state re-render. This guards a focus-drop that USED to happen on Solid: when the
  // cell loop reconciled by reference (bare <For>), table-core's fresh cell instances each
  // pull rebuilt the expander <td>/<button> (the <tr> persisted but its cells were rebuilt),
  // dropping DOM focus to <body> and breaking keyboard activation (Enter/Space on the focused
  // expander left nothing focused). Since the emitter now emits `<Key>` for the
  // `:key="cellCtx.id"` cell loop, Solid keeps the cell node on a stable key too — so the
  // expander is no longer recreated and this re-focus is now a defensive no-op on ALL six
  // targets (re-focusing the SAME kept element — the focusActiveCell imperative-refocus
  // precedent). Kept for safety; it costs nothing when the node is unchanged. The rAF defers
  // past the synchronous reactive flush so any (re)created node exists first.
  const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
  row.toggleExpanded();
  if (ownerRow && typeof requestAnimationFrame === 'function') {
    requestAnimationFrame(() => {
      const btn = ownerRow.querySelector('[data-expander]');
      if (btn) btn.focus();
    });
  }
};
// bodyCellStyle: the non-virtual <td> inline style — pinStyle PLUS a depth-proportional
// left pad on the EXPANDER cell so nested getSubRows children visibly indent (row.depth).
// Only the expander column indents (the tree affordance lives in its dedicated column);
// data columns stay grid-aligned. depth 0 → unchanged (byte-identical-off).
const bodyCellStyle = (row: any, colId: any) => {
  const base = pinStyle(colId);
  if (isExpanderColumn(colId) && row && row.depth) {
    // Only the expander column indents (the tree affordance lives here).
    const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
    return base ? base + pad : pad;
  }
  return base;
};
// ── Grouping template helpers (phase 50 reqs 4-7, D-04/D-05) ───────────────────────────
// Group-header rows ARE expandable rows: table-core's getGroupedRowModel FLATTENS them into
// $data.rows carrying getIsGrouped()/subRows, so they ride the SAME D-04 <template r-for> seam
// (no parallel render path, no nested r-for). These predicates read through the reactive tick
// (rowModelVer) so the group chrome + collapse state re-derive on a re-pull on the fine-grained
// targets (Solid/Lit) — same discipline as rowIsExpanded/visibleCellsFor. `!!`-coerced (the
// listbox aria lesson — a bound boolean must be UNWRAPPED, never a rozieAttr string → TS2322).
// rowIsGrouped: this flattened row is a group-header row.
const rowIsGrouped = (row: any) => !!(tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
// groupingActive: grouping is currently engaged (a non-empty ordered key list). Drives the
// data-group-leaf marker so it is ABSENT when ungrouped (byte-identical-off, req-10).
const groupingActive = () => tick() >= 0 && (currentState().grouping || []).length > 0;
// cellIsGrouped / cellIsAggregated: per-CELL roles on a group-header row. The grouped cell shows
// the group key + toggle + count; an aggregated cell shows the rolled-up value through the
// EXISTING #cell slot (cell.getValue()) — NO new aggregatedCell template (RESEARCH State of the
// Art). A placeholder cell (neither) falls through to the #cell r-else and renders its empty value.
const cellIsGrouped = (cellCtx: any) => !!(tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
const cellIsAggregated = (cellCtx: any) => !!(tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
// cellIsPlaceholder: a PLACEHOLDER cell on a group-header row — a non-grouped, non-aggregated
// cell that table-core fills with the FIRST leaf row's value (cell.getValue() leaks e.g.
// "Services"/"Edsger Dijkstra" onto the group line). Renders BLANK via a dedicated empty
// template branch so the leaked leaf value never paints. Tick-gated exactly like cellIsGrouped
// so the group chrome re-derives on a re-pull on the fine-grained targets (Solid/Lit).
const cellIsPlaceholder = (cellCtx: any) => !!(tick() >= 0 && cellCtx && cellCtx.getIsPlaceholder && cellCtx.getIsPlaceholder());
// groupSubRowCount: the number of underlying LEAF RECORDS under a group-header row (the count
// shown in the header, e.g. "North (40)"). row.subRows is the IMMEDIATE members — for MULTI-LEVEL
// grouping those are sub-GROUPS, not records, so "North" with 2 categories / 40 records would show
// "North (2)". getLeafRows() returns all leaf descendants (the actual record count); keep the
// subRows fallback for safety. Single-level grouping is unchanged (getLeafRows == subRows when the
// children are already leaves).
const groupSubRowCount = (row: any) => row && row.getLeafRows ? row.getLeafRows().length : row && row.subRows ? row.subRows.length : 0;
// groupingKeys: the live ordered grouping array — slot prop for the headless #groupBar + the
// default styled-token reflection. Reads currentState() ($props.grouping ?? $data.groupingDefault),
// both reactive sources, so the bar re-renders on a grouping change across all six targets.
const groupingKeys = () => currentState().grouping || [];
// groupableColumns: the data columns OFFERED to the headless #groupBar (those whose Column/config
// `groupable` is not false) — `[{ id, label }]`. Excludes the chrome columns (select/expander are
// not in columnDefs()). The consumer builds any bar/drag UI from this; the component ships none.
const groupableColumns = () => {
  const out = [];
  const defs = columnDefs();
  for (const d of defs as any) {
    if (!d || d.groupable === false) continue;
    out.push({
      id: d.id,
      label: d.header != null ? d.header : d.id
    });
  }
  return out;
};
// Plain stop-propagation handler (used in place of the `@click.stop` bare modifier —
// a bare `.stop` with no handler hoists to `_guardedUndefined` → `this.undefined($event)`
// on Angular inside an `@for`, F5). Calling an explicit handler is uniform on all six.
const stopEvent = (evt: any) => {
  if (evt && evt.stopPropagation) evt.stopPropagation();
};
// select-all header state (D-06: scopes to all filtered rows = TanStack default).
// `!!`-coerced booleans (the listbox aria lesson — never a bound rozieAttr string).
const isAllRowsSelected = () => !!(tick() >= 0 && table && table.getIsAllRowsSelected());
const isSomeRowsSelected = () => !!(tick() >= 0 && table && table.getIsSomeRowsSelected());
const onToggleAllRows = (evt: any) => {
  if (!table) return;
  table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
};
// per-row checkbox state + toggle (checkbox-only, D-05 — row body does NOT select).
// Read selection from the LIVE controlled state (currentState().rowSelection keyed by
// row.id) — NOT row.getIsSelected(). The latter reads table-core's row model, which
// only reflects a selection AFTER the re-feed watch pushes the new `state` + re-pulls
// (two reactive cycles on React). The controlled-state read updates in the SAME cycle
// as the write funnel, so the controlled <input :checked> reflects the toggle without
// the row-model-re-pull latency — the React controlled-checkbox revert that left
// `.check()` seeing no state change (F6). row.getIsSelected() is the fallback.
const rowIsSelected = (row: any) => {
  if (!row) return false;
  const id = row.id;
  const sel = currentState().rowSelection || {};
  if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
  return !!(row.getIsSelected && row.getIsSelected());
};
const onToggleRow = (row: any, evt: any) => {
  if (!row || !row.toggleSelected) return;
  row.toggleSelected(!!(evt && evt.target && evt.target.checked));
};
// ── Header ⋯ menu chrome (phase 72, D-06) ──────────────────────────────────────────
// onHideColumn: the ⋯ menu's "Hide column" item. Reuses the SAME columnVisibility write
// funnel as the existing colvis toggle (onToggleVisibility in columnChrome.rzts) — just
// forced to `false` rather than toggled, since hide is a one-directional action from the
// menu (the colvis panel is the re-show path). Event stopped HERE (not a `.stop`
// modifier) — same Angular @for-hoist hazard as onPinColumn/onResizeStart (F5).
const onHideColumn = (colId: any, evt: any) => {
  if (evt && evt.stopPropagation) evt.stopPropagation();
  if (!table) return;
  const col = table.getColumn(colId);
  if (col && col.toggleVisibility) col.toggleVisibility(false);
};
// hasAnyFilterableColumn: gates the dedicated filter row (72-05) — true when at least one
// leaf column (excluding the select/expander chrome columns, already excluded by
// allLeafColumns) is filterable. Reactive via allLeafColumns()'s own tick() gate.
const hasAnyFilterableColumn = () => {
  const cols = allLeafColumns();
  for (const c of cols as any) {
    if (c && columnIsFilterable(c.id)) return true;
  }
  return false;
};
// `indeterminate` is a DOM PROPERTY, not an HTML attribute — a `:indeterminate="…"`
// binding only takes effect on Vue (which binds known DOM props); on
// React/Solid/Angular/Lit/Svelte it lands as an inert attribute and `el.indeterminate`
// stays false. So set it IMPERATIVELY: query the select-all checkbox off the component
// root ($el — post-mount safe) and assign the property. Called from refreshRowModel
// (every selection change re-pulls the row model) so it stays in lockstep with the
// table-core selection state. The select-all box is NOT re-created by a selection
// change (only its checked attr flips), so the live element persists.
// `box` is aliased through a module-scope null-let (typeNeutralize → `any`) so the
// strict bundled-leaf tsc accepts `.indeterminate` (querySelector returns `Element`,
// which has no `indeterminate` — it is an HTMLInputElement DOM property). Same idiom
// as Column's `let reg = null; reg = $inject(...)`.
let selectAllBox: any = null;
const syncIndeterminate = () => {
  if (!__rozieRootRef.value || !__rozieRootRef.value!.querySelector) return;
  selectAllBox = __rozieRootRef.value!.querySelector('.rdt-select-all');
  if (selectAllBox) selectAllBox.indeterminate = isSomeRowsSelected() && !isAllRowsSelected();
};

// The registry API handed to <Column> children (whole-object-replace — T-48-PP guard).
// Imperative handle (consumer-callable). Each verb is a PRE-DECLARED top-level
// `const` (the canonical $expose contract — `$expose({ name })` references a
// binding ALREADY in scope; an INLINE-defined verb `$expose({ name: () => {} })`
// is dropped on ALL SIX targets, only the by-reference key survives → a
// runtime ReferenceError at `defineExpose`/`useImperativeHandle`). Sorting verbs +
// a fresh column-def readout, selection, pagination, and column-management verbs.
const sortColumn = (colId: any, desc: any) => {
  if (table) table.getColumn(colId) && table.getColumn(colId).toggleSorting(desc, false);
};
const clearSorting = () => {
  if (table) table.resetSorting(true);
};
const getColumnDefs = () => columnDefs();
// selection verbs (req-7) — drive table-core so the onRowSelectionChange funnel
// emits the fresh state + selection-change.
const toggleAllRows = (value: any) => {
  if (table) table.toggleAllRowsSelected(value);
};
const clearSelection = () => {
  if (table) table.resetRowSelection(true);
};
const getSelectedRows = () => table ? table.getSelectedRowModel().rows.map((r: any) => r.original) : [];
// pagination verbs.
const setPage = (idx: any) => {
  if (table) table.setPageIndex(idx);
};
const setRowsPerPage = (size: any) => {
  if (table) table.setPageSize(size);
};
// column-management verbs (req-8/9/10/11) — drive table-core so the funnels fire.
const toggleColumnVisibility = (colId: any) => {
  if (table) {
    const c = table.getColumn(colId);
    if (c && c.toggleVisibility) c.toggleVisibility();
  }
};
// NOT `setColumnOrder`: a verb named `set<ModelProp>` collides with React's
// auto-generated `setColumnOrder` useState setter for the `columnOrder` model
// prop, and an $expose verb is PUBLIC-CONTRACT-PROTECTED from the React
// deconfliction rename (ROZ524 — the rename target is the verb, which is
// off-limits). So the public verb is `applyColumnOrder` (semantically: apply a
// new column order). The other set* verbs (setPage/setRowsPerPage) do NOT match
// any model prop's setter, so they are collision-free.
const applyColumnOrder = (order: any) => {
  if (table) table.setColumnOrder(order);
};
const resetColumnSizing = () => {
  if (table) table.resetColumnSizing(true);
};
// pinColumn: the verb that drives column.pin; distinct from the template handler
// onPinColumn (no shadow — the deferred-items finding #4 collision check).
const pinColumn = (colId: any, side: any) => {
  if (table) {
    const c = table.getColumn(colId);
    if (c && c.pin) c.pin(side);
  }
};
// getRowIndexRelativeToPage(absRow?) — C1 (phase 63 wave-6) converter: an ABSOLUTE display-order
// index (the focusCell/getActiveCell/activecell-change space) → the PAGE-RELATIVE index. Mirrors
// MUI getRowIndexRelativeToVisibleRows. With NO argument it converts the CURRENT active cell
// (toAbsRow($data.activeRow) - pageRowOffset() collapses to $data.activeRow). In virtual mode
// there is no page (windowing replaces pagination) → the windowed model IS the full model, so it
// returns the absolute index unchanged. Collision-safe: no *-change event, prop, React auto-setter,
// or inherited Lit DOM method named getRowIndexRelativeToPage (ROZ121/124/137 clear).
const getRowIndexRelativeToPage = (absRow: any) => {
  const abs = absRow == null ? toAbsRow(activeRow.value) : Math.trunc(Number(absRow)) || 0;
  if (props.virtual) return abs;
  return abs - pageRowOffset();
};
// C3 (phase 63 wave-9) — the PUBLIC Cut verb: copy the current cell range to the clipboard then
// clear the source cells through the write-funnel (one writeData), delegating to cutRange (the
// clipboardFill funnel that also backs the Ctrl+X shortcut). Reads the persisted $data range /
// active cell, so it cuts the current selection even when the call arrives off a control that
// moved DOM focus off the grid. Collision-safe: no `cut` event / model prop / React auto-setter /
// inherited Lit DOM method named `cut` (ROZ121/124/137 clear) — `cut` is not on HTMLElement.
const cut = () => cutRange();

// indirection. This file stays the seam for verbs that need a rename/adapter, not a mandatory
// ══ Grid interaction mode (phase 49) — STATE + STRUCTURE only ═══════════════════════════
// This plan (02) establishes the gated ARIA roles, the roving single-tab-stop tabindex,
// the active-cell index-pair state, the data-* cell markers, and the SINGLE
// focusActiveCell() seam. Plan 03 adds the keydown navigation math, the $expose verbs
// (focusCell/getActiveCell/clearActiveCell), and the activecell-change event ON TOP.

// interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
// identical to phase 48 (roles fall back to the literals, tabindex drops).
const isGrid = () => props.interactionMode === 'grid';
// Role computeds (RESEARCH Pattern 4). The 'table' branch returns the EXACT phase-48
// literal so 'table'-mode DOM is unchanged. Header cells keep 'columnheader' and rows keep
// 'row'/'rowgroup' in BOTH modes (APG grid) — those stay static literals in the template.
const tableRole = () => isGrid() ? 'grid' : 'table';
const cellRole = () => isGrid() ? 'gridcell' : 'cell';
// ── Cell addressing helpers (plain fns — no $computed alias trap; safe in template) ────
// rowIndexOf: a body row's index over the visible model ($data.rows). tick() puts the read
// in the fine-grained reactive scope (Solid/Lit) so the data-row marker re-derives on a
// re-pull (reorder/filter) — matching visibleCellsFor's discipline.
const rowIndexOf = (row: any) => tick() >= 0 ? (rows.value || []).indexOf(row) : -1;
// colIndexOf: a body cell's position in its row's visible cell list.
const colIndexOf = (row: any, cellCtx: any) => tick() >= 0 ? visibleCellsFor(row).indexOf(cellCtx) : -1;
// headerColIndexOf: a header cell's position in its header group's leaf headers.
const headerColIndexOf = (hg: any, header: any) => (hg && hg.headers ? hg.headers : []).indexOf(header);
// ── C1 (phase 63 wave-6) absolute-index bridge ─────────────────────────────────────────
// The PUBLIC active-cell rowIndex (focusCell/getActiveCell/activecell-change) is the ABSOLUTE
// display-order position in getPrePaginationRowModel().rows (filter+sort+expand applied, BEFORE
// pagination/windowing), in BOTH paginated and virtual modes — reversing the old page-relative
// paginated meaning. INTERNALLY $data.activeRow stays PAGE-RELATIVE in the non-virtual paginated
// body (the data-row markers + the nav math index the page slice) and FULL-MODEL in virtual mode
// (the wr.vi.index space). pageRowOffset() bridges the two so the API speaks one absolute language.
//   - virtual mode: activeRow is already the full pre-pagination index → offset 0.
//   - non-virtual:  activeRow is page-relative → offset = pageIndex * pageSize.
// isGrid()-gated (the active-cell API is grid-only); pageIndex()/pageSize() read live table-core
// state through the reactive tick (filterPaginationRowChrome), so this re-derives on a page change.
const pageRowOffset = () => {
  if (!isGrid() || props.virtual) return 0;
  return pageIndex() * pageSize();
};
// page-relative active row → absolute (display-order) index.
const toAbsRow = (localRow: any) => localRow + pageRowOffset();
// A body row's ABSOLUTE display-order index = its page-relative index + the page offset. Drives
// aria-rowindex on the non-virtual paginated body (B27); the virtual path uses wr.vi.index
// directly (already absolute). Reactive via rowIndexOf's tick().
// Total filtered+sorted PRE-pagination row count — the clamp bound for an absolute focusCell.
// In virtual mode $data.rows IS the full pre-pagination model (bodyRowCount suffices); in the
// non-virtual paginated body $data.rows is only the page slice, so read the live model.
const prePaginationRowCount = () => {
  if (!table || props.virtual) return bodyRowCount();
  const pm = table.getPrePaginationRowModel();
  return pm && pm.rows ? pm.rows.length : bodyRowCount();
};
// Roving tabindex (RESEARCH Code Examples). Reads ONLY reactive $data (ROZ123-safe,
// fine-grained-reactive). Returns null in 'table' mode → the bound numeric attribute
// DROPS entirely (IN-01: on React via the `cellTabindex(...) ?? undefined` numeric-attr
// emitter path landed in 4bec3b8e — NOT rozieAttr, which would string-widen tabIndex and
// TS2322; the other five targets drop it via their own nullish-attr handling), keeping
// 'table'-mode DOM clean. rowKey is the literal
// '__header' for header cells or the String(bodyRowIndex) for body cells, so the active
// header state (activeIsHeader) is addressable through the same computed.
const cellTabindex = (rowKey: any, colIndex: any, level = null) => {
  if (!isGrid()) return null;
  // B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
  // the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
  // tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
  if (bodyRowCount() === 0) {
    return rowKey === '__header' && colIndex === 0 && level === headerLeafLevel() ? 0 : -1;
  }
  // B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
  // grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
  // lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
  // tab-stops (the roving invariant broke under grouped headers).
  if (activeIsHeader.value) {
    if (rowKey !== '__header') return -1;
    return colIndex === activeColIndex.value && level === activeHeaderLevel.value ? 0 : -1;
  }
  const isActive = rowKey === String(activeRow.value) && colIndex === activeColIndex.value;
  return isActive ? 0 : -1;
};
// ── Active-cell ring predicate (grid pointer §1, 260708-ni6) ───────────────────────────
// isActiveCell mirrors cellTabindex's ACTIVE branch (the same (rowKey, colIndex, level)
// address tuples the roving tabindex uses) but returns a BOOLEAN for the `.rdt-cell-active`
// :class binding, and is STATE-DRIVEN — so the ring shows identically on click AND keyboard
// (independent of :focus-visible, which browsers gate off for a mouse-focused non-text <td>).
// It DELIBERATELY omits cellTabindex's B6 empty-grid / header-fallback branch: the ring must
// NOT light on an empty grid's synthetic tab-stop (there is no real active cell there). Reads
// ONLY reactive $data (ROZ123-safe, fine-grained). Returns false in 'table' mode so table-mode
// markup is byte-behaviorally unchanged. Header cells are active only while activeIsHeader is
// true (addressed by BOTH colIndex and level — a grouped multi-level header carries exactly one
// ring); body cells only while activeIsHeader is false.
const isActiveCell = (rowKey: any, colIndex: any, level = null) => {
  if (!isGrid()) return false;
  if (activeIsHeader.value) {
    if (rowKey !== '__header') return false;
    return colIndex === activeColIndex.value && level === activeHeaderLevel.value;
  }
  if (rowKey === '__header') return false;
  return rowKey === String(activeRow.value) && colIndex === activeColIndex.value;
};
// ── The focus SEAM (RESEARCH Pattern 1 + 3, req-6) ─────────────────────────────────────
// resolveCellEl: index pair → DOM element, via a data-* attribute query off the stable
// post-mount root. Uniform on all six, shadow-safe (the query runs from inside the
// component's own scope). rowKey is the literal '__header' or a String(integer index) and
// colIndex is an integer — NO consumer string is interpolated into the selector (T-49-01).
const resolveCellEl = (rowKey: any, colIndex: any, level = null) => {
  if (!gridRoot) return null;
  // B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
  // same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
  // integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
  // match (the parent) when the leaf is meant. level is an integer (NO consumer string is
  // interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
  // byte-unchanged.
  let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
  if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
  return gridRoot.querySelector(sel);
};
// focusActiveCell: THE single DOM-focus-resolution path (req-6). Every focus change —
// the D-04 entry cell here, and (plan 03) arrow nav / focusCell() / the data-change clamp —
// routes through this one function, so a verifier can point to it and phase 53 windowing
// hooks it without a rewrite. Accepts OPTIONAL explicit (nextRow,nextCol) so callers can
// pass FRESH post-write locals (React ROZ138 / Angular signal async — pinned by plan 01);
// falls back to $data when none passed. NEVER stores a DOM node (index-only state).
// 260618-ao9 — params carry explicit `= null` defaults so the cross-target
// emitters type them OPTIONAL (untyped params lower to REQUIRED `any`, making the
// 2-arg `focusActiveCell(r, c)` call sites a TS2554 on React/Solid/Lit — a
// pre-existing regression from the d7166c5e header-crossing `nextIsHeader` add).
// The `= null` default reproduces the documented "falls back to $data when
// omitted" contract: an omitted arg arrives as `null`, and the body's `== null`
// checks already route those to the live `$data` value — behavior-identical.
const focusActiveCell = (nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) => {
  if (!isGrid() || !gridRoot) return;
  // #9 focus-intent epoch: focusActiveCell is THE single seam every keyboard nav re-asserts
  // focus through, so it establishes a fresh "where focus should be" on every call — bump the
  // epoch here (BEFORE arming the virtual-scroll focusWhenReady poll below). A SUBSEQUENT
  // focusActiveCell (the next user nav) bumps again → any pending focusWhenReady captured the
  // OLD value → aborts instead of yanking focus back. The poll captures the POST-bump value so
  // a lone scroll-to-focus with no later nav still lands (epoch stable across its own frames).
  focusIntentEpoch = focusIntentEpoch + 1;
  const r = nextRow == null ? activeRow.value : nextRow;
  const c = nextCol == null ? activeColIndex.value : nextCol;
  // B12: thread the FRESH post-write header level (the grouped-header analog of the
  // nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
  // level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
  const lvl = nextLevel == null ? activeHeaderLevel.value : nextLevel;
  // Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
  // crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
  // Angular's signal write are async within one handler — re-reading $data.activeIsHeader
  // here returns the PRE-write value, resolving focus to the BODY cell instead of the
  // header. Callers pass the fresh isHeader local; falls back to $data when omitted.
  const header = nextIsHeader == null ? activeIsHeader.value : nextIsHeader;
  // ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
  // rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
  // double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
  // in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
  // The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
  // while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
  if (props.virtual && virtualizer && !header && rowIsOutsideWindow(r)) {
    virtualizer.scrollToIndex(r, {
      align: 'center'
    });
    // Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
    // bump → the framework commits the scrolled-in row. On React that commit is async (setState →
    // reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
    // fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
    // React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
    // fast-committing targets resolve on the first attempt (behavior unchanged), React retries
    // across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
    // cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
    let focusAttempts = 0;
    // #9: capture the epoch AFTER this call's own bump (above) so the poll never aborts itself
    // (its captured value equals the current epoch). A LATER focusActiveCell / focusCell /
    // active-cell-moving focusin bumps the epoch → the check below aborts this stale poll.
    const myEpoch = focusIntentEpoch;
    const focusWhenReady = () => {
      // A newer focus intent superseded this poll — abort WITHOUT focusing (the user has since
      // navigated / clicked elsewhere; re-focusing this off-window target would yank focus back).
      if (focusIntentEpoch !== myEpoch) return;
      const el = resolveCellEl(String(r), c);
      if (el) {
        el.focus();
        return;
      }
      focusAttempts = focusAttempts + 1;
      if (focusAttempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
    return;
  }
  const rowKey = header ? '__header' : String(r);
  const el = resolveCellEl(rowKey, c, header ? lvl : null);
  if (el) el.focus();
};
// ══ Grid keyboard navigation (phase 49 plan 03 — RESEARCH Pattern 5 + the delegated handler) ═══
// The nav model is plain ARRAY-INDEX MATH over the VISIBLE model. table-core has already
// done the hard part: $data.rows (body) and $data.headerGroups (header) hold the visible,
// reordered, pinned cell set (row.getVisibleCells() / getHeaderGroups()) — hidden columns
// are ALREADY ABSENT, reorder/pinning is ALREADY REFLECTED (REQ-7). There is NO separate
// "compute visible order" step. Every index is clamped to [0,max] so an out-of-range key
// never throws or builds an injection-shaped selector (Security V5 / T-49-03).

// IN-01: aria-rowcount for the NON-VIRTUAL table. The virtual table binds $data.rows.length
// (the full pre-pagination model). For the non-virtual path $data.rows is the PAGINATED slice,
// so report the FILTERED (pre-pagination) total instead — the count AT users need to know "row N
// of TOTAL". Falls back to $data.rows.length pre-mount (table is null until $onMount).
// NB the helper is named `totalRowCount`, NOT `ariaRowCount`: `ariaRowCount` is an inherited
// HTMLElement ARIA-reflected property (`Element.ariaRowCount: string`), so a same-named method
// becomes a class field that shadows it on Lit → TS2416 cascades to EVERY @property decorator
// (the `valueOf`/`nodeType` inherited-DOM-member collision class, authoring playbook §6).
const totalRowCount = () => {
  if (!table) return (rows.value || []).length;
  const fm = table.getFilteredRowModel();
  return fm && fm.rows ? fm.rows.length : (rows.value || []).length;
};
// ── A11y row bookkeeping (#13): consistent aria-rowindex / aria-rowcount ──────────────
// WAI-ARIA: when aria-rowcount is set on the grid/table, EVERY row (header rows + body rows)
// must carry an aria-rowindex, and aria-rowcount must equal the total number of rows INCLUDING
// the header rows. Before this fix aria-rowcount was set unconditionally to totalRowCount() but
// aria-rowindex was grid-only — so a paginated 'table'-mode grid advertised e.g. rowcount=100
// while its 10 visible rows carried NO index (SR announced "row 1..10 of 100" on the LAST page).
//   headerRowCount = the columnheader rows ($data.headerGroups — a grouped/multi-level header is
//     >1; the role="presentation" filter row is NOT a row and is excluded).
//   gridAriaRowCount = header rows + the FILTERED pre-pagination data total → equals the largest
//     aria-rowindex any body row carries, so count and indices are always mutually consistent.
// NB the helpers are gridAriaRowCount / bodyAriaRowIndex, NOT ariaRowCount / ariaRowIndex: the
// latter collide with the inherited HTMLElement.ariaRowCount / .ariaRowIndex reflected properties
// on Lit (TS2416 — the same inherited-DOM-member collision class as totalRowCount's rename note).
const headerRowCount = () => (headerGroups.value || []).length;
const gridAriaRowCount = () => headerRowCount() + totalRowCount();
// Page offset that is MODE-INDEPENDENT (works in BOTH 'table' and 'grid' mode), unlike
// pageRowOffset() which is isGrid()-gated for the active-cell API. In the non-virtual body
// $data.rows is only the page slice, so a data row's ABSOLUTE index = its page-relative
// rowIndexOf + this offset. Virtual mode never reaches here (that body uses wr.vi.index).
const ariaPageOffset = () => table ? pageIndex() * pageSize() : 0;
// A non-virtual body row's 1-based aria-rowindex: the header rows come first (headerRowCount),
// then the absolute (page-aware) 0-based data index, +1 to 1-base it. Present in BOTH modes so
// it is always consistent with gridAriaRowCount. The virtual body binds
// `headerRowCount() + wr.vi.index + 1` inline (wr.vi.index is already the absolute full-model index).
const bodyAriaRowIndex = (row: any) => headerRowCount() + rowIndexOf(row) + ariaPageOffset() + 1;
// Column count = the visible cell list length (uniform header+body in a flat grid). Reads
// $data.rows (reactive) so it is fine-grained-correct on Solid/Lit; falls back to the
// header leaf count when there are no body rows.
const visibleColCount = () => {
  // NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
  // state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
  // self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
  const rowList = rows.value || [];
  if (rowList.length) return rowList[0].getVisibleCells().length;
  const hg = headerGroups.value || [];
  return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
};
const bodyRowCount = () => (rows.value || []).length;
const clamp = (v: any, lo: any, hi: any) => v < lo ? lo : v > hi ? hi : v;
// ── Multi-level (grouped) header addressing (B12) ──────────────────────────────────────
// $data.headerGroups is ordered top→bottom; the LEAF header row (the one adjacent to the
// body) is the LAST group. The roving active-header state carries activeHeaderLevel (the
// group index) alongside activeColIndex (the index within THAT level's headers) so the
// single-tab-stop invariant + ArrowUp parent-resolution span every header level — a flat
// grid has one level (leafLevel 0), so the table-mode/flat path is unchanged.
const headerLeafLevel = () => {
  const hg = headerGroups.value || [];
  return hg.length ? hg.length - 1 : 0;
};
// #10: the number of header cells AT a given level. A grouped PARENT level may have FEWER
// headers than there are leaf columns (one parent spans several leaves), so horizontal nav on a
// non-leaf header must clamp against THIS count — not visibleColCount() (the leaf-column count),
// which would let ArrowRight/End overrun into a phantom (null) cell → focus dropped to <body>.
// Degenerate cases (no headerGroups, level out of range) fall back to visibleColCount() so the
// clamp is never negative or NaN. The LEAF level's count equals visibleColCount() (one header per
// visible leaf column), so leaf-header + body horizontal nav is byte-behaviorally unchanged.
const headerCountAtLevel = (level: any) => {
  const hg = headerGroups.value || [];
  if (!hg.length) return visibleColCount();
  const grp = level >= 0 && level < hg.length ? hg[level] : null;
  if (!grp || !grp.headers) return visibleColCount();
  return grp.headers.length;
};
const headerAt = (level: any, colIndex: any) => {
  const hg = headerGroups.value || [];
  const grp = hg[level];
  if (!grp || !grp.headers) return null;
  return grp.headers[colIndex] || null;
};
// ArrowUp from a (level, colIndex) leaf/child header → the index of its PARENT header in the
// level above (the parent column that spans it, via table-core header.column.parent). -1 when
// there is no real parent (already at the top, or a placeholder with no group) → the caller
// keeps the active header where it is.
const parentHeaderColIndex = (level: any, colIndex: any) => {
  if (level <= 0) return -1;
  const h = headerAt(level, colIndex);
  if (!h || !h.column || !h.column.parent) return -1;
  const parentId = h.column.parent.id;
  const hg = headerGroups.value || [];
  const pg = hg[level - 1];
  if (!pg || !pg.headers) return -1;
  for (let i = 0; i < pg.headers.length; i++) {
    const ph = pg.headers[i];
    if (ph && ph.column && ph.column.id === parentId) return i;
  }
  return -1;
};
// ArrowDown from a (level, colIndex) GROUP header → the index of its FIRST child header in the
// level below (via table-core column.columns). -1 when the header has no child columns (a leaf)
// → the caller drops into the body instead.
const firstChildHeaderColIndex = (level: any, colIndex: any) => {
  const h = headerAt(level, colIndex);
  if (!h || !h.column) return -1;
  const kids = h.column.columns || [];
  if (!kids.length) return -1;
  const childId = kids[0].id;
  const hg = headerGroups.value || [];
  const cg = hg[level + 1];
  if (!cg || !cg.headers) return -1;
  for (let i = 0; i < cg.headers.length; i++) {
    const ch = cg.headers[i];
    if (ch && ch.column && ch.column.id === childId) return i;
  }
  return -1;
};
// ── Nav helpers: compute the NEXT indices into LOCAL consts, write $data from them, and
// RETURN the fresh locals so the caller threads the SAME values into BOTH focusActiveCell
// AND the activecell-change emit. NEVER re-read $data.activeRow/activeColIndex after the
// write (React setState is async — ROZ138 — the re-read binds the PRE-write value; Angular
// signal writes are async too — both proven live by plan 01's probe). ──────────────────────

// ArrowRight/Left — clamp colIndex over [0, visibleColCount()-1] (no wrap; hidden cols
// already excluded from the visible list per REQ-7).
const moveCol = (delta: any) => {
  // #10: when a grouped PARENT header is active, clamp against the header count AT THE ACTIVE
  // LEVEL (which may be fewer than the leaf-column count) so ArrowRight never overruns onto a
  // phantom cell past that level's headers. Body cells + the leaf header level keep visibleColCount().
  const count = activeIsHeader.value ? headerCountAtLevel(activeHeaderLevel.value) : visibleColCount();
  const max = count - 1;
  const nextCol = clamp(activeColIndex.value + delta, 0, max < 0 ? 0 : max);
  activeColIndex.value = nextCol;
  return nextCol;
};
// ArrowUp/Down + PageUp/Down — cross the header boundary and clamp at body edges (no
// page-cross per D-06/REQ-7). Returns { row, isHeader } fresh locals.
//  - From the header, ArrowDown (delta>0) drops into body row 0 (activeIsHeader=false).
//  - From body row 0, ArrowUp (delta<0) crosses into the header (activeIsHeader=true).
//  - PageUp/Down jump by ±GRID_PAGE_STEP, clamped to the current page bounds (no cross).
const moveRow = (delta: any) => {
  const lastRow = bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const leafLevel = headerLeafLevel();
  if (activeIsHeader.value) {
    if (delta > 0) {
      // B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
      // level down); from the LEAF header level, drop into the body (row 0). A header-level
      // move re-targets activeColIndex (parent↔child column indices differ), so the fresh
      // col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
      if (activeHeaderLevel.value < leafLevel) {
        const childCol = firstChildHeaderColIndex(activeHeaderLevel.value, activeColIndex.value);
        if (childCol >= 0) {
          const nextLevel = activeHeaderLevel.value + 1;
          activeHeaderLevel.value = nextLevel;
          activeColIndex.value = childCol;
          return {
            row: activeRow.value,
            col: childCol,
            isHeader: true,
            level: nextLevel
          };
        }
      }
      // At the leaf header: an empty grid has no body to drop into → stay put.
      if (bodyRowCount() === 0) return {
        row: activeRow.value,
        col: activeColIndex.value,
        isHeader: true,
        level: activeHeaderLevel.value
      };
      // B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
      // (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
      // page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
      // (delta-1 = 0); clamped to the page-last body row.
      const landRow = clamp(delta - 1, 0, maxRow);
      activeIsHeader.value = false;
      activeRow.value = landRow;
      return {
        row: landRow,
        col: activeColIndex.value,
        isHeader: false,
        level: 0
      };
    }
    // B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
    // spans the active column; at the top level (or no real parent) stay put. The parent col
    // index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
    const parentCol = parentHeaderColIndex(activeHeaderLevel.value, activeColIndex.value);
    if (parentCol >= 0) {
      const nextLevel = activeHeaderLevel.value - 1;
      activeHeaderLevel.value = nextLevel;
      activeColIndex.value = parentCol;
      return {
        row: activeRow.value,
        col: parentCol,
        isHeader: true,
        level: nextLevel
      };
    }
    return {
      row: activeRow.value,
      col: activeColIndex.value,
      isHeader: true,
      level: activeHeaderLevel.value
    };
  }
  // In the body: an upward move from row 0 crosses into the LEAF header level (the header row
  // adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
  // activeColIndex carries over unchanged.
  if (delta < 0 && activeRow.value === 0) {
    activeIsHeader.value = true;
    activeHeaderLevel.value = leafLevel;
    return {
      row: activeRow.value,
      col: activeColIndex.value,
      isHeader: true,
      level: leafLevel
    };
  }
  const nextRow = clamp(activeRow.value + delta, 0, maxRow);
  activeRow.value = nextRow;
  activeIsHeader.value = false;
  return {
    row: nextRow,
    col: activeColIndex.value,
    isHeader: false,
    level: 0
  };
};
// Home/End within the current row → col 0 / max. Returns the fresh colIndex.
const gotoColEdge = (toEnd: any) => {
  // #10: End on a grouped PARENT header lands on that level's LAST header (headerCountAtLevel-1),
  // not the leaf-column max — otherwise the ring strands on a phantom cell past the level's
  // headers. Home is index 0 either way. Body cells + the leaf header level keep visibleColCount().
  const count = activeIsHeader.value ? headerCountAtLevel(activeHeaderLevel.value) : visibleColCount();
  const max = count - 1;
  const nextCol = toEnd ? max < 0 ? 0 : max : 0;
  activeColIndex.value = nextCol;
  return nextCol;
};
// gotoRowEdge(toEnd): the §8 (260709-3qt) Ctrl+ArrowUp/Down vertical region-edge jump — move the
// active cell to the data-region row edge (row 0 / last body row) in the CURRENT column, mirroring
// gotoColEdge's horizontal edge jump. Body cells only (the caller gates on !activeIsHeader); always
// lands in the body (activeIsHeader=false). Returns the fresh row index for the shared focus seam.
const gotoRowEdge = (toEnd: any) => {
  const lastRow = bodyRowCount() - 1;
  const nextRow = toEnd ? lastRow < 0 ? 0 : lastRow : 0;
  activeRow.value = nextRow;
  activeIsHeader.value = false;
  return nextRow;
};
// Ctrl+Home → first body cell (0,0); Ctrl+End → last body cell (lastRow,max). Returns the
// fresh { row, col } locals. Both land in the body (activeIsHeader=false).
const gotoStart = () => {
  activeIsHeader.value = false;
  activeRow.value = 0;
  activeColIndex.value = 0;
  return {
    row: 0,
    col: 0
  };
};
const gotoEnd = () => {
  const lastRow = bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const max = visibleColCount() - 1;
  const maxCol = max < 0 ? 0 : max;
  activeIsHeader.value = false;
  activeRow.value = maxRow;
  activeColIndex.value = maxCol;
  return {
    row: maxRow,
    col: maxCol
  };
};
// Resolve the active cell element (for the in-cell trap) — uses the same data-* query as
// the focus seam. rowKey is the literal '__header' or String(integer) — no consumer string.
const currentCellEl = () => {
  const rowKey = activeIsHeader.value ? '__header' : String(activeRow.value);
  return resolveCellEl(rowKey, activeColIndex.value, activeIsHeader.value ? activeHeaderLevel.value : null);
};
// The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
const focusables = (cellEl: any) => {
  if (!cellEl || !cellEl.querySelectorAll) return [];
  const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
  return list.filter((n: any) => !n.disabled);
};
// Enter/F2 → enter interaction mode: focus the active cell's FIRST interactive control
// (D-07 — uniform for header sort buttons and body controls; Enter does NOT sort directly).
// No-op (stay in navigation mode) if the cell has no focusable control.
const enterControl = () => {
  const cellEl = currentCellEl();
  const list = focusables(cellEl);
  if (!list.length) return;
  activeInControl.value = true;
  list[0].focus();
};
// Cycle focus among the controls WITHIN the active cell (D-08 focus containment) — Tab
// forward / Shift+Tab backward, wrapping at the ends. Uses the plan-01-PROVEN per-target
// activeElement read: gridRoot.getRootNode().activeElement is the UNIFORM correct read on
// ALL SIX (document in light DOM; the shadow root on Lit). Reuse verbatim — do NOT re-derive.
const cycleWithinCell = (cellEl: any, forward: any) => {
  const list = focusables(cellEl);
  if (!list.length) return;
  const active = gridRoot ? gridRoot.getRootNode().activeElement : null;
  const cur = list.indexOf(active);
  let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
  if (i >= list.length) i = 0;
  if (i < 0) i = list.length - 1;
  list[i].focus();
};
// THE single delegated keydown handler (RESEARCH "Single delegated keydown handler"). Wired
// as ONE keydown listener on the <table> root — NOT per-cell, NOT with .stop/.prevent modifiers (the
// Angular .stop-in-@for hoist bug, F5/ROZ723). e.preventDefault() is called IMPERATIVELY for
// handled keys. Each nav helper writes $data and RETURNS the fresh post-write locals; those
// SAME locals feed BOTH focusActiveCell AND the activecell-change emit (no $data re-read).
const onGridKeyDown = (e: any) => {
  if (!isGrid() || !e) return;
  const key = e.key;
  // Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
  // via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
  // early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
  // modes (editing / in-control / navigation) stay mutually exclusive and ordered.
  if (editingRow.value >= 0) return;
  // Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
  // editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
  // keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
  if (editingRowIndex.value != null) return;
  // Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
  if (activeInControl.value) {
    if (key === 'Escape') {
      e.preventDefault();
      activeInControl.value = false;
      // Return focus to the OWNING cell (no move happened) — pass the current indices
      // explicitly (the React-emitted seam types both params as required; a zero-arg call
      // is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
      focusActiveCell(activeRow.value, activeColIndex.value);
    } else if (key === 'Tab') {
      e.preventDefault();
      cycleWithinCell(currentCellEl(), !e.shiftKey);
    }
    return;
  }
  // WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
  // grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
  // user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
  // NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
  // focused node; if it is not itself a [data-grid-cell], let the event pass through.
  const tgt = e.target;
  if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
  // Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
  // nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
  // async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
  // plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
  // in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
  // fires ONLY on a real move (a clamped no-op edge move leaves them identical).
  const prevRow = activeRow.value;
  const prevCol = activeColIndex.value;
  const prevIsHeader = activeIsHeader.value;
  const prevLevel = activeHeaderLevel.value;
  let nextRow = prevRow;
  let nextCol = prevCol;
  let nextIsHeader = prevIsHeader;
  // B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
  // threaded into the focus seam so a leaf↔parent header move lands focus at the correct
  // level. moveRow returns it; the non-vertical branches keep the pre-move level.
  let nextLevel = prevLevel;
  // ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
  // the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
  // fall through to a plain navigation move). Body cells only (no range from a header). The
  // extendRange call owns focus + the range-change emit, so return immediately. ──────────
  // ── §8 (260709-3qt) Ctrl/Cmd+Arrow — jump the active cell to the data-region edge (plain
  // Ctrl) or EXTEND the range to that edge (Ctrl+Shift). Body cells only (a header-active
  // Ctrl+Arrow falls through to the plain-arrow branches unchanged). Tested BEFORE the
  // Shift+Arrow / plain-arrow cascade so the modifier combo is matched first. preventDefault
  // suppresses the browser's native Ctrl+Arrow scroll/word-jump. The Ctrl+Shift branch owns
  // extendRange's focus + range-change emit (returns); the plain-Ctrl branch sets the fresh
  // nextRow/nextCol locals and FALLS THROUGH to the shared focus seam (like Ctrl+Home/End). ──
  if ((e.ctrlKey || e.metaKey) && e.shiftKey && !activeIsHeader.value && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
    e.preventDefault();
    if (key === 'ArrowUp') extendRange(-activeRow.value, 0);else if (key === 'ArrowDown') extendRange(bodyRowCount() - 1 - activeRow.value, 0);else if (key === 'ArrowLeft') extendRange(0, -activeColIndex.value);else extendRange(0, visibleColCount() - 1 - activeColIndex.value);
    return;
  } else if ((e.ctrlKey || e.metaKey) && !activeIsHeader.value && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
    e.preventDefault();
    clearRange();
    if (key === 'ArrowUp') {
      nextRow = gotoRowEdge(false);
      nextIsHeader = false;
    } else if (key === 'ArrowDown') {
      nextRow = gotoRowEdge(true);
      nextIsHeader = false;
    } else if (key === 'ArrowLeft') {
      nextCol = gotoColEdge(false);
    } else {
      nextCol = gotoColEdge(true);
    }
  } else if (key === 'ArrowRight' && e.shiftKey && !activeIsHeader.value) {
    e.preventDefault();
    extendRange(0, 1);
    return;
  } else if (key === 'ArrowLeft' && e.shiftKey && !activeIsHeader.value) {
    e.preventDefault();
    extendRange(0, -1);
    return;
  } else if (key === 'ArrowDown' && e.shiftKey && !activeIsHeader.value) {
    e.preventDefault();
    extendRange(1, 0);
    return;
  } else if (key === 'ArrowUp' && e.shiftKey && !activeIsHeader.value) {
    e.preventDefault();
    extendRange(-1, 0);
    return;
  } else if (key === 'ArrowRight') {
    e.preventDefault();
    clearRange();
    nextCol = moveCol(1);
  } else if (key === 'ArrowLeft') {
    e.preventDefault();
    clearRange();
    nextCol = moveCol(-1);
  } else if (key === 'ArrowDown') {
    e.preventDefault();
    clearRange();
    const m = moveRow(1);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'ArrowUp') {
    e.preventDefault();
    clearRange();
    const m = moveRow(-1);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'PageDown') {
    e.preventDefault();
    const m = moveRow(GRID_PAGE_STEP);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'PageUp') {
    e.preventDefault();
    const m = moveRow(-GRID_PAGE_STEP);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'Home') {
    e.preventDefault();
    if (e.ctrlKey || e.metaKey) {
      const s = gotoStart();
      nextRow = s.row;
      nextCol = s.col;
      nextIsHeader = false;
    } else {
      nextCol = gotoColEdge(false);
    }
  } else if (key === 'End') {
    e.preventDefault();
    if (e.ctrlKey || e.metaKey) {
      const en = gotoEnd();
      nextRow = en.row;
      nextCol = en.col;
      nextIsHeader = false;
    } else {
      nextCol = gotoColEdge(true);
    }
  }
  // ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
  // pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
  // edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
  // type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
  // gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
  // falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
  // (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
  else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
    e.preventDefault();
    copyRange();
    return;
  } else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
    e.preventDefault();
    pasteRange();
    return;
  }
  // ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
  // source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
  // Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
  // and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
  // shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
  else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
    e.preventDefault();
    cutRange();
    return;
  }
  // ── 260709-8ct (grid-wide undo/redo) — Ctrl/Cmd+Z undoes; Ctrl/Cmd+Y OR Ctrl/Cmd+Shift+Z
  // redoes. Undoable-gated (`$props.undoable`) — when off, neither preventDefault nor
  // undo()/redo() runs, so a shipped grid with undoable unset is byte-behaviorally unchanged
  // (the browser's own native undo/redo, if any, still fires). NOT clipboardActiveAllowed-
  // gated (unlike Ctrl+C/V/X/Delete above): undo/redo is GRID-WIDE and must work regardless of
  // whether a header or body cell is active. Tested the Ctrl+Shift+Z (redo) combo BEFORE the
  // plain Ctrl+Z (undo) branch so a Shift+Z never falls into undo.
  else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey) && e.shiftKey) {
    if (props.undoable) {
      e.preventDefault();
      redo();
      return;
    }
  } else if ((key === 'y' || key === 'Y') && (e.ctrlKey || e.metaKey)) {
    if (props.undoable) {
      e.preventDefault();
      redo();
      return;
    }
  } else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey)) {
    if (props.undoable) {
      e.preventDefault();
      undo();
      return;
    }
  }
  // ── §7 (260709-3qt) — Delete/Backspace CLEARS the active cell / range through the SAME
  // write-funnel as Cut (applyGridToRange of an empty grid), MINUS the clipboard copy. B11-gated
  // by clipboardActiveAllowed so a header-active Delete/Backspace falls through to NATIVE behavior
  // (never a silent body mutation). The top-of-handler editing early-returns + the line-39
  // data-grid-cell guard keep this to navigation mode; applyGridToRange skips read-only/non-editable
  // cells. Reversible via Ctrl+Z when `undoable` is on (260709-8ct) — clearActiveRange funnels
  // through the SAME writeData seam undo/redo replay through, so no separate inverse machinery
  // is needed here.
  else if ((key === 'Delete' || key === 'Backspace') && clipboardActiveAllowed()) {
    e.preventDefault();
    clearActiveRange();
    return;
  }
  // ── §8 (260709-3qt) — Ctrl/Cmd+A selects the WHOLE BODY range (drives the same range corners
  // shift+arrow uses). preventDefault ALWAYS so the page is never selected in grid mode; only a
  // body-active Ctrl+A builds the range (a header-active Ctrl+A is a no-op — selects nothing). ──
  else if ((key === 'a' || key === 'A') && (e.ctrlKey || e.metaKey)) {
    e.preventDefault();
    if (!activeIsHeader.value) selectAllBody();
    return;
  }
  // ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
  // EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
  // (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
  // collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
  // at least the active editable column); a non-editable active cell falls through unchanged.
  else if (key === 'F2' && e.shiftKey && isActiveCellEditable()) {
    e.preventDefault();
    beginRowEdit((rows.value || [])[activeRow.value]);
    return;
  }
  // ── Boolean in-place toggle (design doc 2026-07-05, Change 1) — a built-in
  // editor:'checkbox' cell toggles + commits INSTANTLY on Space/Enter/F2, no editor opens
  // (the spreadsheet-standard shape for a two-state value). Tested BEFORE the generic
  // Enter/F2 edit-entry branch below (a checkbox cell must never fall into the open-an-
  // editor ceremony) and gated the SAME way (isActiveCellEditable) plus editorTypeOf ===
  // 'checkbox'. Full-row edit mode is unaffected — the editingRowIndex early return at the
  // top of onGridKeyDown already excludes it.
  else if ((key === 'Enter' || key === 'F2' || key === ' ') && isActiveCellEditable() && editorTypeOf(activeCellColumnId()) === 'checkbox') {
    e.preventDefault();
    toggleActiveBooleanCell();
    return;
  }
  // ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
  // Gated by isActiveCellEditable(): a non-editable active cell falls through to
  // enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
  // (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
  else if ((key === 'Enter' || key === 'F2') && isActiveCellEditable()) {
    e.preventDefault();
    beginEdit(activeRow.value, activeColIndex.value, null);
    return;
  } else if (isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && editorTypeOf(activeCellColumnId()) !== 'checkbox') {
    // B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
    // checkbox/select/date editor must NOT take the typed char as its value (it would
    // force-check the checkbox, seed a garbage select option, or corrupt the date) — open
    // those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
    // Checkbox is excluded entirely (type-to-edit disabled — the branch above already
    // handles Space/Enter/F2; any OTHER printable key on a checkbox cell is a no-op).
    e.preventDefault();
    const editType = editorTypeOf(activeCellColumnId());
    const seed = editType === 'text' || editType === 'number' ? key : null;
    beginEdit(activeRow.value, activeColIndex.value, seed);
    return;
  }
  // ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
  // expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
  // verified invariant) so it never hits the edit branches above and would otherwise fall to
  // enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
  // Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
  // model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
  // ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
  // full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
  else if (key === 'Enter' && !activeIsHeader.value && rowIsGrouped((rows.value || [])[activeRow.value])) {
    e.preventDefault();
    // C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
    // active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
    // the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
    // row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
    // fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
    // DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
    // while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
    // settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
    // onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
    // SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
    // until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
    // already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
    const grpRow = activeRow.value;
    const grpCol = activeColIndex.value;
    onToggleExpand((rows.value || [])[activeRow.value], e);
    // guardMoved=true: the group header row is UNCHANGED by its own collapse, so a stale late
    // rAF poll must not steal focus back after the user has already ArrowDown'd to another row.
    recoverGridFocus(String(grpRow), grpCol, null, true);
    return;
  } else if (key === 'Enter' || key === 'F2') {
    e.preventDefault();
    enterControl();
    return;
  } else return;
  // THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
  // focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
  focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
  // WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
  // changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
  // row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
  // B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
  // C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
  // keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
  // toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
  // The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
  if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
    // Mirror getActiveCell's shape (this payload + getActiveCell are documented to speak the
    // SAME language): a header cell has no body-row index, so emit rowIndex:null + isHeader:true
    // rather than a bogus toAbsRow(nextRow) — which would compute a real body-row absolute index
    // for a HEADER move, misleading a consumer into thinking that body row is the active cell.
    emit('activecell-change', nextIsHeader ? {
      rowIndex: null,
      colIndex: nextCol,
      isHeader: true
    } : {
      rowIndex: toAbsRow(nextRow),
      colIndex: nextCol,
      isHeader: false
    });
  }
};
// WR-03: integrate mouse-click + programmatic focus with the roving model. A click on a
// tabindex="-1" cell (or focus arriving any way other than the keyboard nav path) moves
// DOM focus there but does NOT run onGridKeyDown — so activeRow/activeColIndex would stay
// on the OLD cell and the NEXT arrow key would jump from the stale active cell. Wired as
// ONE @focusin on the <table> root (focusin bubbles): resolve the focused element's owning
// [data-grid-cell], parse its data-row/data-col-index, and write them into the active-cell
// state (mirroring the keyboard path). Clears activeInControl ONLY when the cell ITSELF
// (not an inner control) received focus — focusing a control via Enter keeps the in-control
// flag. NEVER emits activecell-change (a focus sync is not a keyboard navigation event).
const syncActiveFromEvent = (e: any) => {
  if (!isGrid() || !e) return;
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null) return;
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(col)) return;
  // #9: snapshot the PRE-write active position so we can bump the focus-intent epoch ONLY when
  // this focusin genuinely MOVES the active cell (a click landing on a NEW cell). A no-op focusin
  // — focus arriving on the ALREADY-active cell, e.g. a scroll/page-switch poll's own el.focus()
  // or focusActiveCell's synchronous re-seat — must NOT bump, or it would abort a legitimate
  // in-flight recovery on its own settling frames (the poll would see a changed epoch and quit).
  const prevIsHeader = activeIsHeader.value;
  const prevRow = activeRow.value;
  const prevCol = activeColIndex.value;
  const prevLevel = activeHeaderLevel.value;
  const isHeader = rowAttr === '__header';
  activeIsHeader.value = isHeader;
  let movedRow = prevRow;
  let movedLevel = prevLevel;
  if (isHeader) {
    // B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
    // roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
    // one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
    const lvlAttr = cellEl.getAttribute('data-header-level');
    const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : headerLeafLevel();
    movedLevel = Number.isFinite(lvl) ? lvl : headerLeafLevel();
    activeHeaderLevel.value = movedLevel;
  } else {
    const row = parseInt(rowAttr, 10);
    if (Number.isFinite(row)) {
      movedRow = row;
      activeRow.value = row;
    }
  }
  activeColIndex.value = col;
  // #9: a genuine active-cell MOVE is a fresh focus intent — supersede any pending async focus
  // poll (scroll-to / page-switch). Compare against the PRE-write snapshot: bump only when the
  // header-flag, column, or (per mode) the header LEVEL / body ROW actually changed.
  if (isHeader !== prevIsHeader || col !== prevCol || (isHeader ? movedLevel !== prevLevel : movedRow !== prevRow)) {
    focusIntentEpoch = focusIntentEpoch + 1;
  }
  // A plain focus collapses any range back to the single active cell — EXCEPT (a) the
  // programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
  // ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
  // focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
  // BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
  // path owns the shift case and flags it here so the collapse is skipped.
  if (rangeTransition) {
    rangeTransition = false;
  } else if (rangeClickPending) {
    rangeClickPending = false;
  } else {
    clearRange();
  }
  // The cell box (not an inner control) receiving focus = navigation mode.
  if (tgt === cellEl) activeInControl.value = false;
};
// onGridMouseDown: the pointer range seam (phase 51 req-7 / D-07 Shift+Click; §6 260709-3qt
// plain drag-to-select). A focusin event carries no reliable `shiftKey`, so the modifier MUST
// be read off the pointer event — @mousedown fires BEFORE the cell's focusin and DOES carry
// shiftKey. A shift-held mousedown on a BODY cell sets the range's moving corner to that cell
// (keeping the anchor), then flags rangeClickPending so the follow-up focusin does not collapse
// the range. A PLAIN (non-shift) mousedown BEGINS a drag-select anchored at that cell (§6): the
// document pointermove/up listeners paint the range as the pointer moves. The fill handle owns
// its own @pointerdown drag (it stops propagation), so a plain mousedown originating inside it is
// skipped. Do NOT preventDefault — native focus must still land (focusin sync + roving tabindex).
const onGridMouseDown = (e: any) => {
  if (!isGrid() || !e) return;
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  // §6: a plain mousedown inside the fill handle is owned by the handle's own pointerdown drag —
  // never begin a range paint from it (the shift path never lands on the 8px handle).
  if (!e.shiftKey && tgt.closest('[data-fill-handle]')) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
  const row = parseInt(rowAttr, 10);
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(row) || !Number.isFinite(col)) return;
  if (e.shiftKey) {
    // Shift+Click: set the moving corner (keeping the anchor) and flag rangeClickPending so the
    // follow-up focusin does not collapse the range (a focusin carries no reliable shiftKey).
    setRangeFocus(row, col);
    activeIsHeader.value = false;
    activeRow.value = row;
    activeColIndex.value = col;
    rangeClickPending = true;
    return;
  }
  // §6 plain mousedown → begin a document-level drag-select anchored at this cell. The mousedown's
  // native focusin commits the ACTIVE cell to (row,col); beginRangeDrag's first cross-cell
  // pointermove paints the range via setRangeFocus (anchored at the active cell). A mousedown with
  // no move collapses to a single active cell (no range).
  beginRangeDrag(row, col);
};
// onGridDblClick: the double-click-into-edit seam (grid pointer §3+§5, 260708-ni6). Wired as
// ONE @dblclick on the <table> root (mirroring the already-delegated @mousedown/@focusin). A
// double-click on a BODY cell either toggles a group (group-header cell) or opens the editor
// (editable cell); a non-editable body cell is a no-op (the cell stays active — its focusin
// already set the active state + the §1 ring). Header cells return early so they keep their
// native sort/menu/resize semantics. Reuses the SAME closest/parse/finite guards as
// syncActiveFromEvent and the SAME beginEdit / onToggleExpand funnels the keyboard path uses —
// no new edit or expand machinery. isGrid()-gated so 'table' mode never runs it.
const onGridDblClick = (e: any) => {
  if (!isGrid() || !e) return;
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
  const row = parseInt(rowAttr, 10);
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(row) || !Number.isFinite(col)) return;
  // NB the local is `rowObj` (NOT `activeRow`): $data.activeRow lowers to the bare React state
  // binding `activeRow`, so a `const activeRow = …` local self-shadows it (TS2448 TDZ — the
  // visibleColCount `rowList` self-shadow class). ($data.rows || [])[row] is the active flattened
  // row (page-relative non-virtual / full-model virtual — both index $data.rows, matching the C2
  // Enter-on-group path + syncActiveFromEvent's row parse).
  const rowObj = (rows.value || [])[row];
  if (rowIsGrouped(rowObj)) {
    // Group-header cell → toggle its collapse/expand through the SAME onToggleExpand funnel the
    // chevron uses (mirrors the C2 Enter-on-group path verbatim), then re-seat focus after the
    // re-render (guardMoved=true — the group-header row is unchanged by its own collapse, so a
    // stale late rAF must not steal focus back after a subsequent nav).
    e.preventDefault();
    onToggleExpand(rowObj, e);
    recoverGridFocus(String(row), col, null, true);
    return;
  }
  // Editable body cell → open its editor (seed=null → seed the EXISTING value, the in-place F2/
  // Enter entry). A non-editable body cell is a no-op: the cell stays active (focusin already set
  // it + the §1 ring), matching the spreadsheet display-vs-edit convention.
  const colId = columnIdAt(row, col);
  if (colId != null && columnEditable(colId)) {
    e.preventDefault();
    beginEdit(row, col, null);
  }
};
// onGridClick: the opt-in single-click-to-edit seam (grid pointer §4, 260708-ni6). Only active
// when the `singleClickEdit` prop is true (default false, negative-opt-out). Wired as ONE @click
// on the <table> root — @click fires on a genuine mouseup-no-drag click (NOT @mousedown), which
// honors the deferred §6 drag guard (a mousedown that begins a drag-select must not open an
// editor). A plain click on an EDITABLE body cell opens its editor via the SAME beginEdit funnel;
// shift+click (range extend) and non-editable cells are unaffected. Same closest/parse/header-skip
// /finite guards as onGridDblClick. isGrid()-gated so 'table' mode never runs it.
const onGridClick = (e: any) => {
  if (!isGrid() || !e) return;
  if (!props.singleClickEdit) return;
  if (e.shiftKey) return;
  // §6 (260709-3qt): a drag-select that MOVED must never open the editor — the editor opens only
  // on a genuine mouseup-no-drag click. beginRangeDrag resets rangeDragMoved=false per gesture, so
  // the flag is always fresh; consume it here so a subsequent plain click still edits.
  if (rangeDragMoved) {
    rangeDragMoved = false;
    return;
  }
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
  const row = parseInt(rowAttr, 10);
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(row) || !Number.isFinite(col)) return;
  // Already editing THIS exact cell → no-op (a click inside an open editor must not re-open it).
  if (editingRow.value === row && editingCol.value === col) return;
  const colId = columnIdAt(row, col);
  if (colId != null && columnEditable(colId)) beginEdit(row, col, null);
};
// WR-02: reset the interaction-mode flag when focus leaves the active cell's subtree.
// Without this, activeInControl could stick `true` — a mouse click OUTSIDE the cell, or
// the focused inner control being removed from the DOM — leaving onGridKeyDown wedged in
// the in-cell-trap branch so arrow nav is dead until Escape. Wired as ONE @focusout on
// the <table> root (focusout bubbles, unlike blur). relatedTarget is the element RECEIVING
// focus (null when focus leaves the document / is retargeted across a shadow boundary). If
// focus is NOT moving to a descendant of the active cell, drop the flag. A Tab-cycle WITHIN
// the cell (interaction mode) keeps relatedTarget inside cellEl → no reset.
const onGridFocusOut = (e: any) => {
  if (!isGrid() || !activeInControl.value) return;
  const next = e ? e.relatedTarget : null;
  const cellEl = currentCellEl();
  if (!cellEl || !next || !cellEl.contains(next)) activeInControl.value = false;
};
// B25: re-focus a resolved valid cell AFTER a programmatic shrink re-renders. The clamp
// runs synchronously BEFORE the framework commits the new tbody, so a deferred rAF-poll
// resolves the [data-row][data-col-index] cell off gridRoot once it has rendered (the fast
// targets land on attempt 1; React/Solid retry across the async commit). Mirrors
// focusCellWhenReady (B23) — DOM-only (reads gridRoot), so it is React-stale-safe.
// guardMoved (default false): when true, the poll does NOT stomp focus that a later nav has
// already moved to a DIFFERENT, STILL-VALID row — used only by the group-collapse re-seat (the
// target group-header row is unchanged, so a stale late rAF must not steal focus back after the
// user ArrowDown'd away → the non-deterministic treegrid collapsed-nav focus-theft). It is left
// OFF for the B25 shrink-recovery site, whose target is a CLAMPED index of a now-REMOVED cell:
// there focus legitimately sits on the doomed old cell (a different row) mid-async-render on
// React and MUST be recovered onto the clamped survivor, not preserved. Compare data-row (NOT
// node identity) so a stale SAME-row cell on Solid's node-replacing re-render still resolves as
// the target — a genuinely dropped focus is always recovered on both sites.
const recoverGridFocus = (rowKey: any, col: any, level: any, guardMoved = false) => {
  if (!gridRoot) return;
  let attempts = 0;
  const tryFocus = () => {
    if (guardMoved) {
      const ae = gridRoot && gridRoot.getRootNode ? gridRoot.getRootNode().activeElement : null;
      const aeCell = ae && ae.closest ? ae.closest('[data-grid-cell]') : null;
      if (aeCell && gridRoot.contains(aeCell)) {
        const aeRow = aeCell.getAttribute('data-row');
        if (aeRow != null && aeRow !== rowKey) return;
      }
    }
    const el = resolveCellEl(rowKey, col, level);
    if (el) {
      el.focus();
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// D-05: clamp the active cell to bounds on every underlying-data change (re-sort, filter,
// pagination, page-size). KEEP the same indices; clamp ONLY when the grid shrank — NO
// row-id following, NO bounce-to-top on a filter keystroke. Gated by isGrid() so 'table'
// mode is entirely untouched. Invoked at the rowModelVer bump path (refreshRowModel).
const clampActiveCell = (rowCount: any, colCount: any) => {
  if (!isGrid()) return;
  // B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
  // just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
  // async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
  // and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
  // out of bounds on React only). Falls back to the live helpers when called without bounds.
  const colN = colCount != null ? colCount : visibleColCount();
  const rowN = rowCount != null ? rowCount : bodyRowCount();
  // B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
  // shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
  // framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
  // — true on all six, incl React's async setState), so the doomed cell + its focus are still
  // observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
  // programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
  // Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
  // doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
  // The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
  // NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
  // shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
  // DOM coords are always fresh.
  let recoverFocus = false;
  let doomedRow = -1;
  let doomedCol = 0;
  if (gridRoot) {
    const rootNode = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
    const focusedEl = rootNode ? rootNode.activeElement : null;
    const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
    if (focusedCell && gridRoot.contains(focusedCell)) {
      const fRowAttr = focusedCell.getAttribute('data-row');
      const fColAttr = focusedCell.getAttribute('data-col-index');
      if (fRowAttr != null && fRowAttr !== '__header') {
        const fr = parseInt(fRowAttr, 10);
        const fc = parseInt(fColAttr, 10);
        if (Number.isFinite(fr) && fr > rowN - 1) {
          recoverFocus = true;
          doomedRow = fr;
          doomedCol = Number.isFinite(fc) ? fc : 0;
        }
      }
    }
  }
  const maxCol = colN - 1;
  const col = clamp(activeColIndex.value, 0, maxCol < 0 ? 0 : maxCol);
  if (col !== activeColIndex.value) activeColIndex.value = col;
  // B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
  // cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
  // an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
  // re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
  if (rowN <= 0) {
    activeIsHeader.value = true;
    activeHeaderLevel.value = headerLeafLevel();
    activeColIndex.value = 0;
    // B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
    // reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
    // async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
    // class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
    // recovery branch below actually runs on React too.
    gridEmptyFallback = true;
    clampRange(rowN - 1, colN - 1);
    // B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
    // reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
    // Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
    // (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
    // the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
    return;
  }
  // B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
  // re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
  // cell. A user-driven header position (not the empty fallback) is left untouched.
  if (gridEmptyFallback) {
    gridEmptyFallback = false;
    activeIsHeader.value = false;
    activeRow.value = 0;
  }
  if (!activeIsHeader.value) {
    const lastRow = rowN - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const row = clamp(activeRow.value, 0, maxRow);
    if (row !== activeRow.value) activeRow.value = row;
  }
  // B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
  // shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
  // out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
  clampRange(rowN - 1, colN - 1);
  // B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
  // when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
  // into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
  if (recoverFocus) {
    const recRow = clamp(doomedRow, 0, rowN - 1);
    const recCol = clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
    recoverGridFocus(String(recRow), recCol, null);
  }
};
// B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
// flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
// plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
// is reached through the mount-time refreshRowModel closure, so a `$data.gridEmptyFallback` READ
// there binds the async-stale mount-time value on React (setState is async — the rangeActive /
// pendingEditFollow / B23-nextRows stale-read class). With the body re-populated after a filter
// CLEAR, that stale read skipped the recovery branch on React → the roving tab-stop stayed on the
// header fallback (columnheader) instead of re-seating a body cell (the B6 recovery gap). A
// synchronously-written plain `let` is read fresh on all six → the empty→non-empty recovery
// re-seats activeRow 0 on React too. The other 5 targets are byte-behaviorally identical (they
// already read reactive $data synchronously). A top-level reassigned `let` referenced from the
// refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
let gridEmptyFallback = false;
// ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
// A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
// (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
// highlight reattaches to the correct cells across virtualization recycling (the
// activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
// range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
// the row-selection slice (the two never touch each other's state).

// inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
// rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
// the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
// rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
// range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
// (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
// range we just set. The flag suppresses that collapse for the in-flight focus settle (the
// editTransition blur-guard precedent). A top-level let → React hoists to useRef.
let rangeTransition = false;
// rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
// pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
// to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
let rangeClickPending = false;
// B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
// true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
// arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
// on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
// `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
// range-change. This module-let is written synchronously (no setState async), so the second
// clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
// across all six targets. A top-level let → React hoists to useRef.
let rangeActive = false;
const inRange = (rIdx: any, cIdx: any) => {
  const a = rangeAnchor.value;
  const f = rangeFocus.value;
  if (!a || !f) return false;
  const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
  const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
  const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
  const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
  return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
};
// getSelectedRange(): the current range as plain integers — { anchor, focus } each a
// { rowIndex, colIndex } pair (or null when no range). T-49-02: positions only, no row
// data, no DOM node. Used by the getSelectedRange $expose verb AND every range-change emit
// (the single payload source) AND copyRange/fillRange (the rectangle they operate over).
const getSelectedRange = () => {
  // B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
  // payload) never reports a corner past a shrunken model — React-stale-safe (the eager
  // refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
  const a = rangeAnchor.value;
  const f = rangeFocus.value;
  if (!a && !f) return {
    anchor: null,
    focus: null
  };
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return {
    anchor: null,
    focus: null
  };
  const clampCorner = (c: any) => c == null ? null : {
    rowIndex: clamp(c.rowIndex, 0, maxRow),
    colIndex: clamp(c.colIndex, 0, maxCol)
  };
  return {
    anchor: clampCorner(a),
    focus: clampCorner(f)
  };
};
// isFillHandleCell(rIdx, cIdx): is this cell the BOTTOM-RIGHT corner of the current range?
// That corner hosts the fill-handle affordance (req-8 / D-04). False without a range — the
// byte-identical-off guard for the handle markup (no range → no handle).
const isFillHandleCell = (rIdx: any, cIdx: any) => {
  const a = rangeAnchor.value;
  const f = rangeFocus.value;
  if (!a || !f) return false;
  const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
  const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
  return rIdx === r1 && cIdx === c1;
};
// emitRangeChange(anchor, focus): fire range-change with the FRESH range corners passed by
// the caller — NOT a re-read of $data.rangeAnchor/rangeFocus. The range corners are <data>
// (useState on React), so re-reading right after the same-tick setState returns the STALE
// pre-write value (ROZ138). extendRange/setRangeFocus thread the just-computed locals through
// here so the emitted payload matches the write. The single call site keeps the count
// predictable (React multi-emit dedup, D-07). One-way notification.
const emitRangeChange = (anchor: any, focus: any) => {
  emit('range-change', {
    anchor,
    focus
  });
};
// extendRange(dRow, dCol): move rangeFocus by the (row,col) delta, clamped to the grid
// bounds, seeding rangeAnchor from the active cell when no range exists yet (Shift+Arrow
// from a bare active cell starts a 1×N / N×1 rectangle anchored at that cell). Body cells
// only (header rows are not range-selectable). Emits range-change from this single site.
const extendRange = (dRow: any, dCol: any) => {
  if (activeIsHeader.value) return;
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return;
  // Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
  let anchor = rangeAnchor.value;
  let focus = rangeFocus.value;
  const hadRange = !!(anchor && focus);
  if (!anchor || !focus) {
    anchor = {
      rowIndex: activeRow.value,
      colIndex: activeColIndex.value
    };
    focus = {
      rowIndex: activeRow.value,
      colIndex: activeColIndex.value
    };
  }
  const nextRow = clamp(focus.rowIndex + dRow, 0, maxRow);
  const nextCol = clamp(focus.colIndex + dCol, 0, maxCol);
  const nextFocus = {
    rowIndex: nextRow,
    colIndex: nextCol
  };
  rangeAnchor.value = anchor;
  rangeFocus.value = nextFocus;
  rangeActive = true;
  // Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
  // from the range's leading edge, the spreadsheet convention).
  activeRow.value = nextRow;
  activeColIndex.value = nextCol;
  // Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
  // settle on the new focus corner is part of THIS range extension, not a fresh navigation.
  rangeTransition = true;
  focusActiveCell(nextRow, nextCol, false);
  // B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
  // and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
  // change → no emit. Seeding a brand-new range (no prior range) is always a change (the
  // rectangle came into existence) even if its first corner is a degenerate 1×1.
  if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
    emitRangeChange(anchor, nextFocus);
  }
};
// setRangeFocus(rIdx, cIdx): set the moving corner to an explicit cell (Shift+Click),
// seeding the anchor from the active cell when no range exists yet. Clamped to bounds.
// Emits range-change from this single site.
const setRangeFocus = (rIdx: any, cIdx: any) => {
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return;
  let anchor = rangeAnchor.value;
  if (!anchor) anchor = {
    rowIndex: activeRow.value,
    colIndex: activeColIndex.value
  };
  const r = clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
  const c = clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
  const nextFocus = {
    rowIndex: r,
    colIndex: c
  };
  rangeAnchor.value = anchor;
  rangeFocus.value = nextFocus;
  rangeActive = true;
  emitRangeChange(anchor, nextFocus);
};
// selectAllBody(): the §8 (260709-3qt) Ctrl+A whole-body select — set the range to span EVERY
// body cell (anchor at the first body cell (0,0), moving corner at the last (maxRow, maxCol)),
// driving the SAME range corners shift+arrow / setRangeFocus use. Emits range-change from a single
// site (the emitRangeChange contract — pass the FRESH corners, never a $data re-read). No-op on an
// empty grid. Body cells only — a header-active Ctrl+A is gated OUT by the caller (never builds a
// range from a header). rangeActive is set synchronously so a follow-up clearRange collapses it.
const selectAllBody = () => {
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return;
  const anchor = {
    rowIndex: 0,
    colIndex: 0
  };
  const focus = {
    rowIndex: maxRow,
    colIndex: maxCol
  };
  rangeAnchor.value = anchor;
  rangeFocus.value = focus;
  rangeActive = true;
  emitRangeChange(anchor, focus);
};
// clearRange(): drop the rectangle (a non-shift navigation / edit-entry collapses any
// range back to a single active cell). Cheap no-op when no range is set (the guard keeps a
// plain navigation with no active range from emitting). B19: when a range DID exist, emit
// range-change with null corners so a consumer mirroring the selection through the event sees
// the drop — without this they hold a STALE rectangle after every non-shift navigation /
// edit-entry collapse (getSelectedRange already reports null, but the event never fired).
const clearRange = () => {
  // B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
  // in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
  // move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
  // would let the SECOND call through and emit a duplicate range-change. rangeActive flips
  // synchronously → the second call returns here.
  if (!rangeActive) return;
  rangeActive = false;
  rangeAnchor.value = null;
  rangeFocus.value = null;
  emitRangeChange(null, null);
};
// B8: clamp the range corners to the current grid bounds after an underlying-data change
// (sort/filter/paginate/page-size all re-derive the row model). A range whose rows now exceed
// the shrunken model would otherwise leave STALE/phantom corners → a copy serializes empty
// rows past the model's end (and getSelectedRange reports out-of-bounds corners). We CLAMP each
// corner into [0,maxRow]×[0,maxCol] (preserving a valid rectangle — a corner that clamps onto
// another keeps the range non-empty); when no selectable body cell remains the rectangle is
// dropped. Does NOT emit range-change here — the clamp is a reconcile, not a user selection
// move (the emit-on-change work, B18/B19, lands in plan 63-05). Called from clampActiveCell.
const clampRange = (maxRowArg: any, maxColArg: any) => {
  const a = rangeAnchor.value;
  const f = rangeFocus.value;
  if (!a && !f) return;
  // Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
  // shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
  const maxRow = maxRowArg != null ? maxRowArg : bodyRowCount() - 1;
  const maxCol = maxColArg != null ? maxColArg : visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) {
    rangeAnchor.value = null;
    rangeFocus.value = null;
    rangeActive = false;
    return;
  }
  if (a) {
    const ar = clamp(a.rowIndex, 0, maxRow);
    const ac = clamp(a.colIndex, 0, maxCol);
    if (ar !== a.rowIndex || ac !== a.colIndex) rangeAnchor.value = {
      rowIndex: ar,
      colIndex: ac
    };
  }
  if (f) {
    const fr = clamp(f.rowIndex, 0, maxRow);
    const fc = clamp(f.colIndex, 0, maxCol);
    if (fr !== f.rowIndex || fc !== f.colIndex) rangeFocus.value = {
      rowIndex: fr,
      colIndex: fc
    };
  }
};
// ══ Clipboard (TSV copy/paste) + drag-fill (phase 51 plan 04 / req-8 / D-03 / D-04) ══════
// The async Clipboard API (grantPermissions confirmed in 51-01). Copy = range→TSV; paste =
// TSV→cells under the D-03 skip rule (editable AND validator-passing cells only) with an
// N-of-M aria-live announce + one cell-edit-commit per committed cell; drag-fill = value-copy
// ONLY (D-04, NO series detection). T-51-01 (BLOCKING-high): pasted TSV is UNTRUSTED — every
// cell is written as plain string DATA through the per-column validator and rendered via the
// SAME {{ }}/rozieDisplay text path as #cell (never innerHTML / a template / a selector); the
// cell-resolution query interpolates integer indices only (resolveCellEl, T-49-01).

// announce(msg): write the polite aria-live PASTE-announce region (D-03 — "N of M cells
// pasted"). SEPARATE from the validation invalidMsg region (different semantics). '' clears it.
const announce = (msg: any) => {
  pasteAnnounce.value = msg != null ? msg : '';
};
// B11: copy / paste (and the Cut verb plan 63-09 adds) are NO-OPS while a HEADER cell is
// active. A header has no body value to copy, and a paste anchored at a header would silently
// write body row 0 at the header's column (a silent body mutation, borderline P0). This is the
// SINGLE reusable guard every clipboard entry path checks — copyRange/pasteRange self-guard
// with it AND the onGridKeyDown Ctrl+C/Ctrl+V branches gate on it (so the native shortcut is
// left untouched on a header). Plan 63-09's Cut reuses this exact predicate.
const clipboardActiveAllowed = () => !activeIsHeader.value;
// fieldOfColId: the row-object key (accessorKey) to write for a column id — the same
// accessorKey-or-id rule the edit funnels use. Used by paste/fill to apply values by field.
const fieldOfColId = (colId: any) => {
  const d = defFor(colId);
  return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
// normalizedRange(): the current rectangle as { r0, r1, c0, c1 } (min/max of anchor+focus),
// or null when no range. The shared rectangle source for copy/paste/fill. B8: the corners are
// CLAMPED to the CURRENT grid bounds ON READ (read at call time → React-stale-safe), so a copy
// after a filter-to-fewer can never serialize phantom rows past the shrunken model even when
// the stored corners were not eagerly re-clamped (refreshRowModel's clamp is async-defeated on
// React; this read-time clamp is the cross-target guarantee). Returns null when no body cell
// remains.
const normalizedRange = () => {
  const a = rangeAnchor.value;
  const f = rangeFocus.value;
  if (!a || !f) return null;
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return null;
  const ar = clamp(a.rowIndex, 0, maxRow);
  const ac = clamp(a.colIndex, 0, maxCol);
  const fr = clamp(f.rowIndex, 0, maxRow);
  const fc = clamp(f.colIndex, 0, maxCol);
  return {
    r0: ar < fr ? ar : fr,
    r1: ar > fr ? ar : fr,
    c0: ac < fc ? ac : fc,
    c1: ac > fc ? ac : fc
  };
};
// B10: escape a TSV field per the spreadsheet convention — a field containing a tab, a CR/LF,
// or a double-quote is wrapped in double-quotes with internal quotes DOUBLED; an ordinary
// field is emitted verbatim. parseTsv() unescapes symmetrically, so a cell carrying a tab /
// newline / quote round-trips without smearing into adjacent cells (T-63-03-02).
const escapeTsvField = (s: any) => {
  if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
    return '"' + s.replace(/"/g, '""') + '"';
  }
  return s;
};
// rangeToTsv(): serialize the current range to TSV — rows joined by '\n', cells by '\t',
// reading each cell's value off the visible model by index (cellValueAt). A single active
// cell (no range) serializes that one cell. Each field is B10-escaped. Pure read — never writes.
const rangeToTsv = () => {
  const box = normalizedRange();
  const r0 = box ? box.r0 : activeRow.value;
  const r1 = box ? box.r1 : activeRow.value;
  const c0 = box ? box.c0 : activeColIndex.value;
  const c1 = box ? box.c1 : activeColIndex.value;
  const lines = [];
  for (let r = r0; r <= r1; r++) {
    const cells = [];
    for (let c = c0; c <= c1; c++) {
      const v = cellValueAt(r, c);
      cells.push(escapeTsvField(v == null ? '' : String(v)));
    }
    lines.push(cells.join('\t'));
  }
  return lines.join('\n');
};
// parseTsv(text): a TSV string → string[][] (rows of cells). Tolerates \r\n; a trailing
// newline does not add a phantom empty row. Pure — produces plain string DATA only (T-51-01:
// the cells are NEVER eval'd / interpolated into a selector / rendered as markup).
const parseTsv = (text: any) => {
  const str = text != null ? String(text) : '';
  // CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
  // large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
  // single-pass scan allocates a cell-per-character grid.
  if (str === '' || str.length > 2000000) return [];
  // B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
  // cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
  // newlines, and doubled quotes ("") inside it are literal content until the closing quote;
  // an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
  const rows = [];
  let row = [];
  let field = '';
  let inQuotes = false;
  let i = 0;
  const n = str.length;
  while (i < n) {
    const ch = str[i];
    if (inQuotes) {
      if (ch === '"') {
        if (i + 1 < n && str[i + 1] === '"') {
          field = field + '"';
          i = i + 2;
          continue;
        }
        inQuotes = false;
        i = i + 1;
        continue;
      }
      field = field + ch;
      i = i + 1;
      continue;
    }
    if (ch === '"' && field === '') {
      inQuotes = true;
      i = i + 1;
      continue;
    }
    if (ch === '\t') {
      row.push(field);
      field = '';
      i = i + 1;
      continue;
    }
    if (ch === '\r') {
      if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
      row.push(field);
      field = '';
      rows.push(row);
      row = [];
      i = i + 1;
      continue;
    }
    if (ch === '\n') {
      row.push(field);
      field = '';
      rows.push(row);
      row = [];
      i = i + 1;
      continue;
    }
    field = field + ch;
    i = i + 1;
  }
  // Flush the trailing field + row.
  row.push(field);
  rows.push(row);
  // Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
  if (rows.length > 1) {
    const last = rows[rows.length - 1];
    if (last.length === 1 && last[0] === '') rows.pop();
  }
  return rows;
};
// copyRange(): write the current range as TSV to the clipboard (async). No-op when the
// async Clipboard API is unavailable (older/insecure contexts) — a copy is best-effort.
const copyRange = () => {
  // B11: never copy from a header-active state (the reusable clipboard guard).
  if (!clipboardActiveAllowed()) return;
  if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
  try {
    const p = navigator.clipboard.writeText(rangeToTsv());
    if (p && p.catch) p.catch(() => {});
  } catch (err: any) {/* best-effort copy */}
};
// applyGridToRange(grid, originRow, originCol): the SHARED write path for paste + fill. Walks
// the grid (string[][]) anchored at (originRow, originCol), CLAMPED to the grid bounds (no
// unbounded loop — T-51-02). For each target cell: count it (total); SKIP if the column is
// non-editable (D-03) or the per-column validator rejects the value (D-03, T-51-01 — the
// value passes runValidator as plain string DATA before any write); else stage it into ONE
// running fresh array (replaceRowValue) and record the committed cell. After the walk: ONE
// writeData (the single r-model:data write), ONE cell-edit-commit per COMMITTED cell, and the
// N-of-M aria-live announce. Returns { wrote, total }.
const applyGridToRange = (grid: any, originRow: any, originCol: any) => {
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return {
    wrote: 0,
    total: 0
  };
  let total = 0;
  let wrote = 0;
  const committed = [];
  // Build the fresh data array incrementally so the whole paste is ONE writeData.
  let next = currentData();
  for (let gr = 0; gr < grid.length; gr++) {
    const r = originRow + gr;
    if (r > maxRow) break;
    const cols = grid[gr] || [];
    for (let gc = 0; gc < cols.length; gc++) {
      const c = originCol + gc;
      if (c > maxCol) break;
      total = total + 1;
      const colId = columnIdAt(r, c);
      if (colId == null || !columnEditable(colId)) continue;
      const rowObj = rowOriginalAt(r);
      // B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
      // single-cell commit coercion) — a numeric column commits a real Number, an empty cell
      // commits null; every other editor type passes through verbatim. No mixed/garbage types
      // ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
      const value = coerceCellValue(colId, cols[gc]);
      // T-51-01: validate the pasted value as plain DATA before any write.
      if (runValidator(colId, value, rowObj) !== true) continue;
      const field = fieldOfColId(colId);
      const srcIndex = sourceIndexOfRow(r);
      const oldValue = rowObj ? rowObj[field] : null;
      next = replaceRowValue(next, srcIndex, field, value);
      committed.push({
        rowId: rowIdAt(r),
        columnId: colId,
        oldValue,
        newValue: value
      });
      wrote = wrote + 1;
    }
  }
  if (wrote > 0) {
    editTransition = true;
    writeData(next);
    editTransition = false;
    // One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
    for (let i = 0; i < committed.length; i++) emit('cell-edit-commit', committed[i]);
  }
  // WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
  // targeted real cells but every one was skipped (validation-failed / non-editable), announce a
  // distinct validation-failed message instead of a misleading "0 of M cells pasted".
  if (wrote > 0) announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) announce('No cells pasted — ' + total + ' cells were invalid or read-only');
  return {
    wrote,
    total
  };
};
// rowOriginalAt / rowIdAt: the underlying row object / id at a visible-model body index.
const rowOriginalAt = (rowIndex: any) => {
  const rowList = rows.value || [];
  const row = rowList[rowIndex];
  return row ? row.original : null;
};
const rowIdAt = (rowIndex: any) => {
  const rowList = rows.value || [];
  const row = rowList[rowIndex];
  return row ? row.id : null;
};
// C3: tile a parsed clipboard `grid` (string[][]) to fill a destination `box` — the spreadsheet
// paste-into-range semantics. The target rectangle is the MAX of the box dims and the source
// dims per axis, so a SMALLER clipboard TILES across a LARGER selection (a single 1×1 cell fills
// the whole range; a 2×2 block repeats — tiled[dr][dc] = src[dr % srcRows][dc % srcCols]), while a
// clipboard LARGER than the selection pastes its full block from the top-left (preserving the
// no-range "clipboard-sized block at the active cell" behavior — a 1×1 destBox + a 1×N clipboard
// yields the full 1×N block, byte-for-byte the prior path). Pure — returns a fresh grid; applies
// nothing. A ragged/short source row defaults the missing cell to '' (coerced per column on write).
const tileGridToBox = (grid: any, box: any) => {
  const srcRows = grid.length;
  // srcCols is the MAX row width across ALL rows (not grid[0].length): a RAGGED clipboard
  // (a later row WIDER than the first, e.g. TSV "a\tb\nc\td\te") would otherwise never read
  // the extra column and silently drop those cells. A row SHORTER than srcCols tiles its
  // missing cells as '' (the `v != null ? v : ''` coercion below), never undefined.
  let srcCols = 0;
  for (let i = 0; i < srcRows; i++) {
    const w = grid[i] && grid[i].length ? grid[i].length : 0;
    if (w > srcCols) srcCols = w;
  }
  if (srcRows <= 0 || srcCols <= 0) return grid;
  const boxRows = box.r1 - box.r0 + 1;
  const boxCols = box.c1 - box.c0 + 1;
  const rows = boxRows > srcRows ? boxRows : srcRows;
  const cols = boxCols > srcCols ? boxCols : srcCols;
  const out = [];
  for (let r = 0; r < rows; r++) {
    const srcLine = grid[r % srcRows] || [];
    const line = [];
    for (let c = 0; c < cols; c++) {
      const v = srcLine[c % srcCols];
      line.push(v != null ? v : '');
    }
    out.push(line);
  }
  return out;
};
// pasteRange(): read TSV from the clipboard (async), parse it, TILE it over the destination
// (C3), and apply it anchored at the destination top-left under the D-03 skip rule. The grid is
// clamped to the grid bounds (T-51-02). A failed/empty read is a silent no-op.
const pasteRange = () => {
  // B11: never paste into a header-active state (the reusable clipboard guard) — a header
  // anchor would silently write body row 0 at the header's column.
  if (!clipboardActiveAllowed()) return;
  if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
  // CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
  // C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
  // single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
  // on React; re-reading them inside the async .then() returns the mount-render stale value, so a
  // selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
  // the box + anchor now and pass them into tileGridToBox / applyGridToRange.
  const box = normalizedRange();
  const anchorRow = box ? box.r0 : activeRow.value;
  const anchorCol = box ? box.c0 : activeColIndex.value;
  const destBox = box || {
    r0: anchorRow,
    r1: anchorRow,
    c0: anchorCol,
    c1: anchorCol
  };
  let p: any = null;
  try {
    p = navigator.clipboard.readText();
  } catch (err: any) {
    return;
  }
  if (!p || !p.then) return;
  p.then((text: any) => {
    const grid = parseTsv(text);
    if (!grid.length) return;
    // C3: tile the clipboard block to fill the destination range (single→range fill,
    // smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
    const tiled = tileGridToBox(grid, destBox);
    applyGridToRange(tiled, anchorRow, anchorCol);
  }).catch(() => {});
};
// cutRange(): C3 Cut — copy the current range to the clipboard (rangeToTsv — the SAME escaped
// serialization copyRange uses) THEN CLEAR the source cells through the SAME write-funnel as
// paste/fill: applyGridToRange of an empty-string grid sized to the range → coerceCellValue('')
// per column (null on a numeric column, '' on text) + the D-03 editable/validator skip rule +
// ONE writeData + one cell-edit-commit per cleared cell + the N-of-M announce. A read-only /
// required cell is left intact (the funnel skips it). B11: a no-op while a header cell is active
// (reuses clipboardActiveAllowed — Cut can never silently clear a body cell from a header anchor).
// The clear is SYNCHRONOUS and runs AFTER rangeToTsv has already serialized, so the copy reads the
// pre-clear values; the clipboard write is best-effort/async and never blocks the clear.
const cutRange = () => {
  if (!clipboardActiveAllowed()) return;
  // Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
  const box = normalizedRange();
  const r0 = box ? box.r0 : activeRow.value;
  const r1 = box ? box.r1 : activeRow.value;
  const c0 = box ? box.c0 : activeColIndex.value;
  const c1 = box ? box.c1 : activeColIndex.value;
  // Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
  if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
    try {
      const cp = navigator.clipboard.writeText(rangeToTsv());
      if (cp && cp.catch) cp.catch(() => {});
    } catch (err: any) {/* best-effort copy */}
  }
  // Clear the source: a grid of empty strings sized to the range, applied at the top-left.
  const grid = [];
  for (let r = r0; r <= r1; r++) {
    const cols = [];
    for (let c = c0; c <= c1; c++) cols.push('');
    grid.push(cols);
  }
  applyGridToRange(grid, r0, c0);
};
// clearActiveRange(): the §7 (260709-3qt) Delete/Backspace clear — cutRange() MINUS the clipboard
// copy. Clears the active cell / selected range through the SAME write-funnel as Cut/paste/fill:
// applyGridToRange of an empty-string grid sized to the range → coerceCellValue('') per column
// (null on a numeric column, '' on text) + the D-03 editable/validator/read-only skip rule + ONE
// writeData + one cell-edit-commit per cleared cell + the N-of-M announce. B11: a no-op while a
// header cell is active (reuses clipboardActiveAllowed — Delete can never silently clear a body
// cell from a header anchor). NO undo — the grid is controlled (writeData → $model.data; every
// clear fires cell-edit-commit), so undo is the consumer's responsibility, the SAME contract
// Cut/Paste/Fill already carry (design §7, approved 2026-07-09).
const clearActiveRange = () => {
  if (!clipboardActiveAllowed()) return;
  // Snapshot the source rectangle synchronously (the ROZ138 concern cutRange/pasteRange share).
  const box = normalizedRange();
  const r0 = box ? box.r0 : activeRow.value;
  const r1 = box ? box.r1 : activeRow.value;
  const c0 = box ? box.c0 : activeColIndex.value;
  const c1 = box ? box.c1 : activeColIndex.value;
  const grid = [];
  for (let r = r0; r <= r1; r++) {
    const cols = [];
    for (let c = c0; c <= c1; c++) cols.push('');
    grid.push(cols);
  }
  applyGridToRange(grid, r0, c0);
};
// tileIndex(i, lo, hi): map an index into the inclusive [lo,hi] source span by TILING (repeat
// the source block), handling indices below lo (negative offset) correctly. A 1-wide source
// (lo===hi) always returns lo. Used by fillRange to resolve, per target cell, WHICH source
// cell it copies — so each column copies its OWN source value down its OWN column.
const tileIndex = (i: any, lo: any, hi: any) => {
  const span = hi - lo + 1;
  if (span <= 1) return lo;
  let k = (i - lo) % span;
  if (k < 0) k = k + span;
  return lo + k;
};
// fillRange(sourceBox): drag-fill (D-04 — VALUE-COPY ONLY, no series detection). B7: the fill
// SOURCE is the PRE-DRAG rectangle (`sourceBox`, captured at pointerdown before the drag grew
// the range); each target cell copies the source cell in its OWN column (and row, when the
// source spans rows), TILED across the source dimensions. This fixes two data-loss bugs: (1) a
// single-scalar broadcast clobbered the other columns' data, and (2) reading box.r0/box.c0
// flipped to the WRONG corner on an up/left drag (the box top-left is a TARGET cell there, not
// the source). `sourceBox` falls back to the box's top-left 1×1 for a no-source fill. Honors the
// SAME editable + validation + type-coercion skip rule as paste (via applyGridToRange): one
// writeData + one cell-edit-commit per committed cell + the N-of-M announce. No-op without a range.
const fillRange = (sourceBox: any, endCell: any) => {
  // B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
  // the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
  // React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
  // grows), so deriving the box from the threaded endpoints is what makes the fill cover the
  // dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
  let box;
  if (sourceBox && sourceBox.r0 != null && endCell) {
    let r0 = sourceBox.r0;
    let r1 = sourceBox.r1;
    let c0 = sourceBox.c0;
    let c1 = sourceBox.c1;
    if (endCell.r < r0) r0 = endCell.r;
    if (endCell.r > r1) r1 = endCell.r;
    if (endCell.c < c0) c0 = endCell.c;
    if (endCell.c > c1) c1 = endCell.c;
    box = {
      r0,
      r1,
      c0,
      c1
    };
  } else {
    box = normalizedRange();
  }
  if (!box) return;
  const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
    r0: box.r0,
    r1: box.r0,
    c0: box.c0,
    c1: box.c0
  };
  const grid = [];
  for (let r = box.r0; r <= box.r1; r++) {
    const cols = [];
    for (let c = box.c0; c <= box.c1; c++) {
      const sr = tileIndex(r, src.r0, src.r1);
      const sc = tileIndex(c, src.c0, src.c1);
      const v = cellValueAt(sr, sc);
      cols.push(v == null ? '' : String(v));
    }
    grid.push(cols);
  }
  applyGridToRange(grid, box.r0, box.c0);
};
// onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
// range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
// cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
// pointermove extends the range to the cell under the pointer; pointerup commits the fill.
let fillDragging = false;
// CR-04: track the live fill-drag document listeners in module-lets so $onUnmount can remove
// them if the component unmounts MID-DRAG (the `up` handler clears them on a normal release,
// but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
let fillDragMove: any = null;
let fillDragUp: any = null;
const teardownFillDrag = () => {
  if (typeof document !== 'undefined') {
    if (fillDragMove) document.removeEventListener('pointermove', fillDragMove);
    if (fillDragUp) document.removeEventListener('pointerup', fillDragUp);
  }
  fillDragMove = null;
  fillDragUp = null;
  fillDragging = false;
};
const cellIndexFromPoint = (clientX: any, clientY: any) => {
  if (typeof document === 'undefined' || !document.elementFromPoint) return null;
  let el = document.elementFromPoint(clientX, clientY);
  // Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
  // a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
  // and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
  // deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
  while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
    const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
    if (!inner || inner === el) break;
    el = inner;
  }
  if (!el || !el.closest) return null;
  const cellEl = el.closest('[data-grid-cell]');
  if (!cellEl) return null;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
  const r = parseInt(rowAttr, 10);
  const c = parseInt(colAttr, 10);
  if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
  return {
    r,
    c
  };
};
const onFillHandlePointerDown = (e: any) => {
  if (!e) return;
  if (e.preventDefault) e.preventDefault();
  if (e.stopPropagation) e.stopPropagation();
  // #leak: tear down any orphaned PRIOR gesture BEFORE reassigning the module-let handlers. If a
  // pointerup was missed (pointer released off-window, context menu, alt-tab), the prior fillDrag's
  // document pointermove/pointerup stay attached; overwriting fillDragMove/fillDragUp below would
  // strand them (removeEventListener could never reach the old refs) → a permanent global
  // pointermove leak. teardownFillDrag is idempotent (no-op when nothing is attached).
  teardownFillDrag();
  fillDragging = true;
  // B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
  // range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
  // up/left drag copies from the real origin (not the post-drag corner that would flip to a
  // target cell). Captured per-gesture in the closure (no module-let needed).
  const sourceBox = normalizedRange();
  // B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
  // the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
  let lastCell = sourceBox ? {
    r: sourceBox.r1,
    c: sourceBox.c1
  } : null;
  const move = (ev: any) => {
    if (!fillDragging) return;
    const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
    // B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
    // pointermove (the pointer fires many per cell) spams the event with identical payloads.
    // Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
    // seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
    // or re-enters the same cell is suppressed (the range is unchanged).
    if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
      lastCell = cell;
      setRangeFocus(cell.r, cell.c);
    }
  };
  const up = () => {
    // teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
    teardownFillDrag();
    // A plain click on the fill handle (pointerdown+up with NO intervening drag) leaves lastCell
    // at the source box's own origin corner (r1,c1), so fillRange(sourceBox, corner) would
    // recommit the source range onto ITSELF — a no-op write that pollutes undo history and fires
    // spurious per-cell cell-edit-commit events (oldValue === newValue). Only fill when the drag
    // actually reached a cell past the source origin.
    if (lastCell && sourceBox && (lastCell.r !== sourceBox.r1 || lastCell.c !== sourceBox.c1)) {
      fillRange(sourceBox, lastCell);
    }
  };
  // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
  fillDragMove = move;
  fillDragUp = up;
  if (typeof document !== 'undefined') {
    document.addEventListener('pointermove', move);
    document.addEventListener('pointerup', up);
  }
};
// §6 (260709-3qt) drag-to-select — mirror the fill-drag listener discipline. rangeDragging gates
// the live gesture; rangeDragMove/rangeDragUp track the document pointermove/pointerup handlers so
// a mid-drag unmount ($onUnmount → teardownRangeDrag) can remove them (CR-04). rangeDragMoved flips
// true once the drag enters a DIFFERENT cell than its mousedown anchor; onGridClick reads it to
// suppress a singleClickEdit editor-open after a drag (reset per-gesture in beginRangeDrag). Each
// top-level let → React hoists to useRef.
let rangeDragging = false;
let rangeDragMove: any = null;
let rangeDragUp: any = null;
let rangeDragMoved = false;
// ══ Mouse drag-to-select (grid cell-interaction §6, 260709-3qt) ═════════════════════════
// A plain (non-shift) mousedown on a body cell begins a document-level drag: the FIRST
// pointermove that reaches a DIFFERENT body cell paints the range moving corner via the
// SHARED-scope setRangeFocus (the SAME range model shift+click / shift+arrow drive), pointerup
// ends it. Mirrors fillDrag.rzts's listener discipline VERBATIM (document pointermove/pointerup
// tracked in module-lets so a mid-drag unmount can remove them — CR-04), and REUSES fillDrag's
// shadow-piercing cellIndexFromPoint (shared scope) so the Lit shadow target is covered uniformly.

// teardownRangeDrag(): remove the live drag listeners, null them, clear the dragging flag. The
// `up` handler calls it on a normal release; $onUnmount calls it if we unmount MID-DRAG (mirrors
// teardownFillDrag). rangeDragMoved is NOT reset here — it is read by onGridClick AFTER pointerup
// (to suppress a singleClickEdit editor-open) and reset per-gesture in beginRangeDrag.
const teardownRangeDrag = () => {
  if (typeof document !== 'undefined') {
    if (rangeDragMove) document.removeEventListener('pointermove', rangeDragMove);
    if (rangeDragUp) document.removeEventListener('pointerup', rangeDragUp);
  }
  rangeDragMove = null;
  rangeDragUp = null;
  rangeDragging = false;
};
// beginRangeDrag(anchorR, anchorC): start a drag-select anchored at the mousedown cell. The
// mousedown's native focus/focusin already committed the ACTIVE cell to (anchorR, anchorC), so
// setRangeFocus (which seeds the anchor from the ACTIVE cell) spans mousedown-cell→pointer-cell —
// we NEVER write $data.rangeAnchor directly (it is React-stale, ROZ138). rangeDragMoved starts
// false and flips true only once the pointer reaches a DIFFERENT cell, so a mousedown-with-no-move
// leaves a single active cell + no range (a normal click). lastCell dedups the many pointermove
// events per cell (setRangeFocus emits range-change — only extend on a NEW cell, mirroring fillDrag's
// B20 dedup). Captured per-gesture in the closure (no module-let needed for lastCell).
const beginRangeDrag = (anchorR: any, anchorC: any) => {
  // #leak: tear down any orphaned PRIOR range gesture BEFORE reassigning the module-let handlers.
  // A missed pointerup (off-window release, context menu, alt-tab) leaves the prior drag's document
  // pointermove/pointerup attached; overwriting rangeDragMove/rangeDragUp below would strand them
  // (removeEventListener could never reach the old refs) → a permanent global pointermove leak.
  // teardownRangeDrag is idempotent (no-op when nothing is attached) and does NOT touch
  // rangeDragMoved, which is reset per-gesture immediately below.
  teardownRangeDrag();
  rangeDragging = true;
  rangeDragMoved = false;
  let lastCell = {
    r: anchorR,
    c: anchorC
  };
  const move = (ev: any) => {
    if (!rangeDragging) return;
    const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
    if (cell && (cell.r !== lastCell.r || cell.c !== lastCell.c)) {
      lastCell = cell;
      rangeDragMoved = true;
      setRangeFocus(cell.r, cell.c);
    }
  };
  const up = () => {
    // teardownRangeDrag clears rangeDragging + removes both listeners (the fill-drag CR-04 path).
    teardownRangeDrag();
  };
  // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
  rangeDragMove = move;
  rangeDragUp = up;
  if (typeof document !== 'undefined') {
    document.addEventListener('pointermove', move);
    document.addEventListener('pointerup', up);
  }
};
// ══ Editable-cell lifecycle (phase 51 plan 02 — RESEARCH Pattern 1/3/4/5) ════════════════
// Single-cell, non-virtual. Index-based state (editingRow/editingCol over the visible model),
// the display↔editor branch in the keyed <td>, F2/Enter/printable entry off the reserved
// onGridKeyDown seam, commit on Enter/Tab/blur, cancel+revert on Escape, sync validation with
// D-01 keep-open. All gated by columnEditable() / the editing index pair so a table with no
// editable columns lowers byte-identical (the editor branch r-if is always false).

// The column id at the active cell (the active row's visible cell list @ activeColIndex).
// Null when out of range (no body rows, or active cell is a header / select column).
const activeCellColumnId = () => {
  if (activeIsHeader.value) return null;
  const rowList = rows.value || [];
  const row = rowList[activeRow.value];
  if (!row) return null;
  const cells = visibleCellsFor(row);
  const cell = cells[activeColIndex.value];
  return cell && cell.column ? cell.column.id : null;
};
// isActiveCellEditable: the active cell sits in an editable column AND is a body cell
// (req-1). Gates the F2/Enter/printable edit-entry branches in onGridKeyDown; a
// non-editable active cell falls through to the reserved enterControl path.
const isActiveCellEditable = () => {
  const colId = activeCellColumnId();
  return colId != null && columnEditable(colId);
};
// isEditing: is the cell at (rowIndex, colIndex) over the visible model in edit? ONE
// predicate covers BOTH modes (RESEARCH Pattern 6):
//  - row mode (req-6): editingRowIndex === rowIndex AND the column at colIndex is editable —
//    so EVERY editable cell in the row enters edit simultaneously (the editor template branch
//    re-uses this gate verbatim, no template fork);
//  - single-cell mode (req-1/3): the editingRow/editingCol pair matches exactly.
// Pure index compare (editingRowIndex null + editingRow -1 = none) → the byte-identical-off
// guard for the editor template branch. $data.editVer is read first so the per-cell branch
// re-derives on Svelte/Solid when editing state mutates from a foreign slot-callback scope.
// Called per-cell in both <td> bodies with the body-specific row index (rowIndexOf(row)
// non-virtual, wr.vi.index virtual).
const isEditing = (rowIndex: any, colIndex: any) => {
  if (editVer.value < 0) return false;
  if (editingRowIndex.value != null && editingRowIndex.value === rowIndex) {
    const colId = columnIdAt(rowIndex, colIndex);
    return colId != null && columnEditable(colId);
  }
  return editingRow.value === rowIndex && editingCol.value === colIndex;
};
// cellAriaInvalid (req-5/D-01): the STRING 'true' ONLY for the editing cell while it holds
// an invalid value — drives :aria-invalid on the <td>. Returns null otherwise so the bound
// attribute DROPS (the rozieAttr nullish-attr path), keeping non-editing cells byte-clean.
// Returns the literal 'true' (NOT boolean true) so rozieAttr's string-literal-union preserve
// keeps React's aria-invalid (Booleanish incl. 'true') happy instead of widening to string.
const cellAriaInvalid = (rowIndex: any, colIndex: any): 'true' | null => isEditing(rowIndex, colIndex) && !!invalidMsg.value ? 'true' : null;
// runValidator: the sync per-column validator (req-5). Reads col.meta.validate; not a
// function → valid (true). Calls it (defensively wrapped — a thrown/non-true/non-string
// return coerces to a generic message so a misbehaving validator can never wedge the
// keymap, Security V5 DoS). A string return is the error message (commit rejected, D-01).
const runValidator = (colId: any, value: any, row: any) => {
  const m = editMetaOf(colId);
  const v = m ? m.validate : null;
  if (typeof v !== 'function') return true;
  let r: any = null;
  try {
    r = v(value, row);
  } catch (err: any) {
    return 'Invalid value';
  }
  if (r === true) return true;
  if (typeof r === 'string') return r;
  return 'Invalid value';
};
// setInvalid: record the current validation error (drives the aria-live region +
// :aria-invalid wired in Task 3). Empty string clears it.
const setInvalid = (msg: any) => {
  invalidMsg.value = msg != null ? msg : '';
};
// replaceRowValue: build a FRESH array with ONE row object replaced (the column's field
// set to the new value); the rest share by reference (the family immutable whole-array
// replace — in-place mutation is silently dropped on React/Solid/Angular/Lit). rowIndex
// is over currentData() (== the visible model order for the non-virtual, unsorted/
// unfiltered single-cell case; the row id is carried for the commit payload).
const replaceRowValue = (rows: any, rowIndex: any, field: any, value: any) => {
  const src = rows || [];
  const out = [];
  for (let i = 0; i < src.length; i++) {
    if (i === rowIndex) {
      // WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
      // and would copy inherited enumerable props of typed/class-instance row objects.
      out.push({
        ...(src[i] || {}),
        [field]: value
      });
    } else {
      out.push(src[i]);
    }
  }
  return out;
};
// Map a visible-model body-row index ($data.rows index) to its underlying currentData()
// index via the row's original object identity (sorting/filtering/pagination may reorder
// the visible model away from the source array order). Falls back to the same index.
const sourceIndexOfRow = (visibleRowIndex: any) => {
  const rowList = rows.value || [];
  const row = rowList[visibleRowIndex];
  if (!row) return visibleRowIndex;
  const orig = row.original;
  const data = currentData() || [];
  const idx = data.indexOf(orig);
  return idx >= 0 ? idx : visibleRowIndex;
};
// The column id / field (accessorKey) / current value / row object / row id for the cell
// in EDIT — keyed off the authoritative editing pair ($data.editingRow/editingCol), NOT
// the active-cell indices (which can drift from the editing cell on a Tab-advance, and are
// async-stale right after a setState on React — ROZ138). Called only from commitEdit.
const editingColumnId = () => {
  const rowList = rows.value || [];
  const row = rowList[editingRow.value];
  if (!row) return null;
  const cells = visibleCellsFor(row);
  const cell = cells[editingCol.value];
  return cell && cell.column ? cell.column.id : null;
};
const editingColumnField = () => {
  const colId = editingColumnId();
  if (colId == null) return null;
  const d = defFor(colId);
  return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
const editingCellValue = () => {
  const rowList = rows.value || [];
  const row = rowList[editingRow.value];
  if (!row) return null;
  const cells = visibleCellsFor(row);
  const cell = cells[editingCol.value];
  return cell ? cell.getValue() : null;
};
const editingRowOriginal = () => {
  const rowList = rows.value || [];
  const row = rowList[editingRow.value];
  return row ? row.original : null;
};
const editingRowId = () => {
  const rowList = rows.value || [];
  const row = rowList[editingRow.value];
  return row ? row.id : null;
};
// Focus the freshly-mounted editor (Pitfall 1, ROZ123): after beginEdit flips the editing
// state, the editor <input> does not exist until the framework commits the r-if branch
// (React setState async; Solid/Lit/Svelte next reactive tick). Poll for the
// [data-editing-cell] element off gridRoot for ~30 frames — the five fast targets resolve
// on attempt 1, React retries across its async commit. NEVER read $refs eagerly.
// B2: selectAll gates the post-focus el.select(). Select-all is right when entering
// edit IN PLACE (F2/Enter/click/row-edit/validation-reject — no seeded char, the user
// retypes), but WRONG on a type-to-edit entry where a printable key already seeded the
// draft (selecting the seeded char makes the next keystroke replace it: Zeta → eta).
// beginEdit threads `seed == null` so a seeded entry skips the select and the caret sits
// AFTER the seeded char; every other caller keeps the default select-all.
// Editor-owns-focus contract (quick 260711-i5m): REVERTS the g52 shadow-piercing helper
// (commit 5fa30045) that recursed into descendant shadow roots. Built-in editors are
// host-DOM — the plain direct query resolves them on all 6 targets (no shadow to cross). A
// #editor DROP-IN now owns its OWN focus via the reactive `autofocus` prop (EditorText's
// $onMount + lazy $watch), so the host never needs to reach across a Lit drop-in's nested
// shadow root at all — see the !hasEditorSlot gate below, which skips the host focus call
// entirely for a drop-in target.
const focusEditorWhenReady = (selectAll = true) => {
  if (!gridRoot) return;
  // Editor-owns-focus contract: when the CURRENT focus target is a #editor drop-in, the host
  // does NOT reach into its DOM — the drop-in self-focuses via its own autofocus prop.
  if (editFocusColId.value != null && hasEditorSlot(editFocusColId.value)) return;
  let attempts = 0;
  const tryFocus = () => {
    const el = gridRoot ? gridRoot.querySelector('[data-editing-cell]') : null;
    // Do NOT stomp focus a later interaction already placed in a DIFFERENT column's editor of
    // this row: focusEditorWhenReady only needs to get focus INTO the (first) freshly-mounted
    // editor; if focus already sits in another editable cell, a late rAF re-focus would steal it
    // back to the first editor and break row-mode Tab containment (the non-deterministic B21
    // focus-theft). Compare the OWNING cell's data-col-index (NOT node identity) so a stale
    // SAME-column editor node on Solid's node-replacing re-render still resolves as the target —
    // a genuinely dropped focus is still recovered.
    const ae = gridRoot && gridRoot.getRootNode ? gridRoot.getRootNode().activeElement : null;
    if (ae && el && ae !== el && ae.closest && gridRoot.contains(ae) && ae.hasAttribute && ae.hasAttribute('data-editing-cell')) {
      const aeCell = ae.closest('[data-grid-cell]');
      const elCell = el.closest ? el.closest('[data-grid-cell]') : null;
      const aeCol = aeCell ? aeCell.getAttribute('data-col-index') : null;
      const elCol = elCell ? elCell.getAttribute('data-col-index') : null;
      if (aeCol != null && aeCol !== elCol) return;
    }
    if (el) {
      el.focus();
      if (selectAll && el.select) {
        try {
          el.select();
        } catch (e: any) {}
      }
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// Column id + current value at an EXPLICIT (rowIndex, colIndex) over the visible model —
// used by beginEdit so it never re-reads $data.activeRow/activeColIndex (which are async-
// stale right after a Tab-advance sets them on React — ROZ138).
const columnIdAt = (rowIndex: any, colIndex: any) => {
  const rowList = rows.value || [];
  const row = rowList[rowIndex];
  if (!row) return null;
  const cells = visibleCellsFor(row);
  const cell = cells[colIndex];
  return cell && cell.column ? cell.column.id : null;
};
const cellValueAt = (rowIndex: any, colIndex: any) => {
  const rowList = rows.value || [];
  const row = rowList[rowIndex];
  if (!row) return null;
  const cells = visibleCellsFor(row);
  const cell = cells[colIndex];
  return cell ? cell.getValue() : null;
};
// beginEdit: open the editor on the (rowIndex, colIndex) cell (req-1/3, D-05). seed===null
// → seed the EXISTING value (F2/Enter in-place edit); a printable char → REPLACE (the
// editor opens holding just that char). Resolves the column from the PASSED indices (not
// $data) so a Tab-advance that just setState'd activeRow/Col works on React. Clears any
// prior invalid state. Focus moves into the editor.
const beginEdit = (rowIndex: any, colIndex: any, seed: any) => {
  const colId = columnIdAt(rowIndex, colIndex);
  if (colId == null || !columnEditable(colId)) return;
  // A new edit session starts — reset the sync idempotency latch so THIS session's eventual
  // commit is not silently no-op'd by a PRIOR session's already-set latch.
  committedThisSession = false;
  setInvalid('');
  // Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
  // editor clears any row-edit state so isEditing never resolves both modes for one cell.
  editingRowIndex.value = null;
  rowDraft.value = {};
  editingRow.value = rowIndex;
  editingCol.value = colIndex;
  draftValue.value = seed != null ? seed : cellValueAt(rowIndex, colIndex);
  activeInControl.value = true;
  editVer.value = editVer.value + 1;
  // Editor-owns-focus contract (quick 260711-i5m): THIS cell's column is the current
  // focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope prop
  // from it. Cleared on endEdit.
  editFocusColId.value = colId;
  // B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
  // seeded char so subsequent typing appends instead of replacing it.
  focusEditorWhenReady(seed == null);
};
// Return focus to a body cell AFTER the editor unmounts (commit/cancel). The display↔
// editor re-render must commit before the <td> is focusable with its roving tabindex —
// on React/Solid/Lit that commit is async, so a synchronous focusActiveCell can run while
// the cell is still the editor (or mid-swap) and focus is lost. Bounded rAF-poll resolves
// the [data-row][data-col-index] cell off gridRoot for ~30 frames (the fast targets land
// on attempt 1; React/Solid retry across the async commit). Mirrors focusEditorWhenReady.
const focusCellWhenReady = (row: any, col: any) => {
  if (!gridRoot) return;
  let attempts = 0;
  const tryFocus = () => {
    const el = resolveCellEl(String(row), col);
    if (el) {
      el.focus();
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// B23: the index of a committed row WITHIN a given (fresh) visible-model array, resolved by
// row IDENTITY. table-core's default getRowId is source-index-based, so a row's id is stable
// across a re-sort (only its VISIBLE position moves); a committed edit replaces the row object
// via a fresh spread (the `original` reference changes), so match by `id` FIRST, `original`
// only as a fallback. Returns -1 when the row filtered out of the view. PURE (the caller passes
// the FRESH row list — refreshRowModel's just-pulled `nextRows`, never the React-stale state).
const indexOfRowIn = (rows: any, rowOriginal: any, rowId: any) => {
  const list = rows || [];
  for (let i = 0; i < list.length; i++) {
    const r = list[i];
    if (!r) continue;
    if (rowId != null && r.id === rowId) return i;
    if (rowOriginal != null && r.original === rowOriginal) return i;
  }
  return -1;
};
// endEdit: tear down the editor (shared by commit/cancel). Clears the editing pair +
// draft + invalid state and returns to navigation mode. Does NOT move focus (callers
// decide where focus lands — commit/cancel return it to the owning cell).
const endEdit = () => {
  editingRow.value = -1;
  editingCol.value = -1;
  draftValue.value = null;
  invalidMsg.value = '';
  activeInControl.value = false;
  editVer.value = editVer.value + 1;
  editFocusColId.value = null;
};
// endRowEdit: tear down full-row edit (shared by commitRow/cancelRow). Clears the row
// index + the per-cell drafts + invalid state and returns to navigation mode. Does NOT
// move focus (callers return it to the active cell). Mirrors endEdit for the row mode.
const endRowEdit = () => {
  editingRowIndex.value = null;
  rowDraft.value = {};
  invalidMsg.value = '';
  activeInControl.value = false;
  editVer.value = editVer.value + 1;
  editFocusColId.value = null;
};
// editorAutofocusFor (quick 260711-i5m, editor-owns-focus contract): the reactive `autofocus`
// #editor scope prop for a given (colId, rowIndex) — true for EXACTLY the current focus-
// target cell, re-deriving on every editVer bump (mirrors isEditing's reactive gate so
// Svelte/Solid re-run this per-cell on a foreign-slot-callback state mutation). Works for
// BOTH single-cell ($data.editingRow) and row mode ($data.editingRowIndex) since
// $data.editFocusColId is set by both beginEdit and beginRowEdit/commitRow/rowEditTab.
const editorAutofocusFor = (colId: any, rowIndex: any) => {
  if (editVer.value < 0) return false;
  if (editingRowIndex.value != null) {
    if (editingRowIndex.value !== rowIndex) return false;
  } else {
    if (editingRow.value !== rowIndex) return false;
  }
  return editFocusColId.value != null && editFocusColId.value === colId;
};
// B3: coerce the committed value by the column's built-in editor type at the single
// commit funnel. A 'number' editor commits a real Number; an empty/whitespace/non-numeric
// draft commits null (never '' / never NaN — Number('') === 0 is a silent footgun). Every
// other editor type commits the value verbatim. Idempotent for the #editor drop-in path
// (an already-numeric override passes through; an explicit null stays null).
const coerceCellValue = (colId: any, raw: any) => {
  if (editorTypeOf(colId) !== 'number') return raw;
  if (raw == null) return null;
  if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
  const s = String(raw).trim();
  if (s === '') return null;
  const n = Number(s);
  return Number.isNaN(n) ? null : n;
};
// commitEdit: validate the draft (req-5); on success replace one row in a fresh array,
// funnel it through writeData (the controlled r-model:data write, req-4), emit EXACTLY
// ONE cell-edit-commit from THIS single call site (React multi-emit dedup, D-07), then
// return focus to the cell. On a validation FAILURE keep the editor OPEN (D-01) — set
// invalid, re-trap focus, never write the model. Captures the optional override value
// (the #editor slot's commit(v) call) else the live draft.
// Returns true when the commit succeeded (model written, editor closed); false when a
// validation failure kept the editor OPEN (D-01). Callers MUST use this return value, not
// a synchronous re-read of $data.editingRow — React's endEdit setState is async, so an
// immediate re-read of editingRow still shows the OLD value (the ROZ138 stale-read class).
const commitEdit = (overrideValue = undefined, skipFocusReturn = false) => {
  if (editingRow.value < 0) return false;
  // Sync idempotency latch (drop-in double cell-edit-commit fix): a second commitEdit call
  // within the SAME edit session — the deferred drop-in's unmount-blur re-entry, which on
  // React fires while $data.editingRow is still async-stale ≥ 0 — no-ops here instead of
  // re-validating/re-writing/re-emitting. Reset by beginEdit/beginRowEdit/editCell.
  if (committedThisSession) return false;
  const colId = editingColumnId();
  if (colId == null) {
    endEdit();
    return false;
  }
  const field = editingColumnField();
  const oldValue = editingCellValue();
  const rowOriginal = editingRowOriginal();
  const rowId = editingRowId();
  // B3: coerce by the column's editor type BEFORE validation + write so the validator
  // and the model both see the typed value (number/null), not the raw draft string.
  const rawValue = overrideValue !== undefined ? overrideValue : draftValue.value;
  const newValue = coerceCellValue(colId, rawValue);
  const err = runValidator(colId, newValue, rowOriginal);
  if (err !== true) {
    // D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
    setInvalid(err);
    focusEditorWhenReady();
    return false;
  }
  setInvalid('');
  // #5: a no-op commit (the coerced value is UNCHANGED — a bare Enter/Tab/blur that edited
  // nothing) must do NO model write, NO history record, and NO commit event: writeData →
  // recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row identity, so an
  // unconditional write on a no-op would destroy redo + spuriously re-render + emit a no-op
  // cell-edit-commit. Compute `changed` and gate the write/emit on it; ALWAYS close the editor.
  const changed = !Object.is(newValue, oldValue);
  // Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
  const focusRow = editingRow.value;
  const focusCol = editingCol.value;
  // Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
  // must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
  editTransition = true;
  // Sync idempotency latch: flip BEFORE writeData/endEdit so the async unmount-blur re-entry
  // (which fires AFTER this call returns, once editTransition is already back to false) finds
  // it set at the top-of-function guard above and no-ops. Set on BOTH paths so a no-op commit
  // is just as re-entry-safe as a real one.
  committedThisSession = true;
  if (changed) {
    const srcIndex = sourceIndexOfRow(editingRow.value);
    const next = replaceRowValue(currentData(), srcIndex, field, newValue);
    writeData(next);
    // Exactly one emit per commit, from this single call site (writeData does NOT emit).
    emit('cell-edit-commit', {
      rowId,
      columnId: colId,
      oldValue,
      newValue
    });
  }
  endEdit();
  editTransition = false;
  if (changed) {
    // Defer the focus return so the display↔editor re-render commits first (async on
    // React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
    // editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
    // Tab-advance (the caller immediately opens the next editor and focuses THAT).
    // B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
    // RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
    // index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
    // consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
    // fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
    // active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
    // index → byte-behaviorally identical to before.
    if (skipFocusReturn !== true) pendingEditFollow = {
      rowOriginal,
      rowId,
      col: focusCol
    };
  } else if (skipFocusReturn !== true) {
    // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume
    // a pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
    // relocate (no write), so the B23 relocation hazard that forces the pendingEditFollow path on
    // a real commit does not apply here: the fixed (focusRow, focusCol) is correct and safe.
    focusCellWhenReady(focusRow, focusCol);
  }
  return true;
};
// toggleActiveBooleanCell (design doc 2026-07-05, Change 1): the spreadsheet-standard
// single-keystroke boolean toggle. Flips the ACTIVE cell's value and commits it through the
// EXACT SAME write funnel commitEdit uses (replaceRowValue → writeData → single $emit) but
// WITHOUT opening an editor — there is no editingRow/editingCol involvement at all, so this
// operates entirely off $data.activeRow/activeColIndex. Gated in onGridKeyDown to
// editor:'checkbox' columns only (Space/Enter/F2), full-row edit mode is unaffected (the
// editingRowIndex early return in onGridKeyDown already excludes it).
const toggleActiveBooleanCell = () => {
  const colId = columnIdAt(activeRow.value, activeColIndex.value);
  if (colId == null || !columnEditable(colId)) return;
  const rowList = rows.value || [];
  const row = rowList[activeRow.value];
  if (!row) return;
  const rowOriginal = row.original;
  const rowId = row.id;
  const oldValue = cellValueAt(activeRow.value, activeColIndex.value);
  const newValue = !oldValue;
  // D-01: same discipline as commitEdit — a rejecting validator blocks the toggle. There is
  // no editor to keep open here, so the toggle simply does not apply (no model write).
  const err = runValidator(colId, newValue, rowOriginal);
  if (err !== true) {
    setInvalid(err);
    return;
  }
  setInvalid('');
  const def = defFor(colId);
  const field = def && def.accessorKey != null ? def.accessorKey : colId;
  const srcIndex = sourceIndexOfRow(activeRow.value);
  // Sync idempotency latch: this toggle is a commit-equivalent (mirrors commitEdit's D-07
  // single-emit discipline) — flip it too so a stray re-entry after this toggle no-ops.
  committedThisSession = true;
  writeData(replaceRowValue(currentData(), srcIndex, field, newValue));
  // Exactly one emit per toggle, from this single call site (writeData does NOT emit) —
  // mirrors commitEdit's D-07 single-emit discipline.
  emit('cell-edit-commit', {
    rowId,
    columnId: colId,
    oldValue,
    newValue
  });
  // Follow the toggled row's focus through a boolean sort/filter relocation AND a
  // fine-grained keyed-row replace (Solid) — the SAME recovery commitEdit relies on; even
  // with no editor to unmount, writeData's re-render can still drop focus.
  pendingEditFollow = {
    rowOriginal,
    rowId,
    col: activeColIndex.value
  };
};
// cancelEdit: discard the draft (D-05 — revert to the pre-edit value, no model write) and
// return focus to the owning cell.
const cancelEdit = () => {
  if (editingRow.value < 0) return;
  // CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
  // Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
  // an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
  // instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
  const focusRow = editingRow.value;
  const focusCol = editingCol.value;
  editTransition = true;
  endEdit();
  editTransition = false;
  focusCellWhenReady(focusRow, focusCol);
};
// ══ Full-row edit lifecycle (phase 51 plan 03 / req-6 / D-06, RESEARCH Pattern 6) ════════
// Shift+F2 (and the editRow $expose verb) put EVERY editable cell in the active row into
// edit at once; one save commits the whole row in ONE writeData (a single fresh-array row
// replace) + ONE row-edit-commit event; Escape reverts the whole row as a unit. Per-column
// validation still runs on each edited cell at commit (D-01 keep-open if ANY fails). The
// editor template branch (isEditing's row arm) is re-used verbatim — no per-mode fork.

// The editable [columnId, field] pairs for a body row at the given visible-model index,
// in visible-cell order. field is the column's accessorKey (the row-object key to write).
const editableColumnsForRow = (rowIndex: any) => {
  const rowList = rows.value || [];
  const row = rowList[rowIndex];
  if (!row) return [];
  const cells = visibleCellsFor(row);
  const out = [];
  for (let c = 0; c < cells.length; c++) {
    const cell = cells[c];
    const colId = cell && cell.column ? cell.column.id : null;
    if (colId == null || !columnEditable(colId)) continue;
    const d = defFor(colId);
    const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
    // colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
    // Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
    // can address a SPECIFIC editor by column, not just the first [data-editing-cell].
    out.push({
      colId,
      field,
      colIndex: c
    });
  }
  return out;
};
// B21/B22: focus the row-mode editor at a given VISIBLE col index. In full-row edit every
// editable cell is already mounted as an editor, so this resolves the cell off gridRoot and
// focuses its [data-editing-cell] control. Bounded rAF-poll (mirrors focusEditorWhenReady)
// so a React re-render that recreates the input across the focus call still lands it. select-
// all on text/number editors (a no-op try/catch on select/checkbox).
// Editor-owns-focus contract (quick 260711-i5m): when the TARGET column is a #editor
// drop-in, the host does NOT reach into its DOM (early return, before starting the rAF poll
// at all) — the drop-in self-focuses via its own reactive `autofocus` prop, which the caller
// (commitRow's B22 reject path / rowEditTab) already flips via $data.editFocusColId. Built-in
// columns are unaffected (hasEditorSlot is false for them) — unchanged host direct-focus.
const focusRowEditorAt = (rowIndex: any, colIndex: any) => {
  if (!gridRoot) return;
  const colId = columnIdAt(rowIndex, colIndex);
  if (colId != null && hasEditorSlot(colId)) return;
  let attempts = 0;
  const tryFocus = () => {
    const cellEl = resolveCellEl(String(rowIndex), colIndex);
    const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
    if (ed) {
      ed.focus();
      if (ed.select) {
        try {
          ed.select();
        } catch (e: any) {}
      }
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// beginRowEdit(row): enter full-row edit on a body row (req-6). Seeds rowDraft from each
// editable column's CURRENT value (so an immediate save is a no-op), clears any single-cell
// edit (mutual exclusivity), and focuses the first editable cell's editor (the bounded
// rAF-poll resolves the first [data-editing-cell] off gridRoot — same mechanism as
// focusEditorWhenReady). Accepts the row OBJECT (the template/Shift+F2 path) — index-resolved
// internally via rowIndexOf so it stays in the editingRow/activeRow index space.
const beginRowEdit = (row: any) => {
  const rowIndex = rowIndexOf(row);
  if (rowIndex < 0) return;
  const editable = editableColumnsForRow(rowIndex);
  if (editable.length === 0) return;
  // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
  committedThisSession = false;
  // Clear any single-cell editor first (mutual exclusivity).
  editingRow.value = -1;
  editingCol.value = -1;
  draftValue.value = null;
  setInvalid('');
  // Seed each editable cell's draft from its current value.
  const draft = {};
  const rowList = rows.value || [];
  const r = rowList[rowIndex];
  const orig = r ? r.original : null;
  for (let i = 0; i < editable.length; i++) {
    const ec = editable[i];
    draft[ec.colId] = orig ? orig[ec.field] : null;
  }
  rowDraft.value = draft;
  editingRowIndex.value = rowIndex;
  activeInControl.value = true;
  editVer.value = editVer.value + 1;
  // Editor-owns-focus contract (quick 260711-i5m): the row's FIRST editable column is the
  // initial focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope
  // prop from it (a built-in column is also host-focused below via focusEditorWhenReady; a
  // drop-in column self-focuses via its own $onMount, gated off the host reach-in in Task 3).
  editFocusColId.value = editable[0].colId;
  focusEditorWhenReady();
};
// commitRow(): validate EVERY edited column (D-01 — keep the row open if ANY fails: set
// invalid + announce, NEVER write the model); on all-valid build ONE fresh array replacing
// the single row object with all rowDraft values applied at once, call writeData ONCE, then
// emit ONE row-edit-commit from THIS single call site, clear the row state, return focus.
// Returns true on a written commit, false when a validation failure kept the row open.
const commitRow = () => {
  if (editingRowIndex.value == null) return false;
  const rowIndex = editingRowIndex.value;
  const editable = editableColumnsForRow(rowIndex);
  if (editable.length === 0) {
    endRowEdit();
    return false;
  }
  const rowList = rows.value || [];
  const r = rowList[rowIndex];
  const rowOriginal = r ? r.original : null;
  const rowId = r ? r.id : null;
  const draft = rowDraft.value || {};
  // Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
  // B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
  // 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
  // commitEdit already coerces via coerceCellValue; the row path silently committed strings →
  // a number column ended up holding '99'). Coerce once here so the validator and the model both
  // see the typed value, identical to the single-cell funnel.
  for (let i = 0; i < editable.length; i++) {
    const ec = editable[i];
    const err = runValidator(ec.colId, coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
    if (err !== true) {
      setInvalid(err);
      // Editor-owns-focus contract (quick 260711-i5m): the OFFENDING column becomes the new
      // reactive focus target BEFORE the host-focus call below — a #editor drop-in already
      // mounted (full-row edit opens every editable cell at once) picks this up via its own
      // lazy $watch on the `autofocus` scope prop flipping false→true. Bump editVer so the
      // coarse-render targets (React/Vue/Angular/Svelte) re-derive the slot binding (Solid's
      // fine-grained accessor re-runs without the bump, but the bump keeps all 6 in lockstep).
      editFocusColId.value = ec.colId;
      editVer.value = editVer.value + 1;
      // B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
      // unconditionally the first editor (focusEditorWhenReady resolves the first
      // [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
      // Gated (Task 3) so a #editor drop-in self-focuses instead of a host DOM reach-in.
      focusRowEditorAt(rowIndex, ec.colIndex);
      return false;
    }
  }
  setInvalid('');
  // Build the changes payload (only the columns whose value actually changed) + the field→
  // value map for the single row-object replace.
  const changes = [];
  const fieldValues = {};
  for (let i = 0; i < editable.length; i++) {
    const ec = editable[i];
    // B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
    // string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
    const newValue = coerceCellValue(ec.colId, draft[ec.colId]);
    const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
    fieldValues[ec.field] = newValue;
    if (oldValue !== newValue) changes.push({
      columnId: ec.colId,
      oldValue,
      newValue
    });
  }
  // Snapshot the active cell to return focus to (the whole row is in edit, so the active-cell
  // row/column is the roving focus target), BEFORE endRowEdit clears editing state.
  const focusRow = activeRow.value;
  const focusCol = activeColIndex.value;
  // #5: a no-op row commit (NO column's value actually changed — a bare Enter/save/outside-click
  // that edited nothing) must do NO model write, NO history record, NO row-edit-commit event:
  // writeData → recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row
  // identity, so an unconditional write on a no-op destroys redo + spuriously re-renders + emits
  // a no-op row-edit-commit. Gate the write/emit on `changes.length`; ALWAYS close the editor.
  const changed = changes.length > 0;
  editTransition = true;
  if (changed) {
    // ONE fresh-array replace of the SINGLE row object with all field values applied at once.
    const srcIndex = sourceIndexOfRow(rowIndex);
    const next = replaceRowValues(currentData(), srcIndex, fieldValues);
    writeData(next);
    // EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
    emit('row-edit-commit', {
      rowId,
      changes
    });
  }
  endRowEdit();
  editTransition = false;
  if (changed) {
    // WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
    // like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
    // col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
    // $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
    // DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
    // refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
    // display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
    // the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
    // poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
    pendingEditFollow = {
      rowOriginal,
      rowId,
      col: focusCol
    };
  } else {
    // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume a
    // pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
    // relocate (no write), so the B23 relocation hazard does not apply: (focusRow, focusCol) is safe.
    focusCellWhenReady(focusRow, focusCol);
  }
  return true;
};
// cancelRow(): revert the whole row as a unit (D-06 — drop every draft, NO model write) and
// return focus to the active cell.
const cancelRow = () => {
  if (editingRowIndex.value == null) return;
  const focusRow = activeRow.value;
  const focusCol = activeColIndex.value;
  editTransition = true;
  endRowEdit();
  editTransition = false;
  focusCellWhenReady(focusRow, focusCol);
};
// replaceRowValues: like replaceRowValue but applies a MAP of field→value to ONE row object
// in a single fresh-array replace (req-6 — the whole-row commit is ONE write, not per cell).
const replaceRowValues = (rows: any, rowIndex: any, fieldValues: any) => {
  const src = rows || [];
  const fv = fieldValues || {};
  const out = [];
  for (let i = 0; i < src.length; i++) {
    if (i === rowIndex) {
      // WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
      // prototype-walking copy. Spread copies own enumerable props only.
      out.push({
        ...(src[i] || {}),
        ...fv
      });
    } else {
      out.push(src[i]);
    }
  }
  return out;
};
// Compute the next editable cell for Tab-advance (req-3, RESEARCH Open-Q3 deterministic
// rule): skip non-editable columns within the row; wrap to the NEXT row's first editable
// cell at the row's end; stop (return null) at grid end. Pure index math over the visible
// model. Returns { row, col } or null.
const nextEditableCell = (fromRow: any, fromCol: any) => {
  const rowList = rows.value || [];
  const rowCount = rowList.length;
  if (rowCount === 0) return null;
  let r = fromRow;
  let c = fromCol + 1;
  while (r < rowCount) {
    const row = rowList[r];
    const cells = row ? visibleCellsFor(row) : [];
    while (c < cells.length) {
      const cell = cells[c];
      const cid = cell && cell.column ? cell.column.id : null;
      if (cid != null && columnEditable(cid)) return {
        row: r,
        col: c
      };
      c = c + 1;
    }
    r = r + 1;
    c = 0;
  }
  return null;
};
// B4: the mirror of nextEditableCell — the PREVIOUS editable cell for a Shift+Tab
// backward move. Skips non-editable columns leftward within the row; wraps to the END
// of the prior row; stops (returns null) at grid start. Pure index math over the visible
// model. Returns { row, col } or null.
const prevEditableCell = (fromRow: any, fromCol: any) => {
  const rowList = rows.value || [];
  const rowCount = rowList.length;
  if (rowCount === 0) return null;
  let r = fromRow;
  let c = fromCol - 1;
  while (r >= 0) {
    const row = rowList[r];
    const cells = row ? visibleCellsFor(row) : [];
    while (c >= 0) {
      const cell = cells[c];
      const cid = cell && cell.column ? cell.column.id : null;
      if (cid != null && columnEditable(cid)) return {
        row: r,
        col: c
      };
      c = c - 1;
    }
    r = r - 1;
    if (r >= 0) {
      const prow = rowList[r];
      const pcells = prow ? visibleCellsFor(prow) : [];
      c = pcells.length - 1;
    }
  }
  return null;
};
// Transient guard: true while an editor commit/cancel/Tab-advance is tearing the current
// editor down. The unmounting editor fires a `blur` as it leaves the DOM — without this
// guard onEditorBlur would re-enter commitEdit on the (already-resolved or newly-opened)
// cell, double-counting cell-edit-commit. A top-level `let` (React hoists to useRef).
let editTransition = false;
// B23: a pending "follow the committed row's focus" request, set by commitEdit (a single-cell
// commit that may relocate the row under an active sort/filter) and consumed ONCE by the next
// refreshRowModel pass — which runs with the FRESH re-derived row model, so it can resolve the
// committed row's NEW display index (React-stale-safe) and re-seat focus there. Shape:
// { rowOriginal, rowId, col } or null. A top-level `let` (React hoists to useRef → persists).
let pendingEditFollow: any = null;
// Sync idempotency latch for a cell commit (drop-in double cell-edit-commit fix, 260705):
// commitEdit's `$data.editingRow < 0` re-entry guard is ASYNC-STALE on React — a deferred
// drop-in editor's unmount-blur (onBlur → $props.commit → commitEdit) fires AFTER commitEdit
// has already returned (editTransition is a SYNC latch, cleared before the async blur), while
// `$data.editingRow` in that stale closure still reads the OLD (pre-endEdit) value, so the
// second commit slips through and re-emits `cell-edit-commit`. A top-level `let` is written/read
// synchronously by plain assignment (unaffected by React's setState batching — that's the point)
// so it stays correct across the async window editTransition/editingRow cannot cover. Set true on
// a SUCCESSFUL commitEdit/toggleActiveBooleanCell; reset to false wherever a NEW edit session
// begins (beginEdit/beginRowEdit/editCell) so the next legitimate commit fires exactly once.
// A top-level `let` (React hoists to useRef → persists).
let committedThisSession = false;
// ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
// In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
// (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
// rowDraft — so the four editors open simultaneously never clobber one shared value. These
// helpers let the ONE editor template branch serve BOTH modes (no per-mode template fork):
// the template binds editorValueFor(colId)/editorCheckedFor(colId) and writes via
// onCellEditorInput(colId, evt)/onCellEditorCheckbox(colId, evt).
const inRowEdit = () => editingRowIndex.value != null;
const editorValueFor = (colId: any) => inRowEdit() ? rowDraft.value ? rowDraft.value[colId] : null : draftValue.value;
const editorCheckedFor = (colId: any) => !!(inRowEdit() ? rowDraft.value ? rowDraft.value[colId] : null : draftValue.value);
// #editor custom-slot callbacks (req-2/6): the consumer's slot calls commit(value)/cancel().
// In SINGLE-CELL mode commit(v) commits that cell (commitEdit override); in ROW mode commit(v)
// only WRITES this column's draft (the row commits as a unit later — never per cell). cancel()
// reverts the cell (single) or the whole row (row mode). Factory-bound per columnId so the
// row-mode commit targets the right draft key.
const editorCommitFor = (colId: any) => (value: any) => {
  if (inRowEdit()) {
    setRowDraft(colId, value);
    return;
  }
  commitEdit(value);
};
const editorCancelFor = () => () => {
  if (inRowEdit()) {
    cancelRow();
    return;
  }
  cancelEdit();
};
// Editor input handlers (the global-filter `evt.target.value` idiom — an untyped param
// neutralizes to `any`, so reading .value/.checked typechecks ×6; an inline
// `$data.x = $event.target.value` binding does NOT neutralize and breaks Lit/React JSX).
// Column-aware: in row mode they write rowDraft[colId] (a FRESH object so Solid/Svelte/React
// re-derive); single-cell they write the shared draftValue.
const onCellEditorInput = (colId: any, evt: any) => {
  const v = evt && evt.target ? evt.target.value : '';
  if (inRowEdit()) {
    setRowDraft(colId, v);
    return;
  }
  draftValue.value = v;
};
const onCellEditorCheckbox = (colId: any, evt: any) => {
  const v = !!(evt && evt.target && evt.target.checked);
  if (inRowEdit()) {
    setRowDraft(colId, v);
    return;
  }
  draftValue.value = v;
};
// setRowDraft: write ONE key into a FRESH rowDraft object (whole-object replace — an
// in-place mutation is silently dropped on React/Solid; the family immutable rule).
const setRowDraft = (colId: any, value: any) => {
  const src = rowDraft.value || {};
  const next = {};
  for (const k in src) next[k] = src[k];
  next[colId] = value;
  rowDraft.value = next;
};
// B21: contain a Tab WITHIN the editing row (editMode='row'). Resolve the editable cells'
// visible col indices for the editing row, find the current editor's col (off the blurring
// editor's owning [data-grid-cell]), then move to the next/prev editable col WITH WRAP so
// focus never leaves the row. A no-op when no row is editing / the row has no editable cells.
const rowEditTab = (target: any, backward: any) => {
  const rowIndex = editingRowIndex.value;
  if (rowIndex == null) return;
  const editable = editableColumnsForRow(rowIndex);
  if (editable.length === 0) return;
  const cols = editable.map((ec: any) => ec.colIndex);
  const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
  const curAttr = cell ? cell.getAttribute('data-col-index') : null;
  const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
  let pos = cols.indexOf(cur);
  if (pos < 0) pos = 0;
  const len = cols.length;
  const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
  // Editor-owns-focus contract (quick 260711-i5m): the Tab target becomes the new reactive
  // focus target BEFORE the host-focus call below, so Tab onto an already-mounted #editor
  // drop-in (row mode) also refocuses it via its own lazy $watch.
  editFocusColId.value = editable[nextPos].colId;
  editVer.value = editVer.value + 1;
  focusRowEditorAt(rowIndex, cols[nextPos]);
};
// onEditorKeyDown: the editor-LOCAL keymap (req-3). Enter → commit + stay (focus returns
// to the cell); Tab → commit + advance to the next editable cell; Escape → cancel +
// revert. preventDefault on handled keys so the grid keymap / native Tab don't double-act.
const onEditorKeyDown = (e: any) => {
  if (!e) return;
  const key = e.key;
  // Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
  // model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
  // row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
  // preventDefault it here.
  if (inRowEdit()) {
    if (key === 'Enter') {
      e.preventDefault();
      commitRow();
    } else if (key === 'Escape') {
      e.preventDefault();
      cancelRow();
    }
    // B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
    // editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
    // Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
    // first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
    // tab order across a Lit shadow boundary).
    else if (key === 'Tab') {
      e.preventDefault();
      rowEditTab(e.target, e.shiftKey);
    }
    return;
  }
  if (key === 'Enter') {
    e.preventDefault();
    commitEdit(undefined);
  } else if (key === 'Tab') {
    e.preventDefault();
    // Resolve the advance target from the EDITING pair (the cell that is open), not the
    // active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
    // moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
    // the editing pair BEFORE commit (commitEdit resets it to -1).
    const fromRow = editingRow.value;
    const fromCol = editingCol.value;
    const target = e.shiftKey ? prevEditableCell(fromRow, fromCol) : nextEditableCell(fromRow, fromCol);
    // skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
    // straight into the next editable cell's editor below. Use the RETURN value (not a
    // re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
    // failure returns false and keeps the editor open (the user must fix the value first).
    const committed = commitEdit(undefined, true);
    if (committed && target) {
      activeRow.value = target.row;
      activeColIndex.value = target.col;
      beginEdit(target.row, target.col, null);
    } else if (committed) {
      // B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
      // grid by returning it to the just-committed cell instead of letting it drop to <body>.
      focusCellWhenReady(fromRow, fromCol);
    }
  } else if (key === 'Escape') {
    e.preventDefault();
    cancelEdit();
  }
};
// onEditorBlur: commit on a genuine click/focus-away (D-01 — an invalid value keeps the
// editor open via commitEdit's reject path). SKIP when:
//  - editTransition is set (a synchronous commit/cancel teardown is unmounting the editor), or
//  - the blur is part of a controlled keyboard transition: focus is moving to a grid cell
//    or another editor inside our gridRoot (Tab-advance, Enter/Escape focus-return). On the
//    async-render targets the unmount-blur can fire AFTER the synchronous flag cleared, so
//    the relatedTarget/containment check is the load-bearing guard, not the flag alone.
const onEditorBlur = (e: any) => {
  // Full-row mode (req-6): a blur that stays WITHIN the row editor — Tab/click between the
  // row's OWN fields — is a normal focus move and must NOT commit (a per-cell blur-commit
  // would split the row into N writes + N events, violating the one-write/one-event contract).
  // But an OUTSIDE-click blur (#7) MUST commit the row: otherwise the model is never written
  // AND editingRowIndex stays set, freezing onGridKeyDown's editingRowIndex early-return so
  // arrow-nav is dead the moment the user clicks back into the grid. Mirror the single-cell
  // branch's relatedTarget shape to tell an in-row focus move from a genuine click-away.
  if (inRowEdit()) {
    // Guard the teardown blur: commitRow's writeData/endRowEdit re-render unmounts the row's
    // editors → a same-tick re-render blur must NOT re-enter commitRow (double row-edit-commit).
    // commitRow sets editTransition synchronously BEFORE writeData, so it is set here during the
    // teardown window (the async unmount-blur that fires after endRowEdit finds editingRowIndex
    // already null → inRowEdit() false → the single-cell tail's editingRow<0 guard returns).
    if (editTransition) return;
    const rowNext = e ? e.relatedTarget : null;
    const rowNextCell = rowNext && rowNext.closest ? rowNext.closest('[data-grid-cell]') : null;
    const rowNextRow = rowNextCell ? rowNextCell.getAttribute('data-row') : null;
    // Focus landing on a cell of the SAME editing row (Tab/click between the row's own fields) →
    // controlled in-row move, do NOT commit. Anything else — a null relatedTarget, another row,
    // a toolbar/widget, or outside the grid entirely — is an outside-click → commit the row as a
    // unit. commitRow clears editingRowIndex, releasing onGridKeyDown's early-return so nav
    // resumes; a no-op row (nothing changed) takes commitRow's clean #5 no-write/no-emit path.
    if (rowNextRow != null && rowNextRow === String(editingRowIndex.value)) return;
    commitRow();
    return;
  }
  if (editingRow.value < 0 || editTransition) return;
  const next = e ? e.relatedTarget : null;
  // A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
  // keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
  // a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
  // conservative null=skip behavior.
  if (next == null) return;
  // Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
  // the editor open on an invalid value).
  if (!(gridRoot && gridRoot.contains && gridRoot.contains(next))) {
    commitEdit(undefined);
    return;
  }
  // Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
  // keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
  // (which must commit + close so the grid is not wedged with an open editor).
  const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
  const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
  // Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
  // controlled move; skip. Also skip when either cell can't be resolved (an unmounting
  // editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
  if (!nextCell || !fromCell || nextCell === fromCell) return;
  // A Tab-advance already committed the old editor and opened the next one, so the live
  // editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
  // editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
  // double commit, WR-04).
  const fromRow = fromCell.getAttribute('data-row');
  const fromCol = fromCell.getAttribute('data-col-index');
  if (fromRow !== String(editingRow.value) || fromCol !== String(editingCol.value)) return;
  // Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
  // commit does NOT bounce focus back to the just-committed editing cell (which would fight
  // the click destination). The commit's writeData re-renders the table and can DROP DOM
  // focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
  // DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
  // (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
  // late, which would steal focus back from a subsequent navigation.
  const destRow = nextCell.getAttribute('data-row');
  const destCol = nextCell.getAttribute('data-col-index');
  commitEdit(undefined, true);
  const reseatDestFocus = () => {
    if (!gridRoot || destRow == null || destCol == null || destRow === '__header') return;
    const root = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
    const act = root && root.activeElement ? root.activeElement : null;
    // Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
    if (act && gridRoot.contains && gridRoot.contains(act)) return;
    const el = resolveCellEl(destRow, parseInt(destCol, 10));
    if (el) el.focus();
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
};
// editCell(rowIndex, colIndex) — programmatic edit-entry ($expose, req-3). Coerces +
// clamps indices, moves the active cell, and opens the editor (no-op on a non-editable
// cell). Collision-clean (RESEARCH name-check): not a verb/event/prop/ROZ137 member.
const editCell = (rowIndex: any, colIndex: any) => {
  const lastRow = bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const maxCol = visibleColCount() - 1;
  const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
  const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
  // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
  committedThisSession = false;
  activeIsHeader.value = false;
  activeRow.value = r;
  activeColIndex.value = c;
  beginEdit(r, c, null);
};
// commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
// nothing is editing. Collision-clean (not `commit`). Handles BOTH edit modes: a full-row
// edit (editRow()/Shift+F2) drives editingRowIndex and leaves editingRow at -1, so the
// single-cell commitEdit guard (editingRow >= 0) is false during a row edit — route to
// commitRow() first so a programmatic commit of a row editor is not a silent no-op.
const commitEditing = () => {
  if (inRowEdit()) {
    commitRow();
    return;
  }
  if (editingRow.value >= 0) commitEdit(undefined);
};
// editRow(rowIndex) — programmatically enter full-row edit on a body row ($expose, req-6 /
// D-06), the API twin of the Shift+F2 shortcut. Addressed BY INDEX over the visible model
// (coerced + clamped); no-op on a row with no editable columns. Collision-clean (RESEARCH
// name-check): `editRow` is not in the 15 existing verbs, not a prop, not a *-change/commit
// event, not a Lit ROZ137-reserved host member. Moves the active cell to the row first so the
// commit/cancel focus-return lands in the right row.
const editRow = (rowIndex: any) => {
  const lastRow = bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
  const rowList = rows.value || [];
  const row = rowList[r];
  if (!row) return;
  activeIsHeader.value = false;
  activeRow.value = r;
  beginRowEdit(row);
};
// ── Grid active-cell $expose verbs (phase 49 plan 03, D-01) — exactly THREE, joining the
// existing 12 (→ 15). Collision-safe names (Pitfall 1): focusCell NOT `focus` (would shadow
// HTMLElement.focus on Lit — ROZ137); clearActiveCell NOT `clear` (listbox already exposes
// `clear`); getActiveCell is a read-style getter. None collide with the 9 *-change events,
// any prop, or a React auto-setter (ROZ121/137/524 clear). ──────────────────────────────────

// focusAbsCellWhenReady — paginated page-switch focus poll (C1). After a programmatic page
// switch the in-page (localRow, col) cell is ambiguous: EVERY page renders a row at the same
// page-relative index, so a plain resolveCellEl(localRow, col) poll would grab the OLD page's
// cell on frame 1 (before the switch commits) and focus it — only for the page switch to then
// REMOVE it, dropping focus to <body>. Disambiguate by the ABSOLUTE aria-rowindex: poll until
// the cell at (localRow, col) carries the TARGET page's body aria-rowindex (i.e. the TARGET
// page has actually rendered), THEN focus. DOM-only (reads gridRoot), so React-stale-safe; works
// for both controlled (round-trips through page-change) and uncontrolled pagination. ~60 frames
// (~1s) to cover the controlled-state parent round-trip on React/Solid/Lit.
//   #13: the body aria-rowindex is now header-offset (bodyAriaRowIndex = headerRowCount + absRow
//   + 1) so header rows + body rows form one consistent aria-rowindex/aria-rowcount space — so
//   the poll target must add headerRowCount() too, else it never matches and focus drops.
const focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
  if (!gridRoot) return;
  let attempts = 0;
  const want = String(headerRowCount() + absRow + 1);
  // #9: capture the focus-intent epoch at arm time (AFTER focusCell's own bump at its top, so
  // this poll never aborts itself). A LATER focus intent — a click landing on a new cell
  // (syncActiveFromEvent) or another focusCell / keyboard nav — bumps the epoch, so this
  // paginated page-switch poll aborts instead of grabbing focus frames after the user moved on.
  const myEpoch = focusIntentEpoch;
  const tryFocus = () => {
    if (focusIntentEpoch !== myEpoch) return;
    const el = resolveCellEl(String(localRow), col);
    if (el) {
      const rowEl = el.closest ? el.closest('[role="row"]') : null;
      const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
      if (ari === want) {
        el.focus();
        return;
      }
    }
    attempts = attempts + 1;
    if (attempts >= 60) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// focusCell(rowIndex, colIndex) — move + focus the active cell. C1 (phase 63 wave-6): rowIndex
// is the ABSOLUTE display-order position in getPrePaginationRowModel().rows (filter+sort+expand
// applied, BEFORE pagination/windowing), in BOTH paginated and virtual modes — REVERSING the old
// page-relative-when-paginated meaning. Args are COERCED to integers and CLAMPED before the
// data-* selector is built (T-49-01/T-63-06-01: never interpolate a raw consumer string; clamp
// the abs index into getPrePaginationRowModel bounds). The activecell-change payload + getActiveCell
// speak the SAME absolute language (toAbsRow).
const focusCell = (rowIndex: any, colIndex: any) => {
  // B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
  // is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
  // isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
  // instance does not leak a spurious activecell-change.
  if (!isGrid()) return;
  // #9: focusCell is a focus-INTENT entry point — bump the epoch BEFORE arming any poll (the
  // switched-page focusAbsCellWhenReady captures the post-bump value; the same-page / virtual
  // branches route through focusActiveCell, which bumps again — harmless). A subsequent focusCell
  // or user nav bumps again → a pending focusAbsCellWhenReady from THIS call aborts.
  focusIntentEpoch = focusIntentEpoch + 1;
  const maxCol = visibleColCount() - 1;
  const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
  // C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
  const absLast = prePaginationRowCount() - 1;
  const absRow = clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
  // B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
  // real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
  // active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
  const prevAbs = toAbsRow(activeRow.value);
  const prevIsHeader = activeIsHeader.value;
  if (props.virtual) {
    // Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
    // the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
    // focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
    activeIsHeader.value = false;
    activeInControl.value = false;
    activeRow.value = absRow;
    activeColIndex.value = c;
    focusActiveCell(absRow, c, false);
  } else {
    // Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
    // in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
    // body's data-row markers (and the roving tabindex) address.
    const size = pageSize();
    const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
    const localRow = absRow - targetPage * size;
    const switched = targetPage !== pageIndex();
    if (switched) setPage(targetPage);
    activeIsHeader.value = false;
    activeInControl.value = false;
    activeRow.value = localRow;
    activeColIndex.value = c;
    if (switched) {
      // The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
      // TARGET page's body aria-rowindex (headerRowCount + absRow + 1, #13) before focusing, so
      // the OLD page's same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only.
      focusAbsCellWhenReady(absRow, localRow, c);
    } else {
      // Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
      // Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
      // async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
      focusActiveCell(localRow, c, false);
    }
  }
  if (absRow !== prevAbs || prevIsHeader) {
    emit('activecell-change', {
      rowIndex: absRow,
      colIndex: c
    });
  }
};
// getActiveCell() — return the current active-cell position. Integers only — no row data,
// no DOM node (T-49-02 Information-Disclosure: return the screen position, nothing else).
// B15: reflect the HEADER-active state. When a header cell is active the roving position is
// NOT a body row — return the header sentinel (rowIndex null + isHeader true, colIndex the
// header column) so a consumer never mistakes a header focus for body 'row 0'. A body cell
// returns the integer rowIndex + isHeader false (back-compatible: the rowIndex/colIndex pair
// is unchanged for the body case).
// C1: a body cell returns the ABSOLUTE display-order rowIndex (toAbsRow) — matching focusCell's
// addressing + the activecell-change payload — in BOTH paginated and virtual modes.
const getActiveCell = () => activeIsHeader.value ? {
  rowIndex: null,
  colIndex: activeColIndex.value,
  isHeader: true
} : {
  rowIndex: toAbsRow(activeRow.value),
  colIndex: activeColIndex.value,
  isHeader: false
};
// clearActiveCell() — reset the roving position to the D-04 entry cell (row 0, col 0) and
// exit interaction mode; the next Tab-in re-enters at the entry cell (D-01). Does NOT emit
// (no move to a new addressable cell — a reset, not a navigation). B16: isGrid()-gated — a
// table-mode instance has no roving active cell, so the verb is a no-op there.
const clearActiveCell = () => {
  if (!isGrid()) return;
  activeIsHeader.value = false;
  activeInControl.value = false;
  activeRow.value = 0;
  activeColIndex.value = 0;
};
// ── Expand $expose verbs (phase 50 req-3, D-06) — joining the existing 19 (→ 23).
// Collision-safe names (ROZ121/137/524): toggleRowExpanded / expandAll / collapseAll are
// not inherited HTMLElement members, Lit lifecycle names, React auto-setters, prop names,
// or *-change events; getExpandedRows is a read-style getter (twin of getSelectedRows).
// Each drives @tanstack/table-core so the onExpandedChange → writeExpanded funnel fires
// one expanded-change. ──────────────────────────────────────────────────────────────────

// toggleRowExpanded(rowId) — toggle ONE row's expanded state, addressed by the consumer's
// row id (the data `id` field) OR the table-core row id. Scans the core flat-row set (all
// rows regardless of current expansion) so a collapsed parent is still resolvable.
const toggleRowExpanded = (rowId: any) => {
  if (!table) return;
  const target = String(rowId);
  const flat = table.getCoreRowModel().flatRows;
  for (const r of flat as any) {
    if (r.id === target || r.original && String(r.original.id) === target) {
      r.toggleExpanded();
      return;
    }
  }
};
// expandAll() — open every expandable row (table-core sets ExpandedState to the `true`
// literal under the hood → Pitfall 2: writeExpanded passes it through verbatim).
const expandAll = () => {
  if (!table) return;
  table.toggleAllRowsExpanded(true);
};
// collapseAll() — reset to a blank expanded state ({}). resetExpanded(true) forces the
// blank reset (NOT the initialState) and fires onExpandedChange → one expanded-change.
const collapseAll = () => {
  if (!table) return;
  table.resetExpanded(true);
};
// getExpandedRows() — return the original row data for every currently-expanded row
// (read-verb twin of expanded-change). Integers/data only — scans the core flat rows and
// filters by getIsExpanded(). Empty when nothing is expanded.
const getExpandedRows = () => {
  if (!table) return [];
  const out = [];
  const flat = table.getCoreRowModel().flatRows;
  for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
  return out;
};
// ── Grouping $expose verbs (phase 50 reqs 4-7, D-06 name-check) ────────────────────────────
// applyGrouping (RENAMED from setGrouping — ROZ524: a bare `set<ModelProp>` verb shadows
// React's auto-generated `setGrouping` useState setter for the `grouping` model slice, and an
// $expose verb is PUBLIC-CONTRACT-PROTECTED from the deconfliction rename; same precedent as
// setColumnOrder→applyColumnOrder) + clearGrouping. Both drive @tanstack/table-core's
// table.setGrouping so the onGroupingChange → writeGrouping funnel fires one group-change with
// the fresh ordered key list. Also handed to the headless #groupBar slot as apply/clear helpers.
const applyGrouping = (cols: any) => {
  if (table) table.setGrouping(cols);
};
const clearGrouping = () => {
  if (table) table.setGrouping([]);
};
// ── Faceted filtering read helpers (phase 50 reqs 8-9, D-03) ────────────────────────────────
// Shared by BOTH the getFaceted* $expose verbs AND the #filter slot props. They resolve a
// column via table.getColumn(colId) (a table-core lookup — NEVER a string-built querySelector,
// T-50-06 / the T-49-01 index-only discipline) and read table-core's CROSS-FILTERED faceted
// values (default impl — reflects rows passing all OTHER active column filters, D-03). They
// touch the reactive tick (`tick() < 0` guard) so the #filter slot props re-derive when an
// upstream filter changes on the fine-grained targets (Solid/Lit) — the visibleCellsFor idiom.
//
// getFacetedUniqueValues: the column's distinct values, KEYS ONLY — occurrence counts are
// deliberately NOT exposed (D-03; the column's getFacetedUniqueValues() returns Map<any,number>,
// we return Array.from(map.keys()) — no .entries()/count surface). Empty array on missing
// column/table. NAMED to match the $expose verb exactly (the ExposedMethod.name shorthand
// contract: an exposed verb lowers to `{ getFacetedUniqueValues }`, which must resolve to THIS
// helper — the table-core factory was aliased to makeFacetedUniqueValues to free this name).
const getFacetedUniqueValues = (colId: any) => {
  if (tick() < 0 || !table) return [];
  const col = table.getColumn(colId);
  if (!col || !col.getFacetedUniqueValues) return [];
  const map = col.getFacetedUniqueValues(); // Map<any, number>
  return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
};
// getFacetedMinMaxValues: the column's [min, max] numeric range, or null when unavailable.
// Named to match the $expose verb (same shorthand contract as getFacetedUniqueValues above).
const getFacetedMinMaxValues = (colId: any) => {
  if (tick() < 0 || !table) return null;
  const col = table.getColumn(colId);
  if (!col || !col.getFacetedMinMaxValues) return null;
  return col.getFacetedMinMaxValues() || null; // [number, number] | null
};

provide('data-table:columns', {
  registerColumn: (id: any, spec: any) => {
    if (id == null) return;
    const key = String(id);
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
    colReg.value = {
      ...colReg.value,
      [key]: spec
    };
  },
  unregisterColumn: (id: any) => {
    if (id == null) return;
    const r = {
      ...colReg.value
    };
    delete r[String(id)];
    colReg.value = r;
  }
});

onMounted(() => {
  // Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
  // edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
  // a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
  // the bound prop when controlled, this fallback otherwise.
  dataDefault.value = data.value || [];
  // Build the table instance HERE so the closures below capture the live `table`.
  table = createTable({
    // Plain value (NOT a `get data()` getter): an object-literal getter rebinds
    // `this` to the options object, and the Angular/Lit emitters resolve $props via
    // `this.data` — so `get data() { return $props.data }` lowers to `this.data`
    // re-entering the getter → infinite recursion (max call stack). `data` is re-fed
    // on every change by the watch's setOptions below, exactly like columns/state, so
    // the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
    // currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
    // (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
    data: currentData(),
    columns: tableColumns(),
    state: currentState(),
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    // Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
    // (mirrors the other models) — inert when `expanded` is empty + no getSubRows
    // (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
    // ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
    // (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
    // default `!!subRows.length` rule applies (only parents with children expand).
    getExpandedRowModel: getExpandedRowModel(),
    getSubRows: (props.getSubRows || undefined) as any,
    getRowCanExpand: props.expandable === true && props.getSubRows == null ? () => true : undefined,
    onExpandedChange: onExpandedChangeCb,
    // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
    // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
    // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
    // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
    // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
    // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
    // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
    // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
    // are unaffected (they fire regardless of this flag).
    autoResetExpanded: false,
    // Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
    // UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
    // (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
    // table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
    // members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
    // nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
    // (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
    getGroupedRowModel: getGroupedRowModel(),
    onGroupingChange: onGroupingChangeCb,
    // Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
    // UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
    // column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
    // The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: makeFacetedUniqueValues(),
    getFacetedMinMaxValues: makeFacetedMinMaxValues(),
    // Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
    // rows verbatim (no client-side filter/sort/paginate) and only emits the change
    // events so the consumer can fetch the next page/filtered slice.
    manualPagination: props.manual === true,
    manualFiltering: props.manual === true,
    manualSorting: props.manual === true,
    // Server-side page-count sources (#2): pass the consumer-supplied total row count and/or
    // explicit page count so table-core can compute getPageCount() under `manual` (where it
    // does not hold the full dataset). undefined when unset → table-core auto-derives from the
    // loaded data (client-pagination path byte-unchanged). Precedence is table-core's: explicit
    // pageCount wins, else ⌈rowCount / pageSize⌉, else auto. With a real count getCanNextPage()
    // becomes true, so a server-pagination consumer can leave page 0.
    rowCount: props.rowCount ?? undefined,
    pageCount: props.pageCount ?? undefined,
    // Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
    // (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
    // default, D-06 — NOT overridden).
    enableRowSelection: props.selectionMode !== 'none',
    enableMultiRowSelection: props.selectionMode === 'multiple',
    // PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
    // no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
    // $watch so React reads fresh currentState (the stale-closure fix, F6).
    onSortingChange: onSortingChangeCb,
    onGlobalFilterChange: onGlobalFilterChangeCb,
    onColumnFiltersChange: onColumnFiltersChangeCb,
    onPaginationChange: onPaginationChangeCb,
    onRowSelectionChange: onRowSelectionChangeCb,
    onColumnVisibilityChange: onColumnVisibilityChangeCb,
    onColumnSizingChange: onColumnSizingChangeCb,
    onColumnOrderChange: onColumnOrderChangeCb,
    onColumnPinningChange: onColumnPinningChangeCb,
    onColumnSizingInfoChange: onColumnSizingInfoChangeCb,
    // Resize mode: 'onChange' so the bound columnSizing model updates live during the
    // drag (the behavioral width-delta assertion observes the in-progress width). Column
    // resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
    columnResizeMode: 'onChange',
    enableColumnResizing: true,
    renderFallbackValue: null,
    // table-core's RESOLVED options type (TableOptionsResolved) requires a global
    // onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
    // callbacks above, so the global hook is a no-op. Present so the createTable() argument
    // satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
    onStateChange: () => {}
  });
  refreshRowModel = () => {
    if (!table) return;
    // Capture fresh locals; never write a $data key then re-read it in the same fn
    // (ROZ138 / React stale-read — setState is async on React, the closure binds the
    // PRE-write value).
    // windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
    // pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
    const nextRows = windowSource().slice();
    const nextGroups = table.getHeaderGroups().slice();
    rows.value = nextRows;
    headerGroups.value = nextGroups;
    rowModelVer.value = rowModelVer.value + 1;
    // Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
    // into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
    // NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
    // setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
    if (props.virtual && virtualizer) {
      virtualizer.setOptions(virtualizerOptions());
      virtualizer._willUpdate();
    }
    // D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
    // clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
    // no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
    // B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
    // async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
    // the range corners on React too — never re-reading the pre-change model.
    const nextRowCount = nextRows.length;
    const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
    clampActiveCell(nextRowCount, nextColCount);
    // #4: clamp a pageIndex that now points PAST the last page. When the consumer holds
    // pagination.pageIndex (controlled) and shrinks the data (filter / replace) so there are
    // fewer pages, the body renders blank ("Page 6 of 3" with Next disabled). Read table-core's
    // LIVE post-re-derive state: getPageCount() is the fresh count (now correct under `manual`
    // too, #2) and getState().pagination is the just-fed state. Funnel the correction through
    // writePagination (the single-emit + two-way-model funnel) so the consumer's controlled
    // pagination prop converges to the last valid page (page-change carries { pageIndex, pageSize }).
    //   • pc > 0 skips the manual-WITHOUT-count case (getPageCount() === -1) — never clamp toward
    //     an unknown total.
    //   • LOOP-GUARD: emit ONLY when the clamped index actually differs. After the consumer echoes
    //     the clamp back through the pagination prop, the re-feed re-enters here with
    //     pageIndex === pc - 1, so `pageIndex > pc - 1` is false → no re-emit; a consumer that
    //     ignores the event triggers no further re-feed, so it stays a single emit either way.
    //   • No fight with table-core's autoResetPageIndex: that reset only fires on table-core's OWN
    //     setX mutations, which this fully-controlled-state architecture never calls (filters/data
    //     flow through setOptions), so reading the live state here can only fire on a genuine
    //     overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
    //     writing paginationDefault, with no regression since table-core does not auto-clamp here).
    const pgState = table.getState().pagination;
    const pc = table.getPageCount();
    if (pc > 0 && pgState.pageIndex > pc - 1) {
      writePagination({
        pageIndex: pc - 1,
        pageSize: pgState.pageSize
      });
    }
    // B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
    // filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
    // indices), so resolve the committed row's NEW index by identity HERE (never from the React-
    // stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
    // gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
    // a no-relocation commit resolves the same index → byte-behaviorally identical to before.
    if (pendingEditFollow && isGrid()) {
      const follow = pendingEditFollow;
      pendingEditFollow = null;
      const followIdx = indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
      if (followIdx >= 0) focusCellWhenReady(followIdx, follow.col);
    }
    // keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
    // selection state (bound :indeterminate is inert on 5/6 targets). The box persists
    // across selection changes; a microtask defer covers React's post-render DOM patch.
    syncIndeterminate();
    if (typeof queueMicrotask !== 'undefined') queueMicrotask(syncIndeterminate);else Promise.resolve().then(syncIndeterminate);
  };

  // initial pull
  refreshRowModel();

  // ── Grid mode: capture the table root ──────────────────────────────────────────────
  // $el is the component root; the <table class="rozie-data-table"> is the grid root the
  // cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
  // (post-mount) so it is non-null and ROZ123-clean.
  gridRoot = __rozieRootRef.value ? __rozieRootRef.value!.querySelector('.rozie-data-table') : null;
  // WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
  // page load AND was non-deterministic on React/Solid (the entry cell may not be
  // committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
  // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
  // mount"); the consumer drives focus by Tabbing/clicking in, never the component.

  // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
  // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
  // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
  // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
  // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
  if (props.virtual) {
    gridScrollEl = __rozieRootRef.value ? __rozieRootRef.value!.querySelector('.rdt-scroll') : null;
    virtualizer = new Virtualizer(virtualizerOptions());
    virtualizerCleanup = virtualizer._didMount();
    // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
    // evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
    // reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
    // non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
    // SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
    // only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
    // never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
    // re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
    // pulls getVirtualItems().
    windowVer.value = windowVer.value + 1;
    // After the first window commits (next frame), refine heights + fire the dev-mode warns
    // ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
    // code and these warns can never fire there (req-1 byte-identical-off preserved).
    const afterFirstFrame = () => {
      // D-10: measure the rendered rows.
      remeasureWindow();
      // D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
      // bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
      // process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
      const h = gridScrollEl ? gridScrollEl.clientHeight : 0;
      if (!h) {
        console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
      }
      // D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
      // CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
      // virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
      // ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
      // detected as a pagination that DIFFERS from that default (a consumer who set a real page
      // size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
      // virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
      const pg = pagination.value;
      const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
      if (props.manual !== true && pgConfigured) {
        console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
      }
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
  }

  // #14: seed the sort/filter announce baseline from the initial (post-mount) state so the LAZY
  // watch's first fire — a real user sort/filter — compares against the true starting values and
  // is classified correctly (a null sentinel would misread the first filter change as a sort change).
  announceState.sorting = effectiveSorting();
  announceState.columnFilters = effectiveColumnFilters();
  announceState.globalFilter = effectiveGlobalFilter();
});
onBeforeUnmount(() => {
  if (virtualizerCleanup) virtualizerCleanup();
  // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
  teardownFillDrag();
  // §6 (260709-3qt): remove any live drag-select document listeners on a mid-drag unmount.
  teardownRangeDrag();
});
onUpdated(() => {
  maybeClearHistoryOnExternalSwap();
  if (!table) return;
  // Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
  // $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
  // bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
  // identical length) still re-feeds.
  const d = currentData() || [];
  if (d === lastData && d.length === lastDataLen) return;
  lastData = d;
  lastDataLen = d.length;
  reFeed();
});

watch(() => [sorting.value, globalFilter.value, columnFilters.value, pagination.value,
// Server-side page-count sources (#2): re-feed when the consumer's rowCount/pageCount
// changes at runtime (e.g. a server response updates the total) so getPageCount() and the
// Next button availability track the new total.
props.rowCount, props.pageCount, rowSelection.value, expanded.value, props.expandable, grouping.value, props.groupable, columnVisibility.value, columnSizing.value, columnOrder.value, columnPinning.value, props.selectionMode, (data.value || []).length,
// Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
// even when the fresh array is the SAME length (a single-cell edit replaces one row
// object → new array ref, identical length → the .length key alone would miss it). The
// controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
// writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
data.value, dataDefault.value,
// Column CONFIG prop (the `:columns` array form) — the sibling column source to
// $data.colReg (the `<Column>` children). Watch it so a runtime `:columns` swap re-feeds:
// columnDefs()/tableColumns() build the UNION of both, and reFeed re-passes columns.
// (Consumers memoize the array as with $props.data/$props.sorting; the uncontrolled
// <Column>-children path leaves $props.columns undefined — a stable no-op getter.)
props.columns, colReg.value], () => {
  reFeed();
  maybeClearHistoryOnExternalSwap();
}, { flush: 'post' });
watch(() => [sorting.value, columnFilters.value, globalFilter.value, sortingDefault.value, columnFiltersDefault.value, globalFilterDefault.value], () => {
  const msg = buildSortFilterAnnounce();
  if (msg) liveAnnounce.value = msg;
}, { flush: 'post' });

defineExpose({ sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut, undo, redo, canUndo, canRedo, clearHistory });
</script>

<style scoped>
.rozie-data-table {
  border-collapse: collapse;
  width: 100%;
  font: var(--rdt-font, 14px system-ui, sans-serif);
  color: var(--rdt-color, inherit);
}
.rdt-sr-live {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.rozie-data-table .rdt-cell-editor {
  font: inherit;
  width: 100%;
  box-sizing: border-box;
}
.rozie-data-table .rdt-td[aria-invalid="true"] {
  outline: var(--rdt-invalid-outline, 2px solid #d33);
  outline-offset: -2px;
}
.rozie-data-table .rdt-td.rdt-in-range {
  background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
}
.rozie-data-table .rdt-td.rdt-cell-active,
.rozie-data-table .rdt-th.rdt-cell-active {
  outline: var(--rdt-active-cell-outline, 2px solid #2563eb);
  outline-offset: -2px;
}
.rozie-data-table .rdt-td {
  position: relative;
}
.rozie-data-table .rdt-fill-handle {
  position: absolute;
  right: -3px;
  bottom: -3px;
  width: 8px;
  height: 8px;
  background: var(--rdt-fill-handle-bg, #2563eb);
  border: 1px solid #fff;
  cursor: crosshair;
  z-index: 1;
  touch-action: none;
}
.rozie-data-table .rdt-th,
.rozie-data-table .rdt-td {
  padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
  text-align: left;
  border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table .rdt-thead .rdt-th {
  font-weight: var(--rdt-header-weight, 600);
  /* OPAQUE default (was rgba(0,0,0,0.03)): a translucent header lets the scrolling body
     bleed through in sticky mode. #f7f7f7 is the visual equivalent of the old 3%-black
     tint over white, but solid. The three design-system themes already ship opaque
     header backgrounds; this makes the zero-config default consistent with them. */
  background: var(--rdt-header-bg, #f7f7f7);
}
.rozie-data-table .rdt-sort-btn {
  display: inline-flex;
  align-items: center;
  gap: var(--rdt-sort-gap, 0.35em);
  background: none;
  border: none;
  font: inherit;
  font-weight: inherit;
  color: inherit;
  cursor: pointer;
  padding: 0;
}
.rozie-data-table .rdt-sort-ind {
  font-size: 0.8em;
  opacity: var(--rdt-sort-ind-opacity, 0.7);
}
.rozie-data-table.rdt-sticky .rdt-thead .rdt-th {
  position: sticky;
  top: var(--rdt-sticky-top, 0);
  z-index: var(--rdt-sticky-z, 2);
}
.rozie-data-table-wrap .rdt-scroll {
  max-height: var(--rozie-data-table-max-height);
  overflow: auto;
}
.rozie-data-table-wrap .rdt-group-bar-host {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--rdt-group-bar-gap, 0.375rem);
}
.rozie-data-table-wrap :deep(.rdt-group-token) {
  display: inline-flex;
  align-items: center;
  padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
  border-radius: var(--rdt-group-token-radius, 999px);
  background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
  font-size: var(--rdt-group-token-size, 0.8125em);
}
.rozie-data-table .rdt-group-header {
  background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
  font-weight: var(--rdt-group-header-weight, 600);
}
.rozie-data-table .rdt-group-toggle {
  margin-right: var(--rdt-group-toggle-gap, 0.375rem);
}
.rozie-data-table .rdt-group-count {
  margin-left: var(--rdt-group-count-gap, 0.375rem);
  opacity: var(--rdt-group-count-opacity, 0.65);
  font-weight: 400;
}
.rozie-data-table-wrap {
  display: flex;
  flex-direction: column;
  gap: var(--rdt-chrome-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-toolbar {
  display: flex;
  gap: var(--rdt-toolbar-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-global-filter,
.rozie-data-table-wrap :deep(.rdt-col-filter) {
  font: inherit;
  /* border-box so the padding + border count INSIDE the declared width — without it
     the col-filter's `width: 100%` + padding overflows its (constrained) header cell. */
  box-sizing: border-box;
  padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
  border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-filter-radius, 4px);
  background: var(--rdt-filter-bg, transparent);
  color: inherit;
}
.rozie-data-table-wrap :deep(.rdt-col-filter) {
  display: block;
  margin-top: var(--rdt-col-filter-gap, 0.25rem);
  width: 100%;
  font-weight: normal;
}
.rozie-data-table .rdt-filter-row {
  background: var(--rdt-filter-row-bg, rgba(0, 0, 0, 0.015));
}
.rozie-data-table .rdt-filter-cell {
  padding: var(--rdt-filter-cell-padding, 0.35rem 0.75rem);
  border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table-wrap .rdt-filter-row :deep(.rdt-col-filter) {
  font-size: var(--rdt-filter-row-input-size, 0.9em);
}
.rozie-data-table-wrap .rdt-pagination {
  display: flex;
  align-items: center;
  gap: var(--rdt-pagination-gap, 0.5rem);
}
.rozie-data-table-wrap .rdt-page-btn {
  font: inherit;
  cursor: pointer;
  padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
  border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-page-btn-radius, 4px);
  background: var(--rdt-page-btn-bg, transparent);
  color: inherit;
}
.rozie-data-table-wrap .rdt-page-btn:disabled {
  opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
  cursor: default;
}
.rozie-data-table-wrap .rdt-page-status {
  font-size: var(--rdt-page-status-size, 0.9em);
}
.rozie-data-table-wrap .rdt-page-size {
  font: inherit;
  padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
  border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-page-size-radius, 4px);
  background: var(--rdt-page-size-bg, transparent);
  color: inherit;
}
.rozie-data-table .rdt-th {
  position: relative;
}
.rozie-data-table .rdt-resize-handle {
  position: absolute;
  top: 0;
  right: 0;
  height: 100%;
  width: var(--rdt-resize-handle-width, 6px);
  padding: 0;
  border: none;
  background: none;
  cursor: col-resize;
  touch-action: none;
  user-select: none;
}
.rozie-data-table .rdt-resize-grip {
  display: block;
  width: var(--rdt-resize-grip-width, 2px);
  height: 100%;
  margin: 0 auto;
  background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
}
.rozie-data-table .rdt-resize-handle:hover .rdt-resize-grip,
.rozie-data-table .rdt-th-resizing .rdt-resize-grip {
  background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
}
.rozie-data-table .rdt-col-menu-trigger {
  font: inherit;
  font-size: var(--rdt-col-menu-trigger-size, 0.9em);
  line-height: 1;
  cursor: pointer;
  margin-left: var(--rdt-col-menu-trigger-margin, 0.35em);
  padding: var(--rdt-col-menu-trigger-padding, 0.15em 0.4em);
  border: var(--rdt-col-menu-trigger-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-col-menu-trigger-radius, 3px);
  background: var(--rdt-col-menu-trigger-bg, transparent);
  color: inherit;
}
.rozie-data-table .rdt-col-menu-trigger:hover {
  background: var(--rdt-col-menu-trigger-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-data-table .rdt-col-menu-trigger:focus-visible {
  outline: var(--rdt-col-menu-trigger-focus-outline, 2px solid #2563eb);
  outline-offset: 1px;
}
.rozie-data-table .rdt-col-menu {
  display: flex;
  flex-direction: column;
  gap: var(--rdt-col-menu-item-gap, 0.15rem);
  min-width: var(--rdt-col-menu-min-width, 9rem);
}
.rozie-data-table .rdt-col-menu-item {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  cursor: pointer;
  padding: var(--rdt-col-menu-item-padding, 0.35em 0.6em);
  border: none;
  border-radius: var(--rdt-col-menu-item-radius, 3px);
  background: none;
  color: inherit;
  white-space: nowrap;
}
.rozie-data-table .rdt-col-menu-item:hover {
  background: var(--rdt-col-menu-item-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-data-table .rdt-col-menu-item[aria-pressed='true'] {
  background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
  font-weight: 700;
}
.rozie-data-table .rdt-col-menu-sep {
  margin: var(--rdt-col-menu-sep-margin, 0.25rem 0);
  border: none;
  border-top: var(--rdt-col-menu-sep-border, 1px solid rgba(0, 0, 0, 0.1));
}
.rozie-data-table-wrap .rdt-colvis {
  position: relative;
}
.rozie-data-table-wrap .rdt-colvis-summary {
  cursor: pointer;
  font: inherit;
  padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
  border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-colvis-summary-radius, 4px);
  list-style: none;
  user-select: none;
}
.rozie-data-table-wrap .rdt-colvis-menu {
  position: absolute;
  z-index: var(--rdt-colvis-menu-z, 5);
  margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
  padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
  display: flex;
  flex-direction: column;
  gap: var(--rdt-colvis-item-gap, 0.25rem);
  border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-colvis-menu-radius, 4px);
  background: var(--rdt-colvis-menu-bg, #fff);
  box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
}
.rozie-data-table-wrap .rdt-colvis-item {
  display: flex;
  align-items: center;
  gap: var(--rdt-colvis-label-gap, 0.4em);
  cursor: pointer;
  white-space: nowrap;
}
.rozie-data-table .rdt-select-th,
.rozie-data-table .rdt-select-td {
  width: var(--rdt-select-col-width, 44px);
  text-align: var(--rdt-select-col-align, center);
  white-space: nowrap;
}
.rozie-data-table .rdt-expander-th,
.rozie-data-table .rdt-expander-td {
  width: var(--rdt-expander-col-width, 40px);
  text-align: var(--rdt-expander-col-align, center);
  white-space: nowrap;
}
.rozie-data-table .rdt-select-all,
.rozie-data-table .rdt-select-row {
  cursor: pointer;
  accent-color: var(--rdt-select-accent, currentColor);
}
</style>
svelte
<script lang="ts">
import Popover from '@rozie-ui/popover-svelte';
import { rozieAttr, rozieDisplay, rozieStyle } from '@rozie/runtime-svelte';

import type { Snippet } from 'svelte';
import { onDestroy, onMount, setContext, untrack } from 'svelte';

interface Props {
  /**
   * The row data — `model: true`, so a committed cell/row edit writes a **fresh** array back through `r-model:data` (uncontrolled fallback `dataDefault`). A stable reference per Rozie's setup-once model — fed directly into table-core (never map/cloned in the watcher).
   * @example
   * <DataTable r-model:data="rows" :columns="cols" />
   */
  data: any[];
  /**
   * Config-array column fallback (lower precedence than `<Column>` children). Each entry: `{ id?, field, header?, sortable?, filterable?, pinned?, width? }`. Columns may come from this array, from `<Column>` children, or both (id-keyed last-write-wins union).
   */
  columns?: any[];
  /**
   * Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
   */
  selectionMode?: string;
  /**
   * `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
   */
  sorting?: any[];
  /**
   * The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
   */
  globalFilter?: string;
  /**
   * `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
   */
  columnFilters?: any[];
  /**
   * `{ pageIndex, pageSize }`. Defaults to `{ pageIndex: 0, pageSize: 10 }`; feeds the prev/next + page-size chrome (and `getPaginationRowModel()`). Two-way: funnels a fresh object through `page-change`.
   */
  pagination?: any;
  /**
   * Server-side hook: sets `manualPagination` / `manualFiltering` / `manualSorting` so table-core trusts the consumer-supplied rows and only emits the change events (the consumer fetches each page).
   */
  manual?: boolean;
  /**
   * Total server-side row count for `manual` pagination; lets the table compute page count when it doesn't hold the full dataset.
   */
  rowCount?: (number) | null;
  /**
   * Explicit total page count for `manual` pagination; overrides rowCount-derived count.
   */
  pageCount?: (number) | null;
  /**
   * Opt-in **expandable rows**. When `true`, a leading chevron expander column auto-injects (after the select column) and `getExpandedRowModel` activates; default `false` is byte-identical-off. Every row can expand to reveal a `#detail` panel unless `getSubRows` is supplied (then only rows with children expand). Bind `:expandable="true"` (a bare attr only coerces on Vue+Lit).
   */
  expandable?: boolean;
  /**
   * `ExpandedState` — `{ [rowId]: true }`, or the `true` literal after `expandAll` (declared `type: [Object, Boolean]`). Multi-expand (multiple rows open at once). Surfaces through `expand-change`; uncontrolled fallback (`$data.expandedDefault`) when unbound — the default is `null` so the uncontrolled fallback AND the grouping auto-expand default are reachable (a non-null default would short-circuit them). When grouping is active and `expanded` is untouched, group subtrees auto-expand.
   */
  expanded?: (any | boolean) | null;
  /**
   * Table-level child-row accessor `(originalRow, index) => TData[] | undefined` that drives nested sub-rows. When supplied (with `expandable`), table-core flattens the hierarchy and the expand seam reveals depth-indented child rows. Null → the `#detail` scoped slot is the expand mode.
   */
  getSubRows?: ((...args: any[]) => any) | null;
  /**
   * Opt-in gate for the **headless `#groupBar`** host region. Default `false` is byte-identical-off. `getGroupedRowModel` is wired unconditionally (inert when `grouping` is empty), so grouping is driven by the `grouping` model; this flag only gates the consumer-facing group-bar surface (the component ships **no** built-in drag UI).
   */
  groupable?: boolean;
  /**
   * `GroupingState` — an ordered `string[]` of column ids (multi-column → nested groups, e.g. `['region','category']`). An empty/unbound list is ungrouped (byte-identical-off). Group-header rows are collapsible (they ride the expand model). Surfaces through `group-change`; uncontrolled fallback (`$data.groupingDefault`, default `[]`) when unbound — the default is `null` (mirroring `expanded`) so the uncontrolled fallback is reachable and the grouping auto-expand default can activate when a consumer applies grouping without binding `r-model:grouping` (a non-null `[]` default would short-circuit it). All reads are null-guarded, so table-core still receives an array.
   */
  grouping?: (any[]) | null;
  /**
   * `RowSelectionState` — `{ [rowId]: true }`. Checkbox-only toggle (the row body does not select). Driven by the `selectionMode` chrome. Two-way: fires `selection-change` regardless of binding.
   */
  rowSelection?: any;
  /**
   * `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
   */
  columnVisibility?: any;
  /**
   * `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
   */
  columnSizing?: any;
  /**
   * `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
   */
  columnOrder?: any[];
  /**
   * `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
   */
  columnPinning?: any;
  /**
   * Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
   */
  stickyHeader?: boolean;
  /**
   * `'table'` (default, row-oriented, byte-behaviorally identical to a plain accessible table) | `'grid'` (GA since Phase 63) — lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)**: `role="grid"`, a roving single tab-stop, 2-D APG arrow-key cell navigation, range selection, and clipboard support.
   */
  interactionMode?: string;
  /**
   * Grid mode only. When `true`, a plain click on an **editable** cell opens its editor immediately (single-click-to-edit) instead of just activating the cell. Default `false` keeps click-to-activate (double-click opens the editor). Shift+click (range selection) and clicks on non-editable cells are unaffected.
   */
  singleClickEdit?: boolean;
  /**
   * Grid mode. When `true`, every committed data mutation (cell/row edit, paste, fill, cut, clear) becomes one undo step: Ctrl/Cmd+Z undoes, Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z redoes. Default `false` records no history and Ctrl+Z/Y are inert.
   */
  undoable?: boolean;
  /**
   * The maximum number of undo steps retained (oldest evicted past this depth). Only consulted when `undoable` is `true`.
   */
  undoLimit?: number;
  /**
   * Opt-in vertical **row windowing**. When `true`, only the visible slice of rows renders inside a bounded `rdt-scroll` container (with leading/trailing spacer rows preserving total scroll height), windowing over the full filtered + sorted (pre-pagination) model and suppressing the client pagination chrome. Default `false` is byte-identical to a non-virtual table.
   */
  virtual?: boolean;
  /**
   * Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
   */
  estimateRowHeight?: number;
  /**
   * A CSS length string bounding the `rdt-scroll` container when `virtual` is on (e.g. `'400px'`). Mirrored to the `--rozie-data-table-max-height` custom property; the prop wins, the token is the fallback.
   */
  maxHeight?: string;
  children?: Snippet;
  groupBar?: Snippet<[{ grouping: any; groupableColumns: any; applyGrouping: any; clearGrouping: any }]>;
  selectAll?: Snippet<[{ checked: any; indeterminate: any; toggle: any }]>;
  colHeader?: Snippet<[{ columnId: any; column: any; label: any }]>;
  filter?: Snippet<[{ columnId: any; value: any; uniqueValues: any; minMax: any; setFilter: any }]>;
  selectCell?: Snippet<[{ row: any; checked: any; toggle: any }]>;
  cell?: Snippet<[{ columnId: any; column: any; row: any; value: any }]>;
  editor?: Snippet<[{ columnId: any; column: any; row: any; value: any; commit: any; cancel: any; autofocus: any }]>;
  detail?: Snippet<[{ row: any }]>;
  snippets?: Record<string, any>;
  onsortchange?: (...args: unknown[]) => void;
  onexpandchange?: (...args: unknown[]) => void;
  ongroupchange?: (...args: unknown[]) => void;
  onfilterchange?: (...args: unknown[]) => void;
  onpagechange?: (...args: unknown[]) => void;
  onselectionchange?: (...args: unknown[]) => void;
  onvisibilitychange?: (...args: unknown[]) => void;
  onresizechange?: (...args: unknown[]) => void;
  onreorderchange?: (...args: unknown[]) => void;
  onpinchange?: (...args: unknown[]) => void;
  onhistorychange?: (...args: unknown[]) => void;
  onactivecellchange?: (...args: unknown[]) => void;
  onrangechange?: (...args: unknown[]) => void;
  oncelleditcommit?: (...args: unknown[]) => void;
  onroweditcommit?: (...args: unknown[]) => void;
}

let __defaultColumns = (() => [])();

let {
  data = $bindable(),
  columns = __defaultColumns,
  selectionMode = 'none',
  sorting = $bindable((() => [])()),
  globalFilter = $bindable(''),
  columnFilters = $bindable((() => [])()),
  pagination = $bindable((() => ({
  pageIndex: 0,
  pageSize: 10
}))()),
  manual = false,
  rowCount = null,
  pageCount = null,
  expandable = false,
  expanded = $bindable(null),
  getSubRows = null,
  groupable = false,
  grouping = $bindable(null),
  rowSelection = $bindable((() => ({}))()),
  columnVisibility = $bindable((() => ({}))()),
  columnSizing = $bindable((() => ({}))()),
  columnOrder = $bindable((() => [])()),
  columnPinning = $bindable((() => ({
  left: [],
  right: []
}))()),
  stickyHeader = false,
  interactionMode = 'table',
  singleClickEdit = false,
  undoable = false,
  undoLimit = 100,
  virtual = false,
  estimateRowHeight = 40,
  maxHeight = '',
  children: __childrenProp,
  groupBar: __groupBarProp,
  selectAll: __selectAllProp,
  colHeader: __colHeaderProp,
  filter: __filterProp,
  selectCell: __selectCellProp,
  cell: __cellProp,
  editor: __editorProp,
  detail: __detailProp,
  snippets,
  onsortchange,
  onexpandchange,
  ongroupchange,
  onfilterchange,
  onpagechange,
  onselectionchange,
  onvisibilitychange,
  onresizechange,
  onreorderchange,
  onpinchange,
  onhistorychange,
  onactivecellchange,
  onrangechange,
  oncelleditcommit,
  onroweditcommit
}: Props = $props();

const children = $derived(__childrenProp ?? snippets?.children);
const groupBar = $derived(__groupBarProp ?? snippets?.groupBar);
const selectAll = $derived(__selectAllProp ?? snippets?.selectAll);
const colHeader = $derived(__colHeaderProp ?? snippets?.colHeader);
const filter = $derived(__filterProp ?? snippets?.filter);
const selectCell = $derived(__selectCellProp ?? snippets?.selectCell);
const cell$$slot = $derived(__cellProp ?? snippets?.cell);
const editor = $derived(__editorProp ?? snippets?.editor);
const detail = $derived(__detailProp ?? snippets?.detail);

let dataDefault: any[] = $state([]);
let sortingDefault: any[] = $state([]);
let globalFilterDefault = $state('');
let columnFiltersDefault: any[] = $state([]);
let paginationDefault = $state({
  pageIndex: 0,
  pageSize: 10
});
let rowSelectionDefault = $state({});
let expandedDefault = $state({});
let groupingDefault: any[] = $state([]);
let columnVisibilityDefault = $state({});
let columnSizingDefault = $state({});
let columnOrderDefault: any[] = $state([]);
let columnPinningDefault = $state({
  left: [],
  right: []
});
let columnSizingInfo = $state({
  startOffset: null,
  startSize: null,
  deltaOffset: null,
  deltaPercentage: null,
  isResizingColumn: false,
  columnSizingStart: []
});
let colReg = $state({});
let rows: any[] = $state([]);
let headerGroups: any[] = $state([]);
let rowModelVer = $state(0);
let windowVer = $state(0);
let activeRow = $state(0);
let activeColIndex = $state(0);
let activeIsHeader = $state(false);
let activeHeaderLevel = $state(0);
let activeInControl = $state(false);
let editingRow = $state(-1);
let editingCol = $state(-1);
let draftValue: any = $state(null);
let invalidMsg = $state('');
let editVer = $state(0);
let editFocusColId: any = $state(null);
let editingRowIndex: any = $state(null);
let rowDraft = $state({});
let rangeAnchor: any = $state(null);
let rangeFocus: any = $state(null);
let pasteAnnounce = $state('');
let liveAnnounce = $state('');

let __rozieRoot = $state<HTMLElement | undefined>(undefined);

import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';
// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).
let table: any = null;
// ── Vertical row windowing instance state (phase 53) ──────────────────────────────────
// Mutable top-level instances (the `let table` precedent — React hoists to useRef; do NOT
// const). NULL until $onMount, and ONLY constructed when $props.virtual. virtualizerCleanup
// holds the _didMount() teardown for $onUnmount; gridScrollEl is the captured .rdt-scroll div
// the virtualizer observes.
let virtualizer: any = null;
let virtualizerCleanup: any = null;
let gridScrollEl: any = null;
// CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
// rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
// pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
// callbacks were what broke the Solid scroll-then-focus seam (D-12 focusActiveCell →
// scrollToIndex → double-rAF focus): a stray remeasure firing inside that focus deferral
// disrupted the focus landing. The sweep ALSO bails while virtual-core is mid-scroll
// (virtualizer.isScrolling), so a measure can't run during scrollToIndex; the next settled
// onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
// once motion settles between scroll steps) is unaffected.
let remeasurePending = false;
// ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
// Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
// via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
// later change is a one-line edit.
const GRID_PAGE_STEP = 10;
// The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
// $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
// shadow-safe because the query runs from INSIDE the component's own scope (the listbox
// querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
// computed/template binding (ROZ123).
let gridRoot: any = null;
// Echo-guard: while WE are writing a slice back, the re-feed watcher must not re-enter
// the funnel. A counter (not a boolean) so nested writes are safe.
let programmatic = 0;
// Focus-intent epoch (#9) — a monotonic counter bumped at every focus-INTENT entry point
// (focusActiveCell / focusCell+focusAbsCellWhenReady arm / a genuine active-cell-moving
// focusin in syncActiveFromEvent). The two async focus-recovery polls (focusWhenReady for the
// virtual off-window scroll, focusAbsCellWhenReady for the paginated page-switch) CAPTURE this
// value at arm time (AFTER their own bump) and abort at the top of each iteration if it has since
// changed — so a LATER user nav (ArrowKey / click) supersedes a stale poll instead of the poll
// yanking focus back frames later. A naive guardMoved "abort if focus moved" check is WRONG here:
// both polls arm while focus still sits on the OLD/being-left cell BY DESIGN (scroll-to /
// page-switch), so an epoch — bumped only by a NEWER intent — is the correct abort signal.
let focusIntentEpoch = 0;
// ── Grid-wide undo/redo (260709-8ct) — history STATE lives in top-level `let` (mirroring
// `programmatic` above), NOT $data: recording a snapshot on every keystroke must not trigger
// a reactive re-render. React hoists each to useRef. undoStack/redoStack hold `data` array
// REFERENCES (never deep copies — see undoHistory.rzts's header comment on the shared-row
// invariant). restoringHistory suppresses re-recording while an undo()/redo() replay is
// in flight.
//
// The external-swap history reset keys on data ORIGIN, not a timing window. Every internal
// writeback stamps its fresh `data` array with a durable, non-enumerable marker under
// DATA_WRITE_TOKEN_KEY (see writeData in writeFunnels.rzts); the reset (maybeClearHistoryOnExternal
// Swap, below) clears history ONLY when a newly-supplied `$props.data` carries no marker — it did
// not come from us, so it is a genuine external dataset swap. Presence of the marker ⟺ "descends
// from one of our writes", and it survives EVERYTHING that defeated the four flag/timer variants:
//   1. A raw-reference latch (`lastWrittenData === currentData()`) — Vue `reactive()` / Svelte 5
//      `$state` / Solid store re-wrap a written array in a NEW Proxy on its way back through props,
//      so `===` never holds. (A non-enumerable own PROPERTY, by contrast, is forwarded through
//      every target's reactive Proxy via `Reflect.get` — readable through the wrap.)
//   2. A single-consume boolean — the re-feed watch fires MULTIPLE times per write; the first pass
//      consumed the flag, a later pass wrongly cleared.
//   3. A content signature (`JSON.stringify`) — the watch can fire with a TRANSIENTLY STALE
//      `currentData()` mid-settle (Solid/Lit), a real-but-older value → false mismatch.
//   4. A deferred settle-window flag (rAF, then a 96ms macrotask) — a slow re-feed on a LARGE
//      controlled table OUTRAN the window (#8); no fixed timeout can be correct (re-feed latency
//      scales with dataset size).
// A STRING key (not a JS Symbol) is deliberate: it is stable BY VALUE on all six targets with ZERO
// caching, whereas a `Symbol()` needs a per-instance memo to hold one identity — and Lit lowers
// `$computed(() => Symbol())` to a plain getter that RE-MINTS the Symbol on every read, so writeData
// and the reset would stamp/read DIFFERENT symbols and the marker would never match. Non-enumerable
// → invisible to JSON.stringify / spread / Object.keys (the consumer's data stays clean); namespaced
// so a consumer array never collides.
const DATA_WRITE_TOKEN_KEY = '__rozieDataWriteToken';
let undoStack: unknown[] = [];
let redoStack: unknown[] = [];
let restoringHistory: boolean = false;
// Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
// has not bound `expanded` and has not yet toggled any group, group-header rows default to
// EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
// roundout-VR leaf-visible baseline). The FIRST group/row toggle sets this true (in
// writeExpanded), after which the user's expanded state wins. Stays false (untouched) on the
// non-grouping path → byte-identical-off (the `expanded` slice resolves to $data.expandedDefault
// exactly as before, both for the plain table AND the expandable-rows feature).
let expandedTouched = false;
// groupingActiveDefault(): is grouping currently engaged (a non-empty ordered key list)? Reads
// the same source order as currentState().grouping ($props.grouping ?? $data.groupingDefault) so
// the expanded auto-default below tracks the live grouping state on every target.
const groupingActiveDefault = () => ((grouping != null ? grouping : groupingDefault) || []).length > 0;
// effectiveColumnPinning(): the auto-injected select/expander chrome columns are a STRUCTURAL
// left-pinned rail — they ALWAYS lead the pinned-left group so the checkbox/chevron stay the
// leftmost body cells in EVERY case (fresh, pinned, AND grouped). Two forces would otherwise
// push a data column ahead of the checkbox:
//   1. Pinning — a consumer pinning `name` left makes it left-pinned; getVisibleCells() returns
//      [left-pinned, center, right-pinned], so an unpinned (center) checkbox renders AFTER it.
//   2. Grouping — table-core's groupedColumnMode defaults to 'reorder', which moves a grouped
//      column to the FRONT of the order, ahead of an unpinned center checkbox.
// Pinning the rail left beats BOTH: the left group always precedes the (grouped-reordered)
// center group. We prepend SELECT_COL_ID then EXPANDER_COL_ID (matching the tableColumns
// injection order [select, expander, ...userCols]) ahead of any consumer left-pins.
// REQUIRES: the chrome column defs carry an explicit `size` (columnBuilders.rzts) — pinStyle's
// sticky offset is col.getStart('left') = Σ preceding pinned SIZES, so a size-less chrome column
// (table-core's 150px default) would inflate every real pinned column's `left` and overlap.
// The consumer's columnPinning model never sees these ids: writeColumnPinning() strips them
// on the way back out (writeFunnels.rzts). Note: this ALWAYS-pin makes the default (no-pin)
// checkbox a sticky-left rail — an intentional baseline change (VR/snapshot baselines drift).
const effectiveColumnPinning = (): any => {
  const base = columnPinning != null ? columnPinning : columnPinningDefault;
  const rail: string[] = [];
  if (selectionEnabled()) rail.push(SELECT_COL_ID);
  if (expandable === true) rail.push(EXPANDER_COL_ID);
  if (rail.length === 0) return base;
  const left = base && base.left ? base.left : [];
  const deduped = left.filter((id: string) => id !== SELECT_COL_ID && id !== EXPANDER_COL_ID);
  return {
    ...base,
    left: rail.concat(deduped)
  };
};
// Assemble the live state object from bound r-model slices (?? uncontrolled fallback).
// All NINE slices are wired (each ?? its own $data.<slice>Default). table-core reads
// this whole object as `state`. Return type annotated `any`: the inferred object-literal
// type does not structurally match table-core's `Partial<TableState>` under the strict
// bundled-leaf tsc (the columnSizingInfo/pagination shapes widen to Record) — the
// runtime shape is correct; `any` sidesteps the over-strict structural check (the
// deferred-items strict-tsc #2 / leaf-output-strict-typecheck close).
const currentState = (): any => ({
  sorting: sorting != null ? sorting : sortingDefault,
  globalFilter: globalFilter != null ? globalFilter : globalFilterDefault,
  columnFilters: columnFilters != null ? columnFilters : columnFiltersDefault,
  pagination: pagination != null ? pagination : paginationDefault,
  rowSelection: rowSelection != null ? rowSelection : rowSelectionDefault,
  // expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
  // literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
  // guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
  // GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
  // `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
  // literal so the grouped subtree is visible by default; the first toggle latches
  // expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
  // unchanged → byte-identical-off (the table + the expandable-rows feature both keep
  // $data.expandedDefault).
  expanded: expanded != null ? expanded : groupingActiveDefault() && !expandedTouched ? true : expandedDefault,
  // grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
  // to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
  // is inert when this is empty (byte-identical-off, req-10).
  grouping: grouping != null ? grouping : groupingDefault,
  columnVisibility: columnVisibility != null ? columnVisibility : columnVisibilityDefault,
  columnSizing: columnSizing != null ? columnSizing : columnSizingDefault,
  columnOrder: columnOrder != null ? columnOrder : columnOrderDefault,
  columnPinning: effectiveColumnPinning(),
  // columnSizingInfo: table-core's transient resize-gesture state. We pass an
  // EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
  // `column.getIsResizing()` / `getResizeHandler()` read
  // `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
  // absent. Seed the default shape (matches table-core's
  // getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
  // every render. Not a two-way model slice (transient gesture state, not consumer
  // state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
  columnSizingInfo: columnSizingInfo
});
// The live row data (Phase 51 req-4): the bound `data` prop when controlled, else the
// uncontrolled $data.dataDefault fallback (mirrors currentState's per-slice ?? pattern).
// A committed edit funnels a FRESH array through writeData, which writes BOTH sinks; the
// re-feed sources here so editing works whether or not the consumer binds r-model:data.
const currentData = (): any => data != null ? data : dataDefault;
// Prototype-safe id-keyed column resolution (T-48-PP): the `:columns` config array is
// applied FIRST (lower precedence), then the <Column> registry OVERRIDES by id (LWW).
// byId is a null-prototype object so a consumer column id of "__proto__"/"constructor"
// cannot pollute Object.prototype. Returns the table-core ColumnDef[]. (No per-column
// render callbacks — cells render via the single #cell/#header scoped slot on this
// component, dispatched by columnId; <Column> carries metadata only.)
const isSafeKey = (k: any) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
// wrapAggregationFn (phase 50 req-5, D-05, threat T-50-04): resolve a per-column
// aggregationFn straight onto the ColumnDef (no component-side switch — RESEARCH
// anti-pattern). A built-in NAME string ('sum'/'min'/'max'/'extent'/'mean'/'median'/
// 'unique'/'uniqueCount'/'count') passes through verbatim — table-core resolves it from its
// built-in `aggregationFns` map. A CUSTOM function `(columnId, leafRows, childRows) => any`
// is DEFENSIVELY WRAPPED (the runValidator precedent): a consumer fn runs per group, so a
// throw is coerced to `undefined` and can never crash getGroupedRowModel (DoS guard).
// Anything else → undefined (no aggregation; the cell renders as a placeholder).
const wrapAggregationFn = (fn: any) => {
  if (typeof fn === 'string') return fn;
  if (typeof fn !== 'function') return undefined;
  return (columnId: any, leafRows: any, childRows: any) => {
    try {
      return fn(columnId, leafRows, childRows);
    } catch (err: any) {
      return undefined;
    }
  };
};
// Build the table-core ColumnDef for ONE config-array entry. A LEAF entry
// ({ id?, field, header?, … }) maps to an accessor ColumnDef; a GROUP entry
// ({ id?, header, columns: [...] }) maps to a multi-level header GROUP column
// whose children are built recursively (B12 — grouped/multi-level column headers).
// Returns null for an unusable entry (no id/field, unsafe key, empty group).
const buildConfigDef = (c: any) => {
  if (!c) return null;
  // Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
  // getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
  // header spans its leaf children (B12). The group id falls back to its header text so it
  // stays addressable (no accessor; group columns carry no data).
  if (Array.isArray(c.columns)) {
    const kids = [];
    for (const child of c.columns as any) {
      const cd = buildConfigDef(child);
      if (cd) kids.push(cd);
    }
    if (!kids.length) return null;
    // Group id: an explicit c.id wins. Otherwise synthesize a STABLE UNIQUE id from the child
    // column ids (which are unique per leaf accessor / recursively-synthesized per nested group)
    // — NOT the header text. Falling back to c.header collided two same-titled groups (e.g. both
    // "Details") into one by-id map key, so the columnDefs LWW merge silently dropped one whole
    // group column + its children. The child-id derivation is deterministic (stable across
    // renders — no Math.random/Date). A group with neither id nor header (nor derivable children)
    // stays dropped as before.
    let gid = c.id;
    if (gid == null) gid = c.header != null ? '__grp_' + kids.map((k: any) => k.id).join('_') : null;
    if (gid == null) return null;
    const id = String(gid);
    if (!isSafeKey(id)) return null;
    return {
      id,
      header: c.header != null ? c.header : id,
      columns: kids
    };
  }
  const rawId = c.id != null ? c.id : c.field;
  if (rawId == null) return null;
  const id = String(rawId);
  if (!isSafeKey(id)) return null;
  return {
    id,
    accessorKey: c.field != null ? c.field : id,
    header: c.header != null ? c.header : id,
    enableSorting: c.sortable === true,
    // per-column filter opt-in (req-5). table-core gates the filter input + value
    // funnel on enableColumnFilter; a column with filterable !== true cannot be
    // filtered (and renders no per-column filter input in the chrome below).
    enableColumnFilter: c.filterable === true,
    filterable: c.filterable === true,
    // Expandable-rows reserved per-column metadata (phase 50, D-04).
    expandable: c.expandable === true,
    // Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
    // so every data column is offered to the headless #groupBar by default; the per-column
    // aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
    // a custom fn defensively wrapped (T-50-04).
    groupable: c.groupable !== false,
    aggregationFn: wrapAggregationFn(c.aggregationFn),
    pinned: c.pinned != null ? c.pinned : '',
    width: c.width != null ? c.width : '',
    // Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
    // metadata carrier the display↔editor branch + runValidator read. Off by default.
    meta: {
      editable: c.editable === true,
      editor: c.editor != null ? c.editor : 'text',
      editorOptions: c.editorOptions != null ? c.editorOptions : [],
      validate: typeof c.validate === 'function' ? c.validate : null
    }
  };
};
const columnDefs = () => {
  const byId = Object.create(null);
  const order = [];
  const cfg = columns || [];
  for (const c of cfg as any) {
    const def = buildConfigDef(c);
    if (!def) continue;
    const id = def.id;
    if (!(id in byId)) order.push(id);
    byId[id] = def;
  }
  const reg = colReg || {};
  for (const id in reg) {
    if (!isSafeKey(id)) continue;
    const spec = reg[id];
    if (!spec) continue;
    if (!(id in byId)) order.push(id);
    byId[id] = {
      id,
      accessorKey: spec.field != null ? spec.field : id,
      header: spec.header != null ? spec.header : id,
      enableSorting: spec.sortable === true,
      enableColumnFilter: spec.filterable === true,
      filterable: spec.filterable === true,
      // Expandable-rows reserved per-column metadata (phase 50, D-04).
      expandable: spec.expandable === true,
      // Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
      groupable: spec.groupable !== false,
      aggregationFn: wrapAggregationFn(spec.aggregationFn),
      pinned: spec.pinned != null ? spec.pinned : '',
      width: spec.width != null ? spec.width : '',
      // Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
      meta: {
        editable: spec.editable === true,
        editor: spec.editor != null ? spec.editor : 'text',
        editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
        validate: typeof spec.validate === 'function' ? spec.validate : null
      }
    };
  }
  const out = [];
  for (const id of order as any) if (byId[id]) out.push(byId[id]);
  return out;
};
// The constant id of the auto-injected leading checkbox column (D-04). Distinct from
// any consumer column id (the registry/config guard never produces a leading "__").
const SELECT_COL_ID = '__rdt_select';
// The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
// Distinct from any consumer column id (the registry/config guard never produces a leading
// "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
const EXPANDER_COL_ID = '__rdt_expander';
// The table-core ColumnDef set actually fed to createTable / setOptions: the resolved
// user columns, PLUS a LEADING checkbox column when selectionMode is 'single' OR
// 'multiple' (D-04). The select column carries enableSorting/enableColumnFilter:false
// and an isSelectColumn marker the template uses to render checkbox chrome (NOT an
// accessor value). 'none' injects nothing. In 'single' mode the per-row checkbox
// renders but the select-all HEADER checkbox is suppressed (selecting a row caps at
// ≤1 via enableMultiRowSelection:false) — a single-select needs a per-row control,
// not a select-all, so without injecting the column single mode would expose NO
// selection UI at all.
const selectionEnabled = () => selectionMode === 'single' || selectionMode === 'multiple';
const tableColumns = () => {
  const cols = columnDefs();
  // Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
  // isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
  // value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
  // → byte-identical-off (req-10).
  let withExpander = cols;
  if (expandable === true) {
    const expanderCol = {
      id: EXPANDER_COL_ID,
      enableSorting: false,
      enableColumnFilter: false,
      filterable: false,
      isExpanderColumn: true,
      pinned: '',
      width: '',
      // Explicit narrow size so table-core's getSize()/getStart('left') match the RENDERED
      // width. Without it table-core assumes its 150px default, which is fine for an UNPINNED
      // chrome column (a CSS `width:1%` trick shrinks it visually) but breaks the moment the
      // column joins the left-pinned rail: pinStyle's sticky offset is Σ preceding pinned
      // SIZES, so a phantom 150px would push every real pinned column ~150px too far right and
      // overlap. Keep this in sync with the `--rdt-expander-col-width` CSS default (40px).
      size: 40
    };
    withExpander = [expanderCol].concat(cols);
  }
  if (selectionEnabled()) {
    const selectCol = {
      id: SELECT_COL_ID,
      enableSorting: false,
      enableColumnFilter: false,
      filterable: false,
      isSelectColumn: true,
      pinned: '',
      width: '',
      // Explicit narrow size so table-core's sticky-offset math (getStart('left')) matches the
      // rendered checkbox width once this column joins the left-pinned rail — see the expander
      // note above. Keep in sync with the `--rdt-select-col-width` CSS default (44px).
      size: 44
    };
    return [selectCol].concat(withExpander);
  }
  return withExpander;
};
// ── sorting slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<SortingState> = value | (old)=>new; the onSortingChange
// callback applies it against the CURRENT sorting, then this funnel writes a FRESH
// array to the uncontrolled default + the two-way model + fires the change event
// REGARDLESS of binding. STATIC key (`$data.sortingDefault` / `$model.sorting`) — a
// dynamic-key funnel is ROZ106 on all six. The remaining 8 slices each get their own
// such funnel in Plans 04/05.
const writeSorting = (next: any) => {
  if (programmatic) return;
  programmatic++;
  sortingDefault = next; // fresh array only (never in-place)
  sorting = next; // two-way emit if bound (no-op-diff if not)
  onsortchange?.(next);
  programmatic--;
};
const applyUpdater = (updater: any, current: any) => typeof updater === 'function' ? updater(current) : updater;
// ── expanded slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────────
// table-core hands an Updater<ExpandedState> = value | (old)=>new; onExpandedChange
// applies it against the CURRENT expanded, then this funnel writes a FRESH value to the
// uncontrolled default + the two-way model + fires `expanded-change` REGARDLESS of binding.
// `next` may be the `true` expand-all literal OR a { [rowId]: true } object — written
// verbatim (Pitfall 2). One emit per change (the shared `programmatic` guard dedups the
// React multi-render re-entry, D-07). STATIC key ($data.expandedDefault / $model.expanded).
const writeExpanded = (next: any) => {
  if (programmatic) return;
  programmatic++;
  // Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
  // the user now owns the expanded state, so currentState() stops defaulting grouped rows to
  // the `true` expand-all literal and honors $data.expandedDefault from here on.
  expandedTouched = true;
  expandedDefault = next; // fresh value only (never in-place)
  expanded = next; // two-way emit if bound (no-op-diff if not)
  // Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
  // prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
  // interface, and an `expanded-change` event would camelCase to the SAME identifier
  // → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
  // sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
  // sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
  onexpandchange?.(next);
  programmatic--;
};
// ── grouping slice: STATIC-KEY fresh-array echo-guarded write funnel (phase 50 reqs 4-7) ──
// table-core hands an Updater<GroupingState> = value | (old)=>new; onGroupingChange applies it
// against the CURRENT grouping, then this funnel writes a FRESH ordered array to the
// uncontrolled default + the two-way model + fires `group-change` REGARDLESS of binding. One
// emit per change (the shared `programmatic` guard dedups the React multi-render re-entry, D-07).
// STATIC key ($data.groupingDefault / $model.grouping). Event stem is `group-change`, NOT
// `grouping-change`: the model:true `grouping` prop auto-generates an `onGroupingChange` callback
// on the React/Solid flat Props interface, and a `grouping-change` event would camelCase to the
// SAME identifier → duplicate-identifier TS2300 (the model-prop==emit-name collision class 50-02
// hit with expanded/expanded-change → expand-change). Every sibling slice stems off a DISTINCT
// name (sorting→sort-change, rowSelection→selection-change); grouping→group-change follows suit.
const writeGrouping = (next: any) => {
  if (programmatic) return;
  programmatic++;
  groupingDefault = next; // fresh ordered array only (never in-place push)
  grouping = next; // two-way emit if bound (no-op-diff if not)
  ongroupchange?.(next);
  programmatic--;
};
// ── globalFilter slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────
// A fresh string (primitive) to the uncontrolled default + the two-way model + fires
// `filter-change` REGARDLESS of binding.
const writeGlobalFilter = (next: any) => {
  if (programmatic) return;
  programmatic++;
  globalFilterDefault = next;
  globalFilter = next;
  onfilterchange?.({
    globalFilter: next
  });
  programmatic--;
};
// ── columnFilters slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ─────
// table-core hands ColumnFiltersState = [{ id, value }]; write a FRESH array (never
// in-place push) + fire `filter-change`. globalFilter + columnFilters both surface
// through `filter-change` (per the plan: filter-change fires regardless of binding).
const writeColumnFilters = (next: any) => {
  if (programmatic) return;
  programmatic++;
  columnFiltersDefault = next;
  columnFilters = next;
  onfilterchange?.({
    columnFilters: next
  });
  programmatic--;
};
// ── pagination slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ───────
// table-core hands { pageIndex, pageSize }; write a FRESH object + fire `page-change`.
const writePagination = (next: any) => {
  if (programmatic) return;
  programmatic++;
  paginationDefault = next;
  pagination = next;
  onpagechange?.(next);
  programmatic--;
};
// ── rowSelection slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands RowSelectionState = { [rowId]: true }; write a FRESH object (never
// in-place key-set) + fire `selection-change` REGARDLESS of binding.
const writeRowSelection = (next: any) => {
  if (programmatic) return;
  programmatic++;
  rowSelectionDefault = next;
  rowSelection = next;
  onselectionchange?.(next);
  programmatic--;
};
// ── columnVisibility slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──
// table-core hands VisibilityState = { [colId]: boolean }; write a FRESH object (never
// in-place key-set) + fire `visibility-change` REGARDLESS of binding.
const writeColumnVisibility = (next: any) => {
  if (programmatic) return;
  programmatic++;
  columnVisibilityDefault = next;
  columnVisibility = next;
  onvisibilitychange?.(next);
  programmatic--;
};
// ── columnSizing slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────
// table-core hands ColumnSizingState = { [colId]: number }; the pointer-drag resize
// handle funnels a FRESH sizing object + fires `resize-change` REGARDLESS of binding.
const writeColumnSizing = (next: any) => {
  if (programmatic) return;
  programmatic++;
  columnSizingDefault = next;
  columnSizing = next;
  onresizechange?.(next);
  programmatic--;
};
// ── columnOrder slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ────────
// table-core hands ColumnOrderState = string[]; write a FRESH order array (never an
// in-place splice) + fire `reorder-change` REGARDLESS of binding.
const writeColumnOrder = (next: any) => {
  if (programmatic) return;
  programmatic++;
  columnOrderDefault = next;
  columnOrder = next;
  onreorderchange?.(next);
  programmatic--;
};
// ── columnPinning slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
// table-core hands ColumnPinningState = { left: string[], right: string[] }; write a
// FRESH object (never in-place push into left/right) + fire `pin-change` REGARDLESS of
// binding.
const writeColumnPinning = (next: any) => {
  if (programmatic) return;
  // effectiveColumnPinning() forces the auto-injected chrome ids (select/expander) into the
  // table-core `left` rail, so table-core hands them back here on every pin change. Strip them
  // before persisting: the CONSUMER's columnPinning model + the pin-change event must reflect
  // only their own columns, never our internal rail ids (which re-inject each render anyway).
  const strip = (ids: any) => (ids || []).filter((id: any) => id !== SELECT_COL_ID && id !== EXPANDER_COL_ID);
  const clean = {
    ...next,
    left: strip(next && next.left),
    right: strip(next && next.right)
  };
  programmatic++;
  columnPinningDefault = clean;
  columnPinning = clean;
  onpinchange?.(clean);
  programmatic--;
};
// ── data slice: STATIC-KEY fresh-array echo-guarded write funnel (Phase 51 req-4) ──
// A committed cell/row edit (or paste/fill in a later wave) replaces ONE row object in
// a FRESH array and funnels it here. Writes the uncontrolled default + the two-way
// model so editing works controlled OR uncontrolled. CRITICAL: writeData does NOT emit —
// unlike the 9 state slices (each has one change event fired inside its funnel), the
// `data` slice's commit event (`cell-edit-commit`) carries a PER-CELL payload and fires
// from the SINGLE commitEdit call site so the count stays exactly one per commit (React
// multi-emit dedup, D-07). Echo-guarded by the shared `programmatic` counter so the
// re-feed watch never re-enters mid-write.
//
// 260709-8ct (grid-wide undo/redo): record the PRE-mutation snapshot BEFORE writing, but
// ONLY when `$props.undoable` is on AND we are not mid-replay (`!restoringHistory` — an
// undo()/redo() call routes back through THIS SAME writeData to reuse the two-way model +
// re-feed watch; without the guard the replay would re-record itself and corrupt the
// stack). `emitHistoryChangeIfEdged` fires `history-change` only when canUndo/canRedo
// availability actually flipped (a long streak of edits that doesn't change availability
// must not spam consumers).
//
// External-swap origin marker: stamp EVERY array we write (undoable or not, incl. an undo/
// redo replay) with the durable, non-enumerable marker under DATA_WRITE_TOKEN_KEY. The reset
// (maybeClearHistoryOnExternalSwap in DataTable.rozie) clears history only when a new $props.data
// lacks the marker → it did not come from us → a genuine external swap. This replaces the
// `dataWriteSettling` settle-window flag that a slow re-feed on a large controlled table outran
// (#8) — see DATA_WRITE_TOKEN_KEY's declaration in DataTable.rozie for the four flag/timer
// variants it supersedes and why the marker is timing-independent. Stamped on the fresh `next`
// array (never the consumer's original), non-enumerable so JSON.stringify / spread / Object.keys
// never see it. We write a FRESH RAW shallow copy (`fresh`) so the marker lands on an UNWRAPPED
// array: an undo/redo replay reuses a snapshot that, in controlled mode, is a framework reactive
// PROXY (svelte `$state`, vue `reactive`) — and `Object.defineProperty` does NOT reliably stick
// through a proxy's trap, so stamping the raw copy (never the possibly-proxied `next`) keeps the
// marker readable. Normal edits already pass a fresh array; the copy shares row references (cheap).
// `try` guards the (never-expected) frozen/sealed-array case.
const writeData = (next: any) => {
  if (programmatic) return;
  if (undoable && !restoringHistory) {
    const prevU = canUndo();
    const prevR = canRedo();
    recordSnapshot(currentData());
    emitHistoryChangeIfEdged(prevU, prevR);
  }
  const fresh = Array.isArray(next) ? next.slice() : next;
  try {
    Object.defineProperty(fresh, DATA_WRITE_TOKEN_KEY, {
      value: true,
      enumerable: false,
      configurable: true,
      writable: true
    });
  } catch (_e: any) {/* a frozen/sealed array can't be stamped — our fresh arrays never are */}
  programmatic++;
  dataDefault = fresh; // fresh raw array only (never in-place, never a proxy)
  data = fresh; // two-way emit if bound (no-op-diff if not)
  programmatic--;
};
// Read the live columnFilters value for a given column id (string-safe; drives the
// per-column filter input's bound value). Reads currentState() (NOT a $data re-read
// of a just-written key → React stale-read safe).
const columnFilterValue = (colId: any) => {
  const cf = currentState().columnFilters || [];
  for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
  return '';
};
// Apply a per-column filter value: build a FRESH ColumnFiltersState array (drop the
// column's prior entry, append the new one unless empty) and funnel it. Never mutate
// the existing array in place (silent on React/Solid/Angular/Lit).
const setColumnFilter = (colId: any, value: any) => {
  const prev = currentState().columnFilters || [];
  const next = [];
  for (const f of prev as any) if (f && f.id !== colId) next.push(f);
  if (value != null && value !== '') next.push({
    id: colId,
    value
  });
  writeColumnFilters(next);
};
// ── grid-wide undo/redo (260709-8ct) — snapshot-stack history engine ──────────────────────
// Per the approved design (docs/superpowers/specs/2026-07-09-data-table-undo-history-design.md,
// decisions 1-6, LOCKED). A pure, unit-testable buffer over the FOUR history lets declared
// top-level in DataTable.rozie beside `let programmatic = 0` (undoStack/redoStack/
// restoringHistory/lastPropsData — NOT $data, so recording an edit causes no reactive
// re-render churn on every keystroke). This module holds the FUNCTIONS only; it references
// those component-scope lets + `$props`, `$emit`, `writeData`, `currentData` BARE (by name,
// zero ES imports) — the SAME inlined-partial pattern writeFunnels.rzts uses for
// `programmatic`/`$data`/`$model` (DataTable.rozie is the ONLY place that ES-imports across
// .rzts partials; a cross-import between writeFunnels and undoHistory would create an ES
// cycle and/or a TDZ on the inlined lets).
//
// Collision-safe (ROZ121/124/137): none of undo/redo/canUndo/canRedo/clearHistory are
// HTMLElement methods, model props, or React auto-generated setters.

// Push the PRE-mutation snapshot (a `data` array reference — never a deep copy; unchanged
// rows are shared across every retained snapshot because every write funnel already builds a
// fresh array reusing unchanged row references, per the design's Memory analysis). Evict the
// oldest snapshot once the stack exceeds `undoLimit` (default 100 — DataTable.rozie prop).
// Any NEW recording invalidates the redo stack (standard undo semantics).
const recordSnapshot = (current: any) => {
  undoStack.push(current);
  const limit = undoLimit != null ? undoLimit : 100;
  while (undoStack.length > limit) undoStack.shift();
  redoStack = [];
};
export const canUndo = () => undoStack.length > 0;
export const canRedo = () => redoStack.length > 0;
// Both stacks empty — the external-swap latch (DataTable.rozie reFeed) and the
// clearHistory() $expose verb share this single implementation.
export const clearHistory = () => {
  undoStack = [];
  redoStack = [];
};
// `$emit('history-change', { canUndo, canRedo })` — the imperative/keyboard $expose verb
// contract. Unconditional (used by undo()/redo() themselves, which always fire exactly once
// per call per the design — NOT edge-gated there; only the writeData-triggered recording path
// below is edge-gated, since a routine sequence of edits would otherwise spam the event).
const emitHistoryChange = () => {
  onhistorychange?.({
    canUndo: canUndo(),
    canRedo: canRedo()
  });
};
// Fire `history-change` ONLY when canUndo/canRedo availability flipped since `prevU`/`prevR`
// were captured (BEFORE recordSnapshot ran). Called from writeData's recording hook so a
// long streak of edits that doesn't change availability (canUndo already true, redo already
// empty) does not spam consumers with a no-op event per keystroke.
const emitHistoryChangeIfEdged = (prevU: any, prevR: any) => {
  const nextU = canUndo();
  const nextR = canRedo();
  if (nextU !== prevU || nextR !== prevR) emitHistoryChange();
};
// undo(): pop the most recent pre-mutation snapshot, push the CURRENT data onto the redo
// stack (so redo can restore it), then replay the popped snapshot through the SAME writeData
// seam — under `restoringHistory = true` so writeData's own recording hook does not
// re-capture this replay (which would corrupt the stack). Replaying through writeData
// (rather than writing $data/$model directly) is deliberate: the two-way $model.data
// writeback, the re-feed $watch, and the echo guard all keep working with zero new code.
export const undo = () => {
  if (!canUndo()) return;
  const prev = undoStack.pop();
  redoStack.push(currentData());
  restoringHistory = true;
  writeData(prev);
  restoringHistory = false;
  emitHistoryChange();
};
// redo(): symmetric — pop the redo stack, push the CURRENT data back onto the undo stack,
// replay through the same guarded writeData seam.
export const redo = () => {
  if (!canRedo()) return;
  const next = redoStack.pop();
  undoStack.push(currentData());
  restoringHistory = true;
  writeData(next);
  restoringHistory = false;
  emitHistoryChange();
};
// Re-read the row model + header groups into $data (fresh arrays → the template
// re-renders). A plain fn (NOT a $computed — getRowModel() must be pulled AFTER a
// setOptions re-feed, imperatively). Defined inside $onMount so it captures the live
// `table`.
let refreshRowModel: any = null;
// PER-SLICE callbacks hoisted to top-level consts (NOT inlined in createTable) so the
// re-feed $watch can re-pass them on every setOptions. On React the createTable
// callbacks would otherwise capture the MOUNT-render's currentState() closure (table
// instance is built once in $onMount); table-core's setOptions keeps the prior
// callbacks unless new ones are supplied, so a stale callback applied each updater
// against the mount-time empty slice → the sort cycle never advances + multi-row
// selection collapses to the last row (React stale-closure, F6). Re-passing these
// fresh (recreated each render on React, reading fresh currentState) in the re-feed
// keeps the Updater base value current. No-op cost on the other five.
const onSortingChangeCb = (updater: any) => {
  writeSorting(applyUpdater(updater, currentState().sorting));
};
const onExpandedChangeCb = (updater: any) => {
  writeExpanded(applyUpdater(updater, currentState().expanded));
};
const onGroupingChangeCb = (updater: any) => {
  writeGrouping(applyUpdater(updater, currentState().grouping));
};
const onGlobalFilterChangeCb = (updater: any) => {
  writeGlobalFilter(applyUpdater(updater, currentState().globalFilter));
};
const onColumnFiltersChangeCb = (updater: any) => {
  writeColumnFilters(applyUpdater(updater, currentState().columnFilters));
};
const onPaginationChangeCb = (updater: any) => {
  writePagination(applyUpdater(updater, currentState().pagination));
};
const onRowSelectionChangeCb = (updater: any) => {
  writeRowSelection(applyUpdater(updater, currentState().rowSelection));
};
const onColumnVisibilityChangeCb = (updater: any) => {
  writeColumnVisibility(applyUpdater(updater, currentState().columnVisibility));
};
const onColumnSizingChangeCb = (updater: any) => {
  writeColumnSizing(applyUpdater(updater, currentState().columnSizing));
};
const onColumnOrderChangeCb = (updater: any) => {
  writeColumnOrder(applyUpdater(updater, currentState().columnOrder));
};
const onColumnPinningChangeCb = (updater: any) => {
  writeColumnPinning(applyUpdater(updater, currentState().columnPinning));
};
const onColumnSizingInfoChangeCb = (updater: any) => {
  const next = applyUpdater(updater, columnSizingInfo);
  columnSizingInfo = next != null ? next : columnSizingInfo;
};
// ══ Vertical row windowing (phase 53, req-1/2/3/6/9/10) — the virtual-core bridge ════════
// virtual-core is a pure state machine EXACTLY like table-core: constructed once in $onMount
// (ONLY when $props.virtual), its imperative onChange push converted to per-target reactivity
// via the SEPARATE $data.windowVer tick, re-fed via setOptions()+_willUpdate() in the
// refreshRowModel path (NEVER a render helper — Pitfall 1). Every runtime reference is guarded
// so the virtual=false emitted path is dead (req-1).
//
// Phase 64 (D-04): the PURE windowing math (windowedRows / padTop / padBottom / pmIndexInWindow /
// rowIsOutsideWindow / virtualizerOptions / virtualItemKey) now lives in the shared, target-agnostic
// `@rozie-ui/headless-core/windowing.rzts` partial and is re-exported below — this file is now the
// thin DATA-TABLE HOST SHELL holding only the impure, per-consumer pieces (the table-bound row
// source + the DOM/refs/virtualizer-instance machinery + the D-05 edit-pinning hook). The math
// dissolves in via inlineScriptPartials() byte-identically; behavior is unchanged (the B13 specs +
// dist-parity are the net). The host satisfies the windowing.rzts contract by convention:
// windowSource() (the row source), pinnedEditIndex()/pinnedMeasurement() (the D-05 pin hook),
// scheduleRemeasure(), and the gridScrollEl/virtualizer/virtual-core-fn references.

// windowSource(): the rows fed to the virtualizer AND held in $data.rows — the windowing.rzts
// host-contract source. When virtual, the FULL filtered+sorted PRE-PAGINATION model
// (A2-verified table.getPrePaginationRowModel()) so windowing REPLACES client pagination (req-9);
// else the normal (paginated) row model — the non-virtual path is byte-unchanged.
const windowSource = () => {
  if (!table) return [];
  if (virtual) return table.getPrePaginationRowModel().rows;
  return table.getRowModel().rows;
};
// Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
// BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
// test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
// (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
// polls for its target cell, so it no longer depends on remeasure timing).
//
// TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
//   - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
//     tick that the windowVer bump triggers, so the recycled nodes already exist by the next
//     microtask — measuring there observes them while they are still connected, BEFORE the next
//     fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
//     fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
//     frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
//     total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
//   - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
//     committed yet), so the rAF pass is what observes React's recycled rows.
// Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
// already-observed node, so running both is cheap and loop-free.
const scheduleRemeasure = () => {
  if (remeasurePending) return;
  remeasurePending = true;
  let ranMicro = false;
  const microPass = () => {
    remeasureWindow();
  };
  const rafPass = () => {
    remeasurePending = false;
    remeasureWindow();
  };
  if (typeof queueMicrotask !== 'undefined') {
    ranMicro = true;
    queueMicrotask(microPass);
  }
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else setTimeout(rafPass, 0);
};
// pinnedEditIndex(): the FULL-MODEL row index of the row currently in edit (D-02 pin-row),
// or -1 when no editor is open. Under virtualization `$data.rows` is the FULL pre-pagination
// model, so editingRow (single-cell) / editingRowIndex (full-row) — both in that index space —
// ARE the full-model index. The pinned row must never recycle while editing (req-9): it is
// unioned into the windowed slice when it scrolls off-window and its height is subtracted from
// the appropriate spacer so the total stays exactly getTotalSize() (the 51-01-proven mechanism).
// This is the data-table half of the D-05 windowing.rzts pin-extension hook (listbox provides none).
const pinnedEditIndex = () => {
  if (editingRow >= 0) return editingRow;
  if (editingRowIndex != null) return editingRowIndex;
  return -1;
};
// pinnedMeasurement(pin): the virtual-core measurement { index, start, size, end, key } for the
// pinned full-model index — its measured (or estimated) height + offset, used to (a) decide
// whether it sits above/below the rendered window and (b) subtract its height from the right
// spacer. Null when out of range / not virtual.
const pinnedMeasurement = (pin: any) => {
  if (!virtualizer || pin < 0) return null;
  const ms = virtualizer.getMeasurements();
  return ms && ms[pin] ? ms[pin] : null;
};
// measureElement sweep (D-10 / CR-01): refine estimated heights to MEASURED ones. The off-root
// querySelector idiom (chartjs/cropper/embla precedent — no per-row callback ref). Each rendered
// <tr> MUST be handed to virtualizer.measureElement on every window commit for it to be observed:
// virtual-core does NOT auto-register rendered rows — measureElement is the SOLE caller of its
// internal ResizeObserver's observe() (virtual-core@3.17.1 dist/esm/index.js:794-817), keyed by
// getItemKey. So this sweep must run not just once at mount but on every onChange tick (via
// scheduleRemeasure), or recycled rows keep the estimateRowHeight seed forever. measureElement is
// idempotent on an already-observed node (the `prevNode !== node` guard), so re-sweeping the
// visible window each commit is cheap and loop-free.
const remeasureWindow = () => {
  if (!virtualizer || !gridRoot) return;
  // Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
  // exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
  // null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
  // scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
  // resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
  // regression); the next settled onChange re-measures the stable window. Manual-scroll
  // recycling (the CR-01 case) has scrollState === null, so it measures normally.
  if (virtualizer.scrollState) return;
  const trs = gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
  for (const tr of trs as any) virtualizer.measureElement(tr);
};
// ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
// Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
// holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
// in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
// compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
//
// HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
// by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
//   - windowSource(): T[]   — the full list to window (the KEY generalization; the DataTable host
//                             returns its pre-pagination row model, listbox/combobox return the
//                             filtered options). This partial MUST NOT reach into the host data engine
//                             directly — rows arrive ONLY through windowSource().
//   - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
//   - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
//   - gridScrollEl              — the scroll-container element handle.
//   - virtualizer               — the host virtual-core instance (built in $onMount from the ref).
//   - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
//   - scheduleRemeasure()       — the host's rAF/microtask remeasure defer.
//   - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
//                             defaulting to no-op): the DataTable host passes its edit-pinning hooks;
//                             listbox passes nothing. Routing pinning through this host hook (NOT
//                             inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.

// getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
// React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
// id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
const virtualItemKey = (i: any) => {
  const src = windowSource();
  return src && src[i] ? src[i].id : undefined;
};
// The FULL virtualizer options. virtual-core's setOptions REPLACES options with
// `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
// source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
// Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
// on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
// increment the React emitter lowers to functional setState — correct even from a mount closure.
const virtualizerOptions = (): any => ({
  count: windowSource().length,
  getScrollElement: () => gridScrollEl,
  estimateSize: () => estimateRowHeight,
  observeElementRect,
  observeElementOffset,
  scrollToFn: elementScroll,
  measureElement,
  overscan: 8,
  getItemKey: virtualItemKey,
  onChange: () => {
    windowVer = windowVer + 1;
    // CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
    // virtual-core only observe()s a node you explicitly hand to measureElement (it does
    // NOT auto-discover rendered rows — measureElement is the SOLE caller of
    // observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
    // into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
    // estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
    // so the new <tr> set is in the DOM before we measure. Safe from an infinite
    // measure→onChange→measure loop: measureElement is idempotent on an already-observed
    // node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
    // measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
    // re-measure is a no-op.
    scheduleRemeasure();
  }
});
// pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
// shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
// two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
// no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
// `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
// guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
// thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
// gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
// Typing-only: the runtime value (a measurement or null) is unchanged.
const pinMeasurement = (pin: number): {
  start: number;
  size: number;
  index: number;
  end: number;
} | null => pinnedMeasurement(pin);
// windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
// { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
// $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
// full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
// `rows` binding → TS2448 self-shadow, line ~1149 lesson).
const windowedRows = () => {
  // SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
  // early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
  // which happens at initial render while `virtualizer` is still null (it is built in $onMount,
  // after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
  // BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
  // signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
  // blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
  // placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
  // first re-run that picks up the now-non-null virtualizer.
  // ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
  // pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
  void windowVer;
  void editVer;
  if (!virtualizer) {
    // Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
    // but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
    // the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
    // rows appear on the first onChange after _didMount.
    if (!virtual) {
      const rowList = rows || [];
      return rowList.map((r: any) => ({
        vi: null,
        row: r
      }));
    }
    return [];
  }
  const items = virtualizer.getVirtualItems();
  const rowList = rows || [];
  // WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
  // shrink window where the virtualizer count is stale relative to $data.rows on the async
  // onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
  // throw "Cannot read properties of undefined"; filter it here so the template never sees it.
  const out = items.map((vi: any) => ({
    vi,
    row: rowList[vi.index]
  })).filter((wr: any) => wr.row);
  // ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
  // window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
  // into another full-model row), LEADING the slice when it sits above the window and TRAILING
  // it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
  // padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
  // into the real windowing.
  const pin = pinnedEditIndex();
  if (pin >= 0 && rowList[pin]) {
    let inWindow = false;
    for (let i = 0; i < items.length; i++) {
      if (items[i].index === pin) {
        inWindow = true;
        break;
      }
    }
    if (!inWindow) {
      const pm = pinMeasurement(pin);
      const firstStart = items.length ? items[0].start : 0;
      const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
      const pinnedEntry = {
        vi: pm != null ? pm : {
          index: pin
        },
        row: rowList[pin],
        pinned: true
      };
      if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
    }
  }
  return out;
};
// Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
// the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
// (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
const padTop = () => {
  // SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
  // spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
  // and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
  void windowVer;
  void editVer;
  if (!virtual || !virtualizer) return 0;
  const items = virtualizer.getVirtualItems();
  let pad = items.length ? items[0].start : 0;
  // D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
  // in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
  // that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
  const pin = pinnedEditIndex();
  if (pin >= 0) {
    const pm = pinMeasurement(pin);
    const inWindow = pmIndexInWindow(items, pin);
    if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
  }
  return pad < 0 ? 0 : pad;
};
const padBottom = () => {
  // subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
  // return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
  // on pin/unpin.
  void windowVer;
  void editVer;
  if (!virtual || !virtualizer) return 0;
  const items = virtualizer.getVirtualItems();
  if (!items.length) return 0;
  let pad = virtualizer.getTotalSize() - items[items.length - 1].end;
  // D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
  // in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
  const pin = pinnedEditIndex();
  if (pin >= 0) {
    const pm = pinMeasurement(pin);
    const inWindow = pmIndexInWindow(items, pin);
    // WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
    // measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
    // index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
    // The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
    // the offset comparison only if the measurement lacks an index (defensive).
    const lastItemIdx = items[items.length - 1].index;
    const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
    if (pm && !inWindow && below) {
      // below the window → it trailed the slice; subtract its height from the trailing spacer.
      if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
    }
  }
  return pad < 0 ? 0 : pad;
};
// pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
const pmIndexInWindow = (items: any, idx: any) => {
  for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
  return false;
};
// rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
// window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
const rowIsOutsideWindow = (r: any) => {
  if (!virtual || !virtualizer) return false;
  const items = virtualizer.getVirtualItems();
  for (const it of items as any) if (it.index === r) return false;
  return true;
};
// ── Sort/filter live-announcement (#14) ─────────────────────────────────────────────
// A polite aria-live announcement whenever the consumer changes sorting or filtering, so a
// screen-reader user hears that the rows were reordered / narrowed (which is otherwise silent).
// announceState holds the last-seen references so the lazy watch below can tell WHICH slice
// changed (sort vs filter) and pick the message. It is a top-level mutable const → stabilized
// once per instance on all six targets (React useMemo-wraps a mutable instance; the others run
// setup once), so it PERSISTS across renders — unlike a top-level `let`, which React resets per
// render. Seeded from the initial state in $onMount so the first (post-mount) change compares
// against the true starting values, not a null sentinel.
// Typed as `unknown` members: these hold opaque last-seen references compared only by
// identity (!==) below, never read in a typed context — the annotation keeps the null seed
// from narrowing the members to `null` (which would reject the real reassignments under
// strictNullChecks in the emitted leaves).
const announceState: {
  sorting: unknown;
  columnFilters: unknown;
  globalFilter: unknown;
} = {
  sorting: null,
  columnFilters: null,
  globalFilter: null
};
// Effective (controlled-or-uncontrolled) reads of the sort/filter slices: the bound prop when
// the consumer bound the matching r-model, else the uncontrolled $data default (mirrors currentState()).
const effectiveSorting = () => sorting != null ? sorting : sortingDefault;
const effectiveColumnFilters = () => columnFilters != null ? columnFilters : columnFiltersDefault;
const effectiveGlobalFilter = () => globalFilter != null ? globalFilter : globalFilterDefault;
// Build the polite message for a sort/filter change and advance announceState. Sort takes
// precedence when the sorting reference changed; otherwise a filter changed → the post-filter
// result count (the FILTERED total via totalRowCount(), NOT the page slice). Returns '' when
// neither actually changed (a no-op watch tick — do not re-announce).
const buildSortFilterAnnounce = () => {
  const nextSorting = effectiveSorting();
  const nextColumnFilters = effectiveColumnFilters();
  const nextGlobalFilter = effectiveGlobalFilter();
  const sortChanged = nextSorting !== announceState.sorting;
  const filterChanged = nextColumnFilters !== announceState.columnFilters || nextGlobalFilter !== announceState.globalFilter;
  announceState.sorting = nextSorting;
  announceState.columnFilters = nextColumnFilters;
  announceState.globalFilter = nextGlobalFilter;
  if (sortChanged) {
    const active = nextSorting && nextSorting.length ? nextSorting[0] : null;
    if (!active) return 'Sorting cleared';
    const rawLabel = headerLabel(active.id);
    const label = typeof rawLabel === 'string' && rawLabel ? rawLabel : active.id;
    return 'Sorted by ' + label + ', ' + (active.desc ? 'descending' : 'ascending');
  }
  if (filterChanged) {
    return totalRowCount() + ' results';
  }
  return '';
};
// Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
// re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
const reFeed = () => {
  if (!table) return;
  // NOTE: the external-swap history reset does NOT live here. reFeed() fires on EVERY watched
  // change — including our OWN synchronous internal `$data.dataDefault` write — so a clear keyed
  // on a `currentData()` read here would (on fine-grained targets) fire mid-round-trip against a
  // TRANSIENTLY-STALE `$props.data` and wrongly wipe a just-recorded edit's history. The reset is
  // keyed on the `$props.data` REFERENCE actually changing instead — see the $onUpdate backstop
  // below (`maybeClearHistoryOnExternalSwap`), which runs on all six targets.
  table.setOptions((prev: any) => ({
    ...prev,
    data: currentData(),
    columns: tableColumns(),
    state: currentState(),
    enableRowSelection: selectionMode !== 'none',
    enableMultiRowSelection: selectionMode === 'multiple',
    // Re-pass the server-side page-count sources (#2) so a RUNTIME rowCount/pageCount change
    // takes effect: setOptions REPLACES via `...prev`, which holds the value captured at
    // createTable time, so an omitted key would freeze the mount-time count. The re-feed
    // $watch keys on both props below.
    rowCount: rowCount ?? undefined,
    pageCount: pageCount ?? undefined,
    // Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
    // setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
    // onExpandedChange callback must re-capture fresh currentState each cycle, F6).
    getExpandedRowModel: getExpandedRowModel(),
    getSubRows: (getSubRows || undefined) as any,
    getRowCanExpand: expandable === true && getSubRows == null ? () => true : undefined,
    onExpandedChange: onExpandedChangeCb,
    // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
    // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
    // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
    // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
    // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
    // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
    // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
    // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
    // are unaffected (they fire regardless of this flag).
    autoResetExpanded: false,
    // Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
    // omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
    // fresh currentState each cycle, F6).
    getGroupedRowModel: getGroupedRowModel(),
    onGroupingChange: onGroupingChangeCb,
    // Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
    // drop the model on re-feed; on React the faceted closures must re-capture so exposed
    // unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: makeFacetedUniqueValues(),
    getFacetedMinMaxValues: makeFacetedMinMaxValues(),
    // Re-pass the per-slice callbacks so React captures fresh currentState each cycle
    // (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
    onSortingChange: onSortingChangeCb,
    onGlobalFilterChange: onGlobalFilterChangeCb,
    onColumnFiltersChange: onColumnFiltersChangeCb,
    onPaginationChange: onPaginationChangeCb,
    onRowSelectionChange: onRowSelectionChangeCb,
    onColumnVisibilityChange: onColumnVisibilityChangeCb,
    onColumnSizingChange: onColumnSizingChangeCb,
    onColumnOrderChange: onColumnOrderChangeCb,
    onColumnPinningChange: onColumnPinningChangeCb,
    onColumnSizingInfoChange: onColumnSizingInfoChangeCb
  }));
  if (refreshRowModel) refreshRowModel();
};
// LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
// `data` PROPERTY): the re-feed $watch reads `(this.data||[]).length` inside a
// preact-signals effect, but `data` is a Lit @property (not a signal) so the effect
// never re-runs when the consumer pushes new rows post-mount (the sticky demo seeds 20
// rows in its own $onMount AFTER the child mounted empty → the body stayed at 0). The
// slice models DO re-pull (their $data.<slice>Default signals are effect-tracked), so
// only a raw `data` reference/length change slips through. $onUpdate (Lit updated())
// fires on ANY property change incl `data`; guard with a stored last-seen data ref +
// length so it re-feeds ONLY on a real data change (no churn). On the coarse-render
// targets the watch already covers it; this is a cheap idempotent backstop.
// External-swap history reset (grid-wide undo/redo, 260709-8ct; #8 fix). Keyed on the CONTROLLED
// `$props.data` REFERENCE changing — deliberately NOT on `currentData()` inside reFeed. An internal
// writeback changes `$data.dataDefault` SYNCHRONOUSLY and only LATER round-trips into `$props.data`;
// keying on `$props.data`'s OWN change means we never observe the transient window where a fine-
// grained target's reFeed reads a stale, unstamped `$props.data` mid-write and wrongly wipes a
// just-recorded edit's history (the stale-read false-clear — the SAME failure that broke the
// content-signature variant — that regressed Solid/Lit when this clear lived in reFeed). When
// `$props.data` genuinely changes: a new array carrying DATA_WRITE_TOKEN round-tripped from one of
// OUR writes → keep; one without it is a dataset the consumer handed us → external swap → clear. A
// non-data tick (sort/filter/pagination) never touches `$props.data` → never clears. Called from
// BOTH the coarse re-feed watch AND the $onUpdate backstop (Lit's @property `data` the effect-
// tracked watch can't observe); both are ref-gated so the redundant call is an idempotent no-op.
let lastPropsData: unknown = null;
const maybeClearHistoryOnExternalSwap = () => {
  const pd = data;
  if (pd === lastPropsData) return; // $props.data did not change → not an external swap
  lastPropsData = pd;
  if (!undoable) return;
  if (pd != null && (pd as any)[DATA_WRITE_TOKEN_KEY] != null) return; // descends from our write → keep
  clearHistory();
};
let lastData: any = null;
let lastDataLen = -1;
// Header click → toggle sort. Shift-click → ADD a secondary sort (multi-sort). Driven
// through table-core's column API so the onSortingChange funnel emits the fresh state.
const onHeaderSort = (colId: any, evt: any) => {
  if (!table) return;
  const col = table.getColumn(colId);
  if (!col || !col.getCanSort()) return;
  const multi = !!(evt && evt.shiftKey);
  // toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
  col.toggleSorting(undefined, multi);
};
// aria-sort string for a column header: 'ascending' | 'descending' | 'none'. Reads
// Reactive tick: read $data.rowModelVer (bumped by every refreshRowModel) so a
// template binding that calls a table-READING chrome helper (pagination/sort/pin/
// visibility predicates below) re-evaluates when the row model changes. On the
// coarse-render targets (Vue/React/Angular) the whole template re-runs anyway so this
// is a no-op; on the FINE-GRAINED targets (Solid/Lit) a helper that only reads the
// non-reactive `table` let would be computed ONCE (when table is still null → the
// default branch) and never update — pagination would read "Page 1 of 1" forever,
// aria-sort never flips, the pin position never sticks. Touching rowModelVer puts each
// helper in the reactive scope. The chrome helpers prefix `tick()` in their guard.
const tick = () => rowModelVer;
// the live sort direction off the table-core column (string-safe — never a bound
// boolean, the listbox aria lesson).
const ariaSortFor = (colId: any) => {
  if (tick() < 0 || !table) return 'none';
  const col = table.getColumn(colId);
  if (!col) return 'none';
  const dir = col.getIsSorted();
  if (dir === 'asc') return 'ascending';
  if (dir === 'desc') return 'descending';
  return 'none';
};
// A small sort-direction glyph for the header (▲/▼/empty). Decorative — aria-hidden.
const sortIndicator = (colId: any) => {
  if (tick() < 0 || !table) return '';
  const col = table.getColumn(colId);
  if (!col) return '';
  const dir = col.getIsSorted();
  if (dir === 'asc') return '▲';
  if (dir === 'desc') return '▼';
  return '';
};
// Template helpers reading the resolved column-def metadata by id (plain fns — used
// in template predicates + interpolation; uniform on all 6, no $computed alias trap).
const defFor = (colId: any) => {
  const defs = columnDefs();
  for (const d of defs as any) if (d.id === colId) return d;
  return null;
};
// Per-row visible cells for the body loop. table-core memoizes row objects by id,
// so a re-pull after a column change (visibility/reorder/pin, or the late <Column>
// registry on first mount) returns the SAME row references with a different cell
// set. On Solid the row loop keeps the existing <tr> across that pull (`:key="row.id"`
// is stable, so the emitter's `<Key>` reconciler holds the node), and Solid will NOT
// re-run a child loop whose `each` reads no signal — so a bare `row.getVisibleCells()`
// goes stale (header reorders, cells don't). Reading `$data.rowModelVer` (bumped by every
// refreshRowModel) inside the `each` puts the inner loop in the reactive scope, so it
// re-derives the cells on every row-model change. No-op on the coarse-render targets.
const visibleCellsFor = (row: any) => rowModelVer >= 0 ? row.getVisibleCells() : [];
// ── Editable-cell column-meta accessors (phase 51 req-1/2/5) ───────────────────────
// editMetaOf: the resolved ColumnDef.meta for a column id (the editable config carried
// from <Column>/`:columns` via columnDefs). Null-safe — an unknown/non-editable column
// returns null and every predicate below short-circuits to the read-only path.
const editMetaOf = (colId: any) => {
  const d = defFor(colId);
  return d && d.meta ? d.meta : null;
};
// columnEditable: whether this column opted into editing (req-1). Drives every editor
// gate; false → the cell stays the read-only #cell display (byte-identical-off).
const columnEditable = (colId: any) => {
  const m = editMetaOf(colId);
  return !!(m && m.editable === true);
};
// editorTypeOf: the built-in editor kind ('text'|'number'|'select'|'checkbox') OR
// 'custom' (the #editor scoped-slot escape hatch, req-2). Defaults to 'text'.
const editorTypeOf = (colId: any) => {
  const m = editMetaOf(colId);
  return m && m.editor != null ? m.editor : 'text';
};
// editorOptionsOf: the select-editor options ([{ value, label }]) for editor='select'.
const editorOptionsOf = (colId: any) => {
  const m = editMetaOf(colId);
  return m && m.editorOptions != null ? m.editorOptions : [];
};
// hasEditorSlot: this column routes through the consumer's #editor scoped slot (req-2)
// — true only when the column declared editor='custom' AND the consumer actually
// provided an #editor slot. Falls through to the built-in editor otherwise (e.g. a
// column marked 'custom' with no slot supplied degrades to the text editor, never blank).
const hasEditorSlot = (colId: any) => editorTypeOf(colId) === 'custom' && !!editor;
// hasFilterSlot: the consumer supplied a #filter scoped slot, so it OWNS the per-column
// filter UI (re-added in 72-05 alongside the dedicated filter row's `<slot name="filter">`
// host — see the 72-03 removal note in that plan's SUMMARY for why this was briefly gone).
const hasFilterSlot = () => !!filter;
const columnIsFilterable = (colId: any) => {
  const d = defFor(colId);
  return !!(d && d.filterable);
};
const headerLabel = (colId: any) => {
  const d = defFor(colId);
  return d ? d.header : colId;
};
// ── Column-management chrome (req-8/9/10/11) ────────────────────────────────────────
// Live header width (px) for a column — drives the <th> :style width binding. Reads the
// table-core column size (post-mount) with a fallback to undefined (auto width).
const headerWidth = (colId: any) => {
  if (tick() < 0 || !table) return null;
  const col = table.getColumn(colId);
  if (!col) return null;
  const w = col.getSize();
  return w != null && w > 0 ? w + 'px' : null;
};
// Pointer-drag resize handler for a resizable header — table-core's getResizeHandler()
// returns a function bound to a pointerdown/touchstart event that drives the column
// size through onColumnSizingChange (our writeColumnSizing funnel) under
// columnResizeMode:'onChange'. Pure delegation; no scratch gesture state held in a
// top-level const (the React fragile-binding rule — table-core owns the gesture state).
const onResizeStart = (colId: any, evt: any) => {
  // stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
  if (evt && evt.stopPropagation) evt.stopPropagation();
  if (!table) return;
  const header = findHeader(colId);
  if (!header || !header.getResizeHandler) return;
  const handler = header.getResizeHandler();
  if (handler) handler(evt);
};
// Find the live header object for a column id across the rendered header groups.
const findHeader = (colId: any) => {
  const groups = headerGroups || [];
  for (const hg of groups as any) {
    const hs = hg.headers || [];
    for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
  }
  return null;
};
const columnIsResizing = (colId: any) => {
  if (tick() < 0 || !table) return false;
  const header = findHeader(colId);
  return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
};
// Visibility toggle (req-8) — drive table-core's column.toggleVisibility so the
// onColumnVisibilityChange funnel emits the fresh state.
const columnIsVisible = (colId: any) => {
  if (tick() < 0 || !table) return true;
  const col = table.getColumn(colId);
  return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
};
const onToggleVisibility = (colId: any) => {
  if (!table) return;
  const col = table.getColumn(colId);
  if (col && col.toggleVisibility) col.toggleVisibility();
};
// The full set of leaf columns (for the visibility-toggle menu) — id + header label +
// current visibility. Excludes the auto-injected CHROME columns (select + expander) —
// neither is a data column: they carry no header label (so they'd surface their raw
// internal id, e.g. '__rdt_expander') and their presence is governed by the
// selectionMode/expandable props, not user-toggleable visibility.
const allLeafColumns = () => {
  if (tick() < 0 || !table) return [];
  const cols = table.getAllLeafColumns ? table.getAllLeafColumns() : [];
  const out = [];
  for (const c of cols as any) {
    if (!c || c.id === SELECT_COL_ID || c.id === EXPANDER_COL_ID) continue;
    out.push({
      id: c.id,
      label: headerLabel(c.id),
      visible: !!(c.getIsVisible && c.getIsVisible())
    });
  }
  return out;
};
// Pinning (req-11) — drive table-core's column.pin('left'|'right'|false) so the
// onColumnPinningChange funnel emits a fresh state. Sticky offsets read the live column
// start/after positions (table-core computes them from the pinned column sizes).
const columnPinSide = (colId: any) => {
  if (tick() < 0 || !table) return false;
  const col = table.getColumn(colId);
  if (!col || !col.getIsPinned) return false;
  return col.getIsPinned();
};
// NOTE: the event is stopped HERE (evt.stopPropagation()) rather than via a `.stop`
// template modifier. The Angular emitter, hoisting a `.stop`-modified handler that
// lives INSIDE an `@for` loop into a class-field wrapper, drops the component `this.`
// qualifier (→ `onPinColumn(...)` bare ReferenceError) and fails to capture the loop
// var — so a `@click.stop="onPinColumn(...)"` inside the header `@for` breaks on
// Angular (F5). Stopping inside the handler sidesteps the broken hoist on all six.
const onPinColumn = (colId: any, side: any, evt: any) => {
  if (evt && evt.stopPropagation) evt.stopPropagation();
  if (!table) return;
  const col = table.getColumn(colId);
  if (col && col.pin) col.pin(side);
};
// Sticky inline style for a pinned header/cell — position:sticky + the computed left or
// right offset. Returns '' (no sticky) for unpinned columns. Returned as a STRING (the
// :style binding is value-driven — never an eval'd attr).
//
// `zIndex` (phase 72 fix, default 1 — body <td> / filter-row <th> layer): an INLINE style
// ALWAYS wins over the stylesheet's `.rozie-data-table.rdt-sticky .rdt-thead .rdt-th
// { z-index: var(--rdt-sticky-z, 2) }` rule, so a pinned header cell that unconditionally
// got `z-index:1` here (same as the pinned body/filter-row cells) silently DOWNGRADED the
// intended sticky-header stacking level from 2 to 1 — tying it with the dedicated filter
// row's own pinned <th> (72-05), which sits LATER in DOM order (a sibling <tr> beneath the
// header row) and therefore visually/interactively covers the header's ⋯ menu (phase 72,
// z-index:1000 relative to Popover's OWN local stacking context — capped by the pinned
// header <th>'s z-index, since a `position:fixed` descendant does not escape an ancestor's
// stacking context, only its layout containing block) whenever that SAME column is both
// pinned and filterable. thStyle() (the header caller) passes zIndex=2 so the header layer
// always wins ties against the filter-row/body layers, which keep the default of 1.
const pinStyle = (colId: any, zIndex = 1) => {
  if (tick() < 0 || !table) return '';
  const col = table.getColumn(colId);
  if (!col || !col.getIsPinned) return '';
  const side = col.getIsPinned();
  if (side === 'left') {
    const left = col.getStart ? col.getStart('left') : 0;
    return 'position:sticky;left:' + left + 'px;z-index:' + zIndex + ';';
  }
  if (side === 'right') {
    const right = col.getAfter ? col.getAfter('right') : 0;
    return 'position:sticky;right:' + right + 'px;z-index:' + zIndex + ';';
  }
  return '';
};
// Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
// uniform on all 6, no bound-object trap. zIndex=2 (see pinStyle) so a pinned header cell
// — which hosts the ⋯ menu's floating content — always stacks above the pinned filter-row
// cell for the same column (zIndex=1, its own default).
const thStyle = (colId: any) => {
  let s = '';
  const w = headerWidth(colId);
  if (w) s += 'width:' + w + ';';
  s += pinStyle(colId, 2);
  return s;
};
// ── Filter chrome handlers ─────────────────────────────────────────────────────────
// Global search input → funnel through table-core's setGlobalFilter so the
// onGlobalFilterChange callback fires the echo-guarded writer. Capture the fresh local
// value (never re-read a just-written $data key — React stale-read).
const onGlobalFilterInput = (evt: any) => {
  const value = evt && evt.target ? evt.target.value : '';
  if (table) {
    table.setGlobalFilter(value);
    return;
  }
  writeGlobalFilter(value);
};
// Per-column filter input → setColumnFilter (fresh-array funnel).
const onColumnFilterInput = (colId: any, evt: any) => {
  const value = evt && evt.target ? evt.target.value : '';
  setColumnFilter(colId, value);
};
// The live global filter value (bound to the search <input>, value-driven NOT eval'd).
const globalFilterValue = () => {
  const v = currentState().globalFilter;
  return v != null ? v : '';
};
// ── Pagination chrome ────────────────────────────────────────────────────────────
// Read the live pagination state off table-core (post-mount) with a currentState()
// fallback (pre-mount / SSR). All string-safe (no bound booleans).
const pageIndex = () => {
  if (tick() >= 0 && table) return table.getState().pagination.pageIndex;
  const p = currentState().pagination;
  return p && p.pageIndex != null ? p.pageIndex : 0;
};
const pageSize = () => {
  if (tick() >= 0 && table) return table.getState().pagination.pageSize;
  const p = currentState().pagination;
  return p && p.pageSize != null ? p.pageSize : 10;
};
// Renamed from `pageCount` → `displayPageCount`: `pageCount` is now a public prop
// (server-side manual pagination), and a same-named top-level helper collides with the
// destructured prop on Svelte and the @Input/@property class field on Angular/Lit. This
// reader is internal (drives the "Page X of Y" chrome) and reads table-core's live
// getPageCount(), which now reflects rowCount/pageCount when manual.
const displayPageCount = () => {
  if (tick() < 0 || !table) return 1;
  const c = table.getPageCount();
  return c != null && c > 0 ? c : 1;
};
const canPrevPage = () => !!(tick() >= 0 && table && table.getCanPreviousPage());
const canNextPage = () => !!(tick() >= 0 && table && table.getCanNextPage());
const onPrevPage = () => {
  if (table) table.previousPage();
};
const onNextPage = () => {
  if (table) table.nextPage();
};
const onPageSizeChange = (evt: any) => {
  if (!table) return;
  const v = evt && evt.target ? evt.target.value : '';
  const n = parseInt(v, 10);
  table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
};
// ── Row-selection chrome (req-7) ───────────────────────────────────────────────────
// Detect the auto-injected leading checkbox column by its constant id (template uses
// this to render checkbox chrome instead of an accessor value).
const isSelectColumn = (colId: any) => colId === SELECT_COL_ID;
// ── Expandable-rows template helpers (phase 50, D-04) ──────────────────────────────
// isExpanderColumn: the auto-injected leading chevron column predicate (mirrors
// isSelectColumn). rowIsExpanded / rowCanExpand read table-core row handles THROUGH the
// reactive tick (rowModelVer) so the chevron glyph + aria-expanded + the #detail r-if
// re-derive on a re-pull on the fine-grained targets (Solid/Lit) — same discipline as
// visibleCellsFor. `!!`-coerced so a bound aria-expanded emits an UNWRAPPED boolean (the
// listbox aria lesson — never a rozieAttr string → TS2322 on React/Solid).
const isExpanderColumn = (colId: any) => colId === EXPANDER_COL_ID;
// rowCanExpand gates ONLY the leading expander-column detail chevron. Group-header rows
// are excluded (`!getIsGrouped`): with `expandable` + grouping, getRowCanExpand returns
// `() => true` for EVERY flattened row, so without this a group header rendered TWO
// chevrons — the group-toggle in its grouped cell AND a redundant detail chevron in the
// leading column (both fire onToggleExpand on the shared expanded state). A group row's
// expand affordance is the group-toggle; the leading-column chevron is detail-only.
const rowCanExpand = (row: any) => !!(tick() >= 0 && row && row.getCanExpand && row.getCanExpand() && !(row.getIsGrouped && row.getIsGrouped()));
const rowIsExpanded = (row: any) => !!(tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
// rowShowsDetail: the #detail <tr> renders ONLY in #detail mode (no getSubRows) when the
// row is expanded AND is NOT a group-header row. With getSubRows the children arrive as
// ordinary depth-indented rows in $data.rows (table-core flattens) — NO additive detail
// row, NO nested r-for (Pitfall 1). The `!rowIsGrouped` guard is load-bearing: grouping
// and detail-expand share table-core's SINGLE `expanded` state, so a group-header row is
// `getIsExpanded()===true` the moment its group opens; without this guard that expanded
// group row also satisfied `getSubRows==null && rowIsExpanded`, painting a spurious
// #detail panel under every opened group (the group-toggle looked "linked" to detail).
const rowShowsDetail = (row: any) => getSubRows == null && !rowIsGrouped(row) && rowIsExpanded(row);
// Toggle a row's expanded state through table-core so onExpandedChange → writeExpanded
// fires exactly one expanded-change. Used by the chevron @click (native <button> handles
// Enter/Space → click, so NO explicit @keydown.enter/.space — that would DOUBLE-toggle on
// a real button; the grid @keydown is inert in 'table' mode, isGrid()-gated).
const onToggleExpand = (row: any, evt: any) => {
  if (!row || !row.toggleExpanded) return;
  // Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
  // expanded-state re-render. This guards a focus-drop that USED to happen on Solid: when the
  // cell loop reconciled by reference (bare <For>), table-core's fresh cell instances each
  // pull rebuilt the expander <td>/<button> (the <tr> persisted but its cells were rebuilt),
  // dropping DOM focus to <body> and breaking keyboard activation (Enter/Space on the focused
  // expander left nothing focused). Since the emitter now emits `<Key>` for the
  // `:key="cellCtx.id"` cell loop, Solid keeps the cell node on a stable key too — so the
  // expander is no longer recreated and this re-focus is now a defensive no-op on ALL six
  // targets (re-focusing the SAME kept element — the focusActiveCell imperative-refocus
  // precedent). Kept for safety; it costs nothing when the node is unchanged. The rAF defers
  // past the synchronous reactive flush so any (re)created node exists first.
  const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
  row.toggleExpanded();
  if (ownerRow && typeof requestAnimationFrame === 'function') {
    requestAnimationFrame(() => {
      const btn = ownerRow.querySelector('[data-expander]');
      if (btn) btn.focus();
    });
  }
};
// bodyCellStyle: the non-virtual <td> inline style — pinStyle PLUS a depth-proportional
// left pad on the EXPANDER cell so nested getSubRows children visibly indent (row.depth).
// Only the expander column indents (the tree affordance lives in its dedicated column);
// data columns stay grid-aligned. depth 0 → unchanged (byte-identical-off).
const bodyCellStyle = (row: any, colId: any) => {
  const base = pinStyle(colId);
  if (isExpanderColumn(colId) && row && row.depth) {
    // Only the expander column indents (the tree affordance lives here).
    const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
    return base ? base + pad : pad;
  }
  return base;
};
// ── Grouping template helpers (phase 50 reqs 4-7, D-04/D-05) ───────────────────────────
// Group-header rows ARE expandable rows: table-core's getGroupedRowModel FLATTENS them into
// $data.rows carrying getIsGrouped()/subRows, so they ride the SAME D-04 <template r-for> seam
// (no parallel render path, no nested r-for). These predicates read through the reactive tick
// (rowModelVer) so the group chrome + collapse state re-derive on a re-pull on the fine-grained
// targets (Solid/Lit) — same discipline as rowIsExpanded/visibleCellsFor. `!!`-coerced (the
// listbox aria lesson — a bound boolean must be UNWRAPPED, never a rozieAttr string → TS2322).
// rowIsGrouped: this flattened row is a group-header row.
const rowIsGrouped = (row: any) => !!(tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
// groupingActive: grouping is currently engaged (a non-empty ordered key list). Drives the
// data-group-leaf marker so it is ABSENT when ungrouped (byte-identical-off, req-10).
const groupingActive = () => tick() >= 0 && (currentState().grouping || []).length > 0;
// cellIsGrouped / cellIsAggregated: per-CELL roles on a group-header row. The grouped cell shows
// the group key + toggle + count; an aggregated cell shows the rolled-up value through the
// EXISTING #cell slot (cell.getValue()) — NO new aggregatedCell template (RESEARCH State of the
// Art). A placeholder cell (neither) falls through to the #cell r-else and renders its empty value.
const cellIsGrouped = (cellCtx: any) => !!(tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
const cellIsAggregated = (cellCtx: any) => !!(tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
// cellIsPlaceholder: a PLACEHOLDER cell on a group-header row — a non-grouped, non-aggregated
// cell that table-core fills with the FIRST leaf row's value (cell.getValue() leaks e.g.
// "Services"/"Edsger Dijkstra" onto the group line). Renders BLANK via a dedicated empty
// template branch so the leaked leaf value never paints. Tick-gated exactly like cellIsGrouped
// so the group chrome re-derives on a re-pull on the fine-grained targets (Solid/Lit).
const cellIsPlaceholder = (cellCtx: any) => !!(tick() >= 0 && cellCtx && cellCtx.getIsPlaceholder && cellCtx.getIsPlaceholder());
// groupSubRowCount: the number of underlying LEAF RECORDS under a group-header row (the count
// shown in the header, e.g. "North (40)"). row.subRows is the IMMEDIATE members — for MULTI-LEVEL
// grouping those are sub-GROUPS, not records, so "North" with 2 categories / 40 records would show
// "North (2)". getLeafRows() returns all leaf descendants (the actual record count); keep the
// subRows fallback for safety. Single-level grouping is unchanged (getLeafRows == subRows when the
// children are already leaves).
const groupSubRowCount = (row: any) => row && row.getLeafRows ? row.getLeafRows().length : row && row.subRows ? row.subRows.length : 0;
// groupingKeys: the live ordered grouping array — slot prop for the headless #groupBar + the
// default styled-token reflection. Reads currentState() ($props.grouping ?? $data.groupingDefault),
// both reactive sources, so the bar re-renders on a grouping change across all six targets.
const groupingKeys = () => currentState().grouping || [];
// groupableColumns: the data columns OFFERED to the headless #groupBar (those whose Column/config
// `groupable` is not false) — `[{ id, label }]`. Excludes the chrome columns (select/expander are
// not in columnDefs()). The consumer builds any bar/drag UI from this; the component ships none.
const groupableColumns = () => {
  const out = [];
  const defs = columnDefs();
  for (const d of defs as any) {
    if (!d || d.groupable === false) continue;
    out.push({
      id: d.id,
      label: d.header != null ? d.header : d.id
    });
  }
  return out;
};
// Plain stop-propagation handler (used in place of the `@click.stop` bare modifier —
// a bare `.stop` with no handler hoists to `_guardedUndefined` → `this.undefined($event)`
// on Angular inside an `@for`, F5). Calling an explicit handler is uniform on all six.
const stopEvent = (evt: any) => {
  if (evt && evt.stopPropagation) evt.stopPropagation();
};
// select-all header state (D-06: scopes to all filtered rows = TanStack default).
// `!!`-coerced booleans (the listbox aria lesson — never a bound rozieAttr string).
const isAllRowsSelected = () => !!(tick() >= 0 && table && table.getIsAllRowsSelected());
const isSomeRowsSelected = () => !!(tick() >= 0 && table && table.getIsSomeRowsSelected());
const onToggleAllRows = (evt: any) => {
  if (!table) return;
  table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
};
// per-row checkbox state + toggle (checkbox-only, D-05 — row body does NOT select).
// Read selection from the LIVE controlled state (currentState().rowSelection keyed by
// row.id) — NOT row.getIsSelected(). The latter reads table-core's row model, which
// only reflects a selection AFTER the re-feed watch pushes the new `state` + re-pulls
// (two reactive cycles on React). The controlled-state read updates in the SAME cycle
// as the write funnel, so the controlled <input :checked> reflects the toggle without
// the row-model-re-pull latency — the React controlled-checkbox revert that left
// `.check()` seeing no state change (F6). row.getIsSelected() is the fallback.
const rowIsSelected = (row: any) => {
  if (!row) return false;
  const id = row.id;
  const sel = currentState().rowSelection || {};
  if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
  return !!(row.getIsSelected && row.getIsSelected());
};
const onToggleRow = (row: any, evt: any) => {
  if (!row || !row.toggleSelected) return;
  row.toggleSelected(!!(evt && evt.target && evt.target.checked));
};
// ── Header ⋯ menu chrome (phase 72, D-06) ──────────────────────────────────────────
// onHideColumn: the ⋯ menu's "Hide column" item. Reuses the SAME columnVisibility write
// funnel as the existing colvis toggle (onToggleVisibility in columnChrome.rzts) — just
// forced to `false` rather than toggled, since hide is a one-directional action from the
// menu (the colvis panel is the re-show path). Event stopped HERE (not a `.stop`
// modifier) — same Angular @for-hoist hazard as onPinColumn/onResizeStart (F5).
const onHideColumn = (colId: any, evt: any) => {
  if (evt && evt.stopPropagation) evt.stopPropagation();
  if (!table) return;
  const col = table.getColumn(colId);
  if (col && col.toggleVisibility) col.toggleVisibility(false);
};
// hasAnyFilterableColumn: gates the dedicated filter row (72-05) — true when at least one
// leaf column (excluding the select/expander chrome columns, already excluded by
// allLeafColumns) is filterable. Reactive via allLeafColumns()'s own tick() gate.
const hasAnyFilterableColumn = () => {
  const cols = allLeafColumns();
  for (const c of cols as any) {
    if (c && columnIsFilterable(c.id)) return true;
  }
  return false;
};
// `indeterminate` is a DOM PROPERTY, not an HTML attribute — a `:indeterminate="…"`
// binding only takes effect on Vue (which binds known DOM props); on
// React/Solid/Angular/Lit/Svelte it lands as an inert attribute and `el.indeterminate`
// stays false. So set it IMPERATIVELY: query the select-all checkbox off the component
// root ($el — post-mount safe) and assign the property. Called from refreshRowModel
// (every selection change re-pulls the row model) so it stays in lockstep with the
// table-core selection state. The select-all box is NOT re-created by a selection
// change (only its checked attr flips), so the live element persists.
// `box` is aliased through a module-scope null-let (typeNeutralize → `any`) so the
// strict bundled-leaf tsc accepts `.indeterminate` (querySelector returns `Element`,
// which has no `indeterminate` — it is an HTMLInputElement DOM property). Same idiom
// as Column's `let reg = null; reg = $inject(...)`.
let selectAllBox: any = null;
const syncIndeterminate = () => {
  if (!__rozieRoot || !__rozieRoot!.querySelector) return;
  selectAllBox = __rozieRoot!.querySelector('.rdt-select-all');
  if (selectAllBox) selectAllBox.indeterminate = isSomeRowsSelected() && !isAllRowsSelected();
};

// The registry API handed to <Column> children (whole-object-replace — T-48-PP guard).
// Imperative handle (consumer-callable). Each verb is a PRE-DECLARED top-level
// `const` (the canonical $expose contract — `$expose({ name })` references a
// binding ALREADY in scope; an INLINE-defined verb `$expose({ name: () => {} })`
// is dropped on ALL SIX targets, only the by-reference key survives → a
// runtime ReferenceError at `defineExpose`/`useImperativeHandle`). Sorting verbs +
// a fresh column-def readout, selection, pagination, and column-management verbs.
export const sortColumn = (colId: any, desc: any) => {
  if (table) table.getColumn(colId) && table.getColumn(colId).toggleSorting(desc, false);
};
export const clearSorting = () => {
  if (table) table.resetSorting(true);
};
export const getColumnDefs = () => columnDefs();
// selection verbs (req-7) — drive table-core so the onRowSelectionChange funnel
// emits the fresh state + selection-change.
export const toggleAllRows = (value: any) => {
  if (table) table.toggleAllRowsSelected(value);
};
export const clearSelection = () => {
  if (table) table.resetRowSelection(true);
};
export const getSelectedRows = () => table ? table.getSelectedRowModel().rows.map((r: any) => r.original) : [];
// pagination verbs.
export const setPage = (idx: any) => {
  if (table) table.setPageIndex(idx);
};
export const setRowsPerPage = (size: any) => {
  if (table) table.setPageSize(size);
};
// column-management verbs (req-8/9/10/11) — drive table-core so the funnels fire.
export const toggleColumnVisibility = (colId: any) => {
  if (table) {
    const c = table.getColumn(colId);
    if (c && c.toggleVisibility) c.toggleVisibility();
  }
};
// NOT `setColumnOrder`: a verb named `set<ModelProp>` collides with React's
// auto-generated `setColumnOrder` useState setter for the `columnOrder` model
// prop, and an $expose verb is PUBLIC-CONTRACT-PROTECTED from the React
// deconfliction rename (ROZ524 — the rename target is the verb, which is
// off-limits). So the public verb is `applyColumnOrder` (semantically: apply a
// new column order). The other set* verbs (setPage/setRowsPerPage) do NOT match
// any model prop's setter, so they are collision-free.
export const applyColumnOrder = (order: any) => {
  if (table) table.setColumnOrder(order);
};
export const resetColumnSizing = () => {
  if (table) table.resetColumnSizing(true);
};
// pinColumn: the verb that drives column.pin; distinct from the template handler
// onPinColumn (no shadow — the deferred-items finding #4 collision check).
export const pinColumn = (colId: any, side: any) => {
  if (table) {
    const c = table.getColumn(colId);
    if (c && c.pin) c.pin(side);
  }
};
// getRowIndexRelativeToPage(absRow?) — C1 (phase 63 wave-6) converter: an ABSOLUTE display-order
// index (the focusCell/getActiveCell/activecell-change space) → the PAGE-RELATIVE index. Mirrors
// MUI getRowIndexRelativeToVisibleRows. With NO argument it converts the CURRENT active cell
// (toAbsRow($data.activeRow) - pageRowOffset() collapses to $data.activeRow). In virtual mode
// there is no page (windowing replaces pagination) → the windowed model IS the full model, so it
// returns the absolute index unchanged. Collision-safe: no *-change event, prop, React auto-setter,
// or inherited Lit DOM method named getRowIndexRelativeToPage (ROZ121/124/137 clear).
export const getRowIndexRelativeToPage = (absRow: any) => {
  const abs = absRow == null ? toAbsRow(activeRow) : Math.trunc(Number(absRow)) || 0;
  if (virtual) return abs;
  return abs - pageRowOffset();
};
// C3 (phase 63 wave-9) — the PUBLIC Cut verb: copy the current cell range to the clipboard then
// clear the source cells through the write-funnel (one writeData), delegating to cutRange (the
// clipboardFill funnel that also backs the Ctrl+X shortcut). Reads the persisted $data range /
// active cell, so it cuts the current selection even when the call arrives off a control that
// moved DOM focus off the grid. Collision-safe: no `cut` event / model prop / React auto-setter /
// inherited Lit DOM method named `cut` (ROZ121/124/137 clear) — `cut` is not on HTMLElement.
export const cut = () => cutRange();

// indirection. This file stays the seam for verbs that need a rename/adapter, not a mandatory
// ══ Grid interaction mode (phase 49) — STATE + STRUCTURE only ═══════════════════════════
// This plan (02) establishes the gated ARIA roles, the roving single-tab-stop tabindex,
// the active-cell index-pair state, the data-* cell markers, and the SINGLE
// focusActiveCell() seam. Plan 03 adds the keydown navigation math, the $expose verbs
// (focusCell/getActiveCell/clearActiveCell), and the activecell-change event ON TOP.

// interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
// identical to phase 48 (roles fall back to the literals, tabindex drops).
const isGrid = () => interactionMode === 'grid';
// Role computeds (RESEARCH Pattern 4). The 'table' branch returns the EXACT phase-48
// literal so 'table'-mode DOM is unchanged. Header cells keep 'columnheader' and rows keep
// 'row'/'rowgroup' in BOTH modes (APG grid) — those stay static literals in the template.
const tableRole = () => isGrid() ? 'grid' : 'table';
const cellRole = () => isGrid() ? 'gridcell' : 'cell';
// ── Cell addressing helpers (plain fns — no $computed alias trap; safe in template) ────
// rowIndexOf: a body row's index over the visible model ($data.rows). tick() puts the read
// in the fine-grained reactive scope (Solid/Lit) so the data-row marker re-derives on a
// re-pull (reorder/filter) — matching visibleCellsFor's discipline.
const rowIndexOf = (row: any) => tick() >= 0 ? (rows || []).indexOf(row) : -1;
// colIndexOf: a body cell's position in its row's visible cell list.
const colIndexOf = (row: any, cellCtx: any) => tick() >= 0 ? visibleCellsFor(row).indexOf(cellCtx) : -1;
// headerColIndexOf: a header cell's position in its header group's leaf headers.
const headerColIndexOf = (hg: any, header: any) => (hg && hg.headers ? hg.headers : []).indexOf(header);
// ── C1 (phase 63 wave-6) absolute-index bridge ─────────────────────────────────────────
// The PUBLIC active-cell rowIndex (focusCell/getActiveCell/activecell-change) is the ABSOLUTE
// display-order position in getPrePaginationRowModel().rows (filter+sort+expand applied, BEFORE
// pagination/windowing), in BOTH paginated and virtual modes — reversing the old page-relative
// paginated meaning. INTERNALLY $data.activeRow stays PAGE-RELATIVE in the non-virtual paginated
// body (the data-row markers + the nav math index the page slice) and FULL-MODEL in virtual mode
// (the wr.vi.index space). pageRowOffset() bridges the two so the API speaks one absolute language.
//   - virtual mode: activeRow is already the full pre-pagination index → offset 0.
//   - non-virtual:  activeRow is page-relative → offset = pageIndex * pageSize.
// isGrid()-gated (the active-cell API is grid-only); pageIndex()/pageSize() read live table-core
// state through the reactive tick (filterPaginationRowChrome), so this re-derives on a page change.
const pageRowOffset = () => {
  if (!isGrid() || virtual) return 0;
  return pageIndex() * pageSize();
};
// page-relative active row → absolute (display-order) index.
const toAbsRow = (localRow: any) => localRow + pageRowOffset();
// A body row's ABSOLUTE display-order index = its page-relative index + the page offset. Drives
// aria-rowindex on the non-virtual paginated body (B27); the virtual path uses wr.vi.index
// directly (already absolute). Reactive via rowIndexOf's tick().
// Total filtered+sorted PRE-pagination row count — the clamp bound for an absolute focusCell.
// In virtual mode $data.rows IS the full pre-pagination model (bodyRowCount suffices); in the
// non-virtual paginated body $data.rows is only the page slice, so read the live model.
const prePaginationRowCount = () => {
  if (!table || virtual) return bodyRowCount();
  const pm = table.getPrePaginationRowModel();
  return pm && pm.rows ? pm.rows.length : bodyRowCount();
};
// Roving tabindex (RESEARCH Code Examples). Reads ONLY reactive $data (ROZ123-safe,
// fine-grained-reactive). Returns null in 'table' mode → the bound numeric attribute
// DROPS entirely (IN-01: on React via the `cellTabindex(...) ?? undefined` numeric-attr
// emitter path landed in 4bec3b8e — NOT rozieAttr, which would string-widen tabIndex and
// TS2322; the other five targets drop it via their own nullish-attr handling), keeping
// 'table'-mode DOM clean. rowKey is the literal
// '__header' for header cells or the String(bodyRowIndex) for body cells, so the active
// header state (activeIsHeader) is addressable through the same computed.
const cellTabindex = (rowKey: any, colIndex: any, level = null) => {
  if (!isGrid()) return null;
  // B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
  // the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
  // tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
  if (bodyRowCount() === 0) {
    return rowKey === '__header' && colIndex === 0 && level === headerLeafLevel() ? 0 : -1;
  }
  // B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
  // grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
  // lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
  // tab-stops (the roving invariant broke under grouped headers).
  if (activeIsHeader) {
    if (rowKey !== '__header') return -1;
    return colIndex === activeColIndex && level === activeHeaderLevel ? 0 : -1;
  }
  const isActive = rowKey === String(activeRow) && colIndex === activeColIndex;
  return isActive ? 0 : -1;
};
// ── Active-cell ring predicate (grid pointer §1, 260708-ni6) ───────────────────────────
// isActiveCell mirrors cellTabindex's ACTIVE branch (the same (rowKey, colIndex, level)
// address tuples the roving tabindex uses) but returns a BOOLEAN for the `.rdt-cell-active`
// :class binding, and is STATE-DRIVEN — so the ring shows identically on click AND keyboard
// (independent of :focus-visible, which browsers gate off for a mouse-focused non-text <td>).
// It DELIBERATELY omits cellTabindex's B6 empty-grid / header-fallback branch: the ring must
// NOT light on an empty grid's synthetic tab-stop (there is no real active cell there). Reads
// ONLY reactive $data (ROZ123-safe, fine-grained). Returns false in 'table' mode so table-mode
// markup is byte-behaviorally unchanged. Header cells are active only while activeIsHeader is
// true (addressed by BOTH colIndex and level — a grouped multi-level header carries exactly one
// ring); body cells only while activeIsHeader is false.
const isActiveCell = (rowKey: any, colIndex: any, level = null) => {
  if (!isGrid()) return false;
  if (activeIsHeader) {
    if (rowKey !== '__header') return false;
    return colIndex === activeColIndex && level === activeHeaderLevel;
  }
  if (rowKey === '__header') return false;
  return rowKey === String(activeRow) && colIndex === activeColIndex;
};
// ── The focus SEAM (RESEARCH Pattern 1 + 3, req-6) ─────────────────────────────────────
// resolveCellEl: index pair → DOM element, via a data-* attribute query off the stable
// post-mount root. Uniform on all six, shadow-safe (the query runs from inside the
// component's own scope). rowKey is the literal '__header' or a String(integer index) and
// colIndex is an integer — NO consumer string is interpolated into the selector (T-49-01).
const resolveCellEl = (rowKey: any, colIndex: any, level = null) => {
  if (!gridRoot) return null;
  // B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
  // same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
  // integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
  // match (the parent) when the leaf is meant. level is an integer (NO consumer string is
  // interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
  // byte-unchanged.
  let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
  if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
  return gridRoot.querySelector(sel);
};
// focusActiveCell: THE single DOM-focus-resolution path (req-6). Every focus change —
// the D-04 entry cell here, and (plan 03) arrow nav / focusCell() / the data-change clamp —
// routes through this one function, so a verifier can point to it and phase 53 windowing
// hooks it without a rewrite. Accepts OPTIONAL explicit (nextRow,nextCol) so callers can
// pass FRESH post-write locals (React ROZ138 / Angular signal async — pinned by plan 01);
// falls back to $data when none passed. NEVER stores a DOM node (index-only state).
// 260618-ao9 — params carry explicit `= null` defaults so the cross-target
// emitters type them OPTIONAL (untyped params lower to REQUIRED `any`, making the
// 2-arg `focusActiveCell(r, c)` call sites a TS2554 on React/Solid/Lit — a
// pre-existing regression from the d7166c5e header-crossing `nextIsHeader` add).
// The `= null` default reproduces the documented "falls back to $data when
// omitted" contract: an omitted arg arrives as `null`, and the body's `== null`
// checks already route those to the live `$data` value — behavior-identical.
const focusActiveCell = (nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) => {
  if (!isGrid() || !gridRoot) return;
  // #9 focus-intent epoch: focusActiveCell is THE single seam every keyboard nav re-asserts
  // focus through, so it establishes a fresh "where focus should be" on every call — bump the
  // epoch here (BEFORE arming the virtual-scroll focusWhenReady poll below). A SUBSEQUENT
  // focusActiveCell (the next user nav) bumps again → any pending focusWhenReady captured the
  // OLD value → aborts instead of yanking focus back. The poll captures the POST-bump value so
  // a lone scroll-to-focus with no later nav still lands (epoch stable across its own frames).
  focusIntentEpoch = focusIntentEpoch + 1;
  const r = nextRow == null ? activeRow : nextRow;
  const c = nextCol == null ? activeColIndex : nextCol;
  // B12: thread the FRESH post-write header level (the grouped-header analog of the
  // nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
  // level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
  const lvl = nextLevel == null ? activeHeaderLevel : nextLevel;
  // Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
  // crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
  // Angular's signal write are async within one handler — re-reading $data.activeIsHeader
  // here returns the PRE-write value, resolving focus to the BODY cell instead of the
  // header. Callers pass the fresh isHeader local; falls back to $data when omitted.
  const header = nextIsHeader == null ? activeIsHeader : nextIsHeader;
  // ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
  // rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
  // double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
  // in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
  // The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
  // while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
  if (virtual && virtualizer && !header && rowIsOutsideWindow(r)) {
    virtualizer.scrollToIndex(r, {
      align: 'center'
    });
    // Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
    // bump → the framework commits the scrolled-in row. On React that commit is async (setState →
    // reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
    // fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
    // React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
    // fast-committing targets resolve on the first attempt (behavior unchanged), React retries
    // across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
    // cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
    let focusAttempts = 0;
    // #9: capture the epoch AFTER this call's own bump (above) so the poll never aborts itself
    // (its captured value equals the current epoch). A LATER focusActiveCell / focusCell /
    // active-cell-moving focusin bumps the epoch → the check below aborts this stale poll.
    const myEpoch = focusIntentEpoch;
    const focusWhenReady = () => {
      // A newer focus intent superseded this poll — abort WITHOUT focusing (the user has since
      // navigated / clicked elsewhere; re-focusing this off-window target would yank focus back).
      if (focusIntentEpoch !== myEpoch) return;
      const el = resolveCellEl(String(r), c);
      if (el) {
        el.focus();
        return;
      }
      focusAttempts = focusAttempts + 1;
      if (focusAttempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
    return;
  }
  const rowKey = header ? '__header' : String(r);
  const el = resolveCellEl(rowKey, c, header ? lvl : null);
  if (el) el.focus();
};
// ══ Grid keyboard navigation (phase 49 plan 03 — RESEARCH Pattern 5 + the delegated handler) ═══
// The nav model is plain ARRAY-INDEX MATH over the VISIBLE model. table-core has already
// done the hard part: $data.rows (body) and $data.headerGroups (header) hold the visible,
// reordered, pinned cell set (row.getVisibleCells() / getHeaderGroups()) — hidden columns
// are ALREADY ABSENT, reorder/pinning is ALREADY REFLECTED (REQ-7). There is NO separate
// "compute visible order" step. Every index is clamped to [0,max] so an out-of-range key
// never throws or builds an injection-shaped selector (Security V5 / T-49-03).

// IN-01: aria-rowcount for the NON-VIRTUAL table. The virtual table binds $data.rows.length
// (the full pre-pagination model). For the non-virtual path $data.rows is the PAGINATED slice,
// so report the FILTERED (pre-pagination) total instead — the count AT users need to know "row N
// of TOTAL". Falls back to $data.rows.length pre-mount (table is null until $onMount).
// NB the helper is named `totalRowCount`, NOT `ariaRowCount`: `ariaRowCount` is an inherited
// HTMLElement ARIA-reflected property (`Element.ariaRowCount: string`), so a same-named method
// becomes a class field that shadows it on Lit → TS2416 cascades to EVERY @property decorator
// (the `valueOf`/`nodeType` inherited-DOM-member collision class, authoring playbook §6).
const totalRowCount = () => {
  if (!table) return (rows || []).length;
  const fm = table.getFilteredRowModel();
  return fm && fm.rows ? fm.rows.length : (rows || []).length;
};
// ── A11y row bookkeeping (#13): consistent aria-rowindex / aria-rowcount ──────────────
// WAI-ARIA: when aria-rowcount is set on the grid/table, EVERY row (header rows + body rows)
// must carry an aria-rowindex, and aria-rowcount must equal the total number of rows INCLUDING
// the header rows. Before this fix aria-rowcount was set unconditionally to totalRowCount() but
// aria-rowindex was grid-only — so a paginated 'table'-mode grid advertised e.g. rowcount=100
// while its 10 visible rows carried NO index (SR announced "row 1..10 of 100" on the LAST page).
//   headerRowCount = the columnheader rows ($data.headerGroups — a grouped/multi-level header is
//     >1; the role="presentation" filter row is NOT a row and is excluded).
//   gridAriaRowCount = header rows + the FILTERED pre-pagination data total → equals the largest
//     aria-rowindex any body row carries, so count and indices are always mutually consistent.
// NB the helpers are gridAriaRowCount / bodyAriaRowIndex, NOT ariaRowCount / ariaRowIndex: the
// latter collide with the inherited HTMLElement.ariaRowCount / .ariaRowIndex reflected properties
// on Lit (TS2416 — the same inherited-DOM-member collision class as totalRowCount's rename note).
const headerRowCount = () => (headerGroups || []).length;
const gridAriaRowCount = () => headerRowCount() + totalRowCount();
// Page offset that is MODE-INDEPENDENT (works in BOTH 'table' and 'grid' mode), unlike
// pageRowOffset() which is isGrid()-gated for the active-cell API. In the non-virtual body
// $data.rows is only the page slice, so a data row's ABSOLUTE index = its page-relative
// rowIndexOf + this offset. Virtual mode never reaches here (that body uses wr.vi.index).
const ariaPageOffset = () => table ? pageIndex() * pageSize() : 0;
// A non-virtual body row's 1-based aria-rowindex: the header rows come first (headerRowCount),
// then the absolute (page-aware) 0-based data index, +1 to 1-base it. Present in BOTH modes so
// it is always consistent with gridAriaRowCount. The virtual body binds
// `headerRowCount() + wr.vi.index + 1` inline (wr.vi.index is already the absolute full-model index).
const bodyAriaRowIndex = (row: any) => headerRowCount() + rowIndexOf(row) + ariaPageOffset() + 1;
// Column count = the visible cell list length (uniform header+body in a flat grid). Reads
// $data.rows (reactive) so it is fine-grained-correct on Solid/Lit; falls back to the
// header leaf count when there are no body rows.
const visibleColCount = () => {
  // NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
  // state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
  // self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
  const rowList = rows || [];
  if (rowList.length) return rowList[0].getVisibleCells().length;
  const hg = headerGroups || [];
  return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
};
const bodyRowCount = () => (rows || []).length;
const clamp = (v: any, lo: any, hi: any) => v < lo ? lo : v > hi ? hi : v;
// ── Multi-level (grouped) header addressing (B12) ──────────────────────────────────────
// $data.headerGroups is ordered top→bottom; the LEAF header row (the one adjacent to the
// body) is the LAST group. The roving active-header state carries activeHeaderLevel (the
// group index) alongside activeColIndex (the index within THAT level's headers) so the
// single-tab-stop invariant + ArrowUp parent-resolution span every header level — a flat
// grid has one level (leafLevel 0), so the table-mode/flat path is unchanged.
const headerLeafLevel = () => {
  const hg = headerGroups || [];
  return hg.length ? hg.length - 1 : 0;
};
// #10: the number of header cells AT a given level. A grouped PARENT level may have FEWER
// headers than there are leaf columns (one parent spans several leaves), so horizontal nav on a
// non-leaf header must clamp against THIS count — not visibleColCount() (the leaf-column count),
// which would let ArrowRight/End overrun into a phantom (null) cell → focus dropped to <body>.
// Degenerate cases (no headerGroups, level out of range) fall back to visibleColCount() so the
// clamp is never negative or NaN. The LEAF level's count equals visibleColCount() (one header per
// visible leaf column), so leaf-header + body horizontal nav is byte-behaviorally unchanged.
const headerCountAtLevel = (level: any) => {
  const hg = headerGroups || [];
  if (!hg.length) return visibleColCount();
  const grp = level >= 0 && level < hg.length ? hg[level] : null;
  if (!grp || !grp.headers) return visibleColCount();
  return grp.headers.length;
};
const headerAt = (level: any, colIndex: any) => {
  const hg = headerGroups || [];
  const grp = hg[level];
  if (!grp || !grp.headers) return null;
  return grp.headers[colIndex] || null;
};
// ArrowUp from a (level, colIndex) leaf/child header → the index of its PARENT header in the
// level above (the parent column that spans it, via table-core header.column.parent). -1 when
// there is no real parent (already at the top, or a placeholder with no group) → the caller
// keeps the active header where it is.
const parentHeaderColIndex = (level: any, colIndex: any) => {
  if (level <= 0) return -1;
  const h = headerAt(level, colIndex);
  if (!h || !h.column || !h.column.parent) return -1;
  const parentId = h.column.parent.id;
  const hg = headerGroups || [];
  const pg = hg[level - 1];
  if (!pg || !pg.headers) return -1;
  for (let i = 0; i < pg.headers.length; i++) {
    const ph = pg.headers[i];
    if (ph && ph.column && ph.column.id === parentId) return i;
  }
  return -1;
};
// ArrowDown from a (level, colIndex) GROUP header → the index of its FIRST child header in the
// level below (via table-core column.columns). -1 when the header has no child columns (a leaf)
// → the caller drops into the body instead.
const firstChildHeaderColIndex = (level: any, colIndex: any) => {
  const h = headerAt(level, colIndex);
  if (!h || !h.column) return -1;
  const kids = h.column.columns || [];
  if (!kids.length) return -1;
  const childId = kids[0].id;
  const hg = headerGroups || [];
  const cg = hg[level + 1];
  if (!cg || !cg.headers) return -1;
  for (let i = 0; i < cg.headers.length; i++) {
    const ch = cg.headers[i];
    if (ch && ch.column && ch.column.id === childId) return i;
  }
  return -1;
};
// ── Nav helpers: compute the NEXT indices into LOCAL consts, write $data from them, and
// RETURN the fresh locals so the caller threads the SAME values into BOTH focusActiveCell
// AND the activecell-change emit. NEVER re-read $data.activeRow/activeColIndex after the
// write (React setState is async — ROZ138 — the re-read binds the PRE-write value; Angular
// signal writes are async too — both proven live by plan 01's probe). ──────────────────────

// ArrowRight/Left — clamp colIndex over [0, visibleColCount()-1] (no wrap; hidden cols
// already excluded from the visible list per REQ-7).
const moveCol = (delta: any) => {
  // #10: when a grouped PARENT header is active, clamp against the header count AT THE ACTIVE
  // LEVEL (which may be fewer than the leaf-column count) so ArrowRight never overruns onto a
  // phantom cell past that level's headers. Body cells + the leaf header level keep visibleColCount().
  const count = activeIsHeader ? headerCountAtLevel(activeHeaderLevel) : visibleColCount();
  const max = count - 1;
  const nextCol = clamp(activeColIndex + delta, 0, max < 0 ? 0 : max);
  activeColIndex = nextCol;
  return nextCol;
};
// ArrowUp/Down + PageUp/Down — cross the header boundary and clamp at body edges (no
// page-cross per D-06/REQ-7). Returns { row, isHeader } fresh locals.
//  - From the header, ArrowDown (delta>0) drops into body row 0 (activeIsHeader=false).
//  - From body row 0, ArrowUp (delta<0) crosses into the header (activeIsHeader=true).
//  - PageUp/Down jump by ±GRID_PAGE_STEP, clamped to the current page bounds (no cross).
const moveRow = (delta: any) => {
  const lastRow = bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const leafLevel = headerLeafLevel();
  if (activeIsHeader) {
    if (delta > 0) {
      // B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
      // level down); from the LEAF header level, drop into the body (row 0). A header-level
      // move re-targets activeColIndex (parent↔child column indices differ), so the fresh
      // col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
      if (activeHeaderLevel < leafLevel) {
        const childCol = firstChildHeaderColIndex(activeHeaderLevel, activeColIndex);
        if (childCol >= 0) {
          const nextLevel = activeHeaderLevel + 1;
          activeHeaderLevel = nextLevel;
          activeColIndex = childCol;
          return {
            row: activeRow,
            col: childCol,
            isHeader: true,
            level: nextLevel
          };
        }
      }
      // At the leaf header: an empty grid has no body to drop into → stay put.
      if (bodyRowCount() === 0) return {
        row: activeRow,
        col: activeColIndex,
        isHeader: true,
        level: activeHeaderLevel
      };
      // B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
      // (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
      // page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
      // (delta-1 = 0); clamped to the page-last body row.
      const landRow = clamp(delta - 1, 0, maxRow);
      activeIsHeader = false;
      activeRow = landRow;
      return {
        row: landRow,
        col: activeColIndex,
        isHeader: false,
        level: 0
      };
    }
    // B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
    // spans the active column; at the top level (or no real parent) stay put. The parent col
    // index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
    const parentCol = parentHeaderColIndex(activeHeaderLevel, activeColIndex);
    if (parentCol >= 0) {
      const nextLevel = activeHeaderLevel - 1;
      activeHeaderLevel = nextLevel;
      activeColIndex = parentCol;
      return {
        row: activeRow,
        col: parentCol,
        isHeader: true,
        level: nextLevel
      };
    }
    return {
      row: activeRow,
      col: activeColIndex,
      isHeader: true,
      level: activeHeaderLevel
    };
  }
  // In the body: an upward move from row 0 crosses into the LEAF header level (the header row
  // adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
  // activeColIndex carries over unchanged.
  if (delta < 0 && activeRow === 0) {
    activeIsHeader = true;
    activeHeaderLevel = leafLevel;
    return {
      row: activeRow,
      col: activeColIndex,
      isHeader: true,
      level: leafLevel
    };
  }
  const nextRow = clamp(activeRow + delta, 0, maxRow);
  activeRow = nextRow;
  activeIsHeader = false;
  return {
    row: nextRow,
    col: activeColIndex,
    isHeader: false,
    level: 0
  };
};
// Home/End within the current row → col 0 / max. Returns the fresh colIndex.
const gotoColEdge = (toEnd: any) => {
  // #10: End on a grouped PARENT header lands on that level's LAST header (headerCountAtLevel-1),
  // not the leaf-column max — otherwise the ring strands on a phantom cell past the level's
  // headers. Home is index 0 either way. Body cells + the leaf header level keep visibleColCount().
  const count = activeIsHeader ? headerCountAtLevel(activeHeaderLevel) : visibleColCount();
  const max = count - 1;
  const nextCol = toEnd ? max < 0 ? 0 : max : 0;
  activeColIndex = nextCol;
  return nextCol;
};
// gotoRowEdge(toEnd): the §8 (260709-3qt) Ctrl+ArrowUp/Down vertical region-edge jump — move the
// active cell to the data-region row edge (row 0 / last body row) in the CURRENT column, mirroring
// gotoColEdge's horizontal edge jump. Body cells only (the caller gates on !activeIsHeader); always
// lands in the body (activeIsHeader=false). Returns the fresh row index for the shared focus seam.
const gotoRowEdge = (toEnd: any) => {
  const lastRow = bodyRowCount() - 1;
  const nextRow = toEnd ? lastRow < 0 ? 0 : lastRow : 0;
  activeRow = nextRow;
  activeIsHeader = false;
  return nextRow;
};
// Ctrl+Home → first body cell (0,0); Ctrl+End → last body cell (lastRow,max). Returns the
// fresh { row, col } locals. Both land in the body (activeIsHeader=false).
const gotoStart = () => {
  activeIsHeader = false;
  activeRow = 0;
  activeColIndex = 0;
  return {
    row: 0,
    col: 0
  };
};
const gotoEnd = () => {
  const lastRow = bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const max = visibleColCount() - 1;
  const maxCol = max < 0 ? 0 : max;
  activeIsHeader = false;
  activeRow = maxRow;
  activeColIndex = maxCol;
  return {
    row: maxRow,
    col: maxCol
  };
};
// Resolve the active cell element (for the in-cell trap) — uses the same data-* query as
// the focus seam. rowKey is the literal '__header' or String(integer) — no consumer string.
const currentCellEl = () => {
  const rowKey = activeIsHeader ? '__header' : String(activeRow);
  return resolveCellEl(rowKey, activeColIndex, activeIsHeader ? activeHeaderLevel : null);
};
// The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
const focusables = (cellEl: any) => {
  if (!cellEl || !cellEl.querySelectorAll) return [];
  const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
  return list.filter((n: any) => !n.disabled);
};
// Enter/F2 → enter interaction mode: focus the active cell's FIRST interactive control
// (D-07 — uniform for header sort buttons and body controls; Enter does NOT sort directly).
// No-op (stay in navigation mode) if the cell has no focusable control.
const enterControl = () => {
  const cellEl = currentCellEl();
  const list = focusables(cellEl);
  if (!list.length) return;
  activeInControl = true;
  list[0].focus();
};
// Cycle focus among the controls WITHIN the active cell (D-08 focus containment) — Tab
// forward / Shift+Tab backward, wrapping at the ends. Uses the plan-01-PROVEN per-target
// activeElement read: gridRoot.getRootNode().activeElement is the UNIFORM correct read on
// ALL SIX (document in light DOM; the shadow root on Lit). Reuse verbatim — do NOT re-derive.
const cycleWithinCell = (cellEl: any, forward: any) => {
  const list = focusables(cellEl);
  if (!list.length) return;
  const active = gridRoot ? gridRoot.getRootNode().activeElement : null;
  const cur = list.indexOf(active);
  let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
  if (i >= list.length) i = 0;
  if (i < 0) i = list.length - 1;
  list[i].focus();
};
// THE single delegated keydown handler (RESEARCH "Single delegated keydown handler"). Wired
// as ONE keydown listener on the <table> root — NOT per-cell, NOT with .stop/.prevent modifiers (the
// Angular .stop-in-@for hoist bug, F5/ROZ723). e.preventDefault() is called IMPERATIVELY for
// handled keys. Each nav helper writes $data and RETURNS the fresh post-write locals; those
// SAME locals feed BOTH focusActiveCell AND the activecell-change emit (no $data re-read).
const onGridKeyDown = (e: any) => {
  if (!isGrid() || !e) return;
  const key = e.key;
  // Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
  // via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
  // early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
  // modes (editing / in-control / navigation) stay mutually exclusive and ordered.
  if (editingRow >= 0) return;
  // Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
  // editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
  // keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
  if (editingRowIndex != null) return;
  // Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
  if (activeInControl) {
    if (key === 'Escape') {
      e.preventDefault();
      activeInControl = false;
      // Return focus to the OWNING cell (no move happened) — pass the current indices
      // explicitly (the React-emitted seam types both params as required; a zero-arg call
      // is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
      focusActiveCell(activeRow, activeColIndex);
    } else if (key === 'Tab') {
      e.preventDefault();
      cycleWithinCell(currentCellEl(), !e.shiftKey);
    }
    return;
  }
  // WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
  // grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
  // user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
  // NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
  // focused node; if it is not itself a [data-grid-cell], let the event pass through.
  const tgt = e.target;
  if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
  // Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
  // nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
  // async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
  // plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
  // in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
  // fires ONLY on a real move (a clamped no-op edge move leaves them identical).
  const prevRow = activeRow;
  const prevCol = activeColIndex;
  const prevIsHeader = activeIsHeader;
  const prevLevel = activeHeaderLevel;
  let nextRow = prevRow;
  let nextCol = prevCol;
  let nextIsHeader = prevIsHeader;
  // B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
  // threaded into the focus seam so a leaf↔parent header move lands focus at the correct
  // level. moveRow returns it; the non-vertical branches keep the pre-move level.
  let nextLevel = prevLevel;
  // ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
  // the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
  // fall through to a plain navigation move). Body cells only (no range from a header). The
  // extendRange call owns focus + the range-change emit, so return immediately. ──────────
  // ── §8 (260709-3qt) Ctrl/Cmd+Arrow — jump the active cell to the data-region edge (plain
  // Ctrl) or EXTEND the range to that edge (Ctrl+Shift). Body cells only (a header-active
  // Ctrl+Arrow falls through to the plain-arrow branches unchanged). Tested BEFORE the
  // Shift+Arrow / plain-arrow cascade so the modifier combo is matched first. preventDefault
  // suppresses the browser's native Ctrl+Arrow scroll/word-jump. The Ctrl+Shift branch owns
  // extendRange's focus + range-change emit (returns); the plain-Ctrl branch sets the fresh
  // nextRow/nextCol locals and FALLS THROUGH to the shared focus seam (like Ctrl+Home/End). ──
  if ((e.ctrlKey || e.metaKey) && e.shiftKey && !activeIsHeader && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
    e.preventDefault();
    if (key === 'ArrowUp') extendRange(-activeRow, 0);else if (key === 'ArrowDown') extendRange(bodyRowCount() - 1 - activeRow, 0);else if (key === 'ArrowLeft') extendRange(0, -activeColIndex);else extendRange(0, visibleColCount() - 1 - activeColIndex);
    return;
  } else if ((e.ctrlKey || e.metaKey) && !activeIsHeader && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
    e.preventDefault();
    clearRange();
    if (key === 'ArrowUp') {
      nextRow = gotoRowEdge(false);
      nextIsHeader = false;
    } else if (key === 'ArrowDown') {
      nextRow = gotoRowEdge(true);
      nextIsHeader = false;
    } else if (key === 'ArrowLeft') {
      nextCol = gotoColEdge(false);
    } else {
      nextCol = gotoColEdge(true);
    }
  } else if (key === 'ArrowRight' && e.shiftKey && !activeIsHeader) {
    e.preventDefault();
    extendRange(0, 1);
    return;
  } else if (key === 'ArrowLeft' && e.shiftKey && !activeIsHeader) {
    e.preventDefault();
    extendRange(0, -1);
    return;
  } else if (key === 'ArrowDown' && e.shiftKey && !activeIsHeader) {
    e.preventDefault();
    extendRange(1, 0);
    return;
  } else if (key === 'ArrowUp' && e.shiftKey && !activeIsHeader) {
    e.preventDefault();
    extendRange(-1, 0);
    return;
  } else if (key === 'ArrowRight') {
    e.preventDefault();
    clearRange();
    nextCol = moveCol(1);
  } else if (key === 'ArrowLeft') {
    e.preventDefault();
    clearRange();
    nextCol = moveCol(-1);
  } else if (key === 'ArrowDown') {
    e.preventDefault();
    clearRange();
    const m = moveRow(1);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'ArrowUp') {
    e.preventDefault();
    clearRange();
    const m = moveRow(-1);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'PageDown') {
    e.preventDefault();
    const m = moveRow(GRID_PAGE_STEP);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'PageUp') {
    e.preventDefault();
    const m = moveRow(-GRID_PAGE_STEP);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'Home') {
    e.preventDefault();
    if (e.ctrlKey || e.metaKey) {
      const s = gotoStart();
      nextRow = s.row;
      nextCol = s.col;
      nextIsHeader = false;
    } else {
      nextCol = gotoColEdge(false);
    }
  } else if (key === 'End') {
    e.preventDefault();
    if (e.ctrlKey || e.metaKey) {
      const en = gotoEnd();
      nextRow = en.row;
      nextCol = en.col;
      nextIsHeader = false;
    } else {
      nextCol = gotoColEdge(true);
    }
  }
  // ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
  // pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
  // edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
  // type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
  // gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
  // falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
  // (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
  else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
    e.preventDefault();
    copyRange();
    return;
  } else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
    e.preventDefault();
    pasteRange();
    return;
  }
  // ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
  // source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
  // Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
  // and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
  // shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
  else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
    e.preventDefault();
    cutRange();
    return;
  }
  // ── 260709-8ct (grid-wide undo/redo) — Ctrl/Cmd+Z undoes; Ctrl/Cmd+Y OR Ctrl/Cmd+Shift+Z
  // redoes. Undoable-gated (`$props.undoable`) — when off, neither preventDefault nor
  // undo()/redo() runs, so a shipped grid with undoable unset is byte-behaviorally unchanged
  // (the browser's own native undo/redo, if any, still fires). NOT clipboardActiveAllowed-
  // gated (unlike Ctrl+C/V/X/Delete above): undo/redo is GRID-WIDE and must work regardless of
  // whether a header or body cell is active. Tested the Ctrl+Shift+Z (redo) combo BEFORE the
  // plain Ctrl+Z (undo) branch so a Shift+Z never falls into undo.
  else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey) && e.shiftKey) {
    if (undoable) {
      e.preventDefault();
      redo();
      return;
    }
  } else if ((key === 'y' || key === 'Y') && (e.ctrlKey || e.metaKey)) {
    if (undoable) {
      e.preventDefault();
      redo();
      return;
    }
  } else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey)) {
    if (undoable) {
      e.preventDefault();
      undo();
      return;
    }
  }
  // ── §7 (260709-3qt) — Delete/Backspace CLEARS the active cell / range through the SAME
  // write-funnel as Cut (applyGridToRange of an empty grid), MINUS the clipboard copy. B11-gated
  // by clipboardActiveAllowed so a header-active Delete/Backspace falls through to NATIVE behavior
  // (never a silent body mutation). The top-of-handler editing early-returns + the line-39
  // data-grid-cell guard keep this to navigation mode; applyGridToRange skips read-only/non-editable
  // cells. Reversible via Ctrl+Z when `undoable` is on (260709-8ct) — clearActiveRange funnels
  // through the SAME writeData seam undo/redo replay through, so no separate inverse machinery
  // is needed here.
  else if ((key === 'Delete' || key === 'Backspace') && clipboardActiveAllowed()) {
    e.preventDefault();
    clearActiveRange();
    return;
  }
  // ── §8 (260709-3qt) — Ctrl/Cmd+A selects the WHOLE BODY range (drives the same range corners
  // shift+arrow uses). preventDefault ALWAYS so the page is never selected in grid mode; only a
  // body-active Ctrl+A builds the range (a header-active Ctrl+A is a no-op — selects nothing). ──
  else if ((key === 'a' || key === 'A') && (e.ctrlKey || e.metaKey)) {
    e.preventDefault();
    if (!activeIsHeader) selectAllBody();
    return;
  }
  // ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
  // EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
  // (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
  // collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
  // at least the active editable column); a non-editable active cell falls through unchanged.
  else if (key === 'F2' && e.shiftKey && isActiveCellEditable()) {
    e.preventDefault();
    beginRowEdit((rows || [])[activeRow]);
    return;
  }
  // ── Boolean in-place toggle (design doc 2026-07-05, Change 1) — a built-in
  // editor:'checkbox' cell toggles + commits INSTANTLY on Space/Enter/F2, no editor opens
  // (the spreadsheet-standard shape for a two-state value). Tested BEFORE the generic
  // Enter/F2 edit-entry branch below (a checkbox cell must never fall into the open-an-
  // editor ceremony) and gated the SAME way (isActiveCellEditable) plus editorTypeOf ===
  // 'checkbox'. Full-row edit mode is unaffected — the editingRowIndex early return at the
  // top of onGridKeyDown already excludes it.
  else if ((key === 'Enter' || key === 'F2' || key === ' ') && isActiveCellEditable() && editorTypeOf(activeCellColumnId()) === 'checkbox') {
    e.preventDefault();
    toggleActiveBooleanCell();
    return;
  }
  // ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
  // Gated by isActiveCellEditable(): a non-editable active cell falls through to
  // enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
  // (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
  else if ((key === 'Enter' || key === 'F2') && isActiveCellEditable()) {
    e.preventDefault();
    beginEdit(activeRow, activeColIndex, null);
    return;
  } else if (isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && editorTypeOf(activeCellColumnId()) !== 'checkbox') {
    // B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
    // checkbox/select/date editor must NOT take the typed char as its value (it would
    // force-check the checkbox, seed a garbage select option, or corrupt the date) — open
    // those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
    // Checkbox is excluded entirely (type-to-edit disabled — the branch above already
    // handles Space/Enter/F2; any OTHER printable key on a checkbox cell is a no-op).
    e.preventDefault();
    const editType = editorTypeOf(activeCellColumnId());
    const seed = editType === 'text' || editType === 'number' ? key : null;
    beginEdit(activeRow, activeColIndex, seed);
    return;
  }
  // ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
  // expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
  // verified invariant) so it never hits the edit branches above and would otherwise fall to
  // enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
  // Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
  // model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
  // ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
  // full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
  else if (key === 'Enter' && !activeIsHeader && rowIsGrouped((rows || [])[activeRow])) {
    e.preventDefault();
    // C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
    // active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
    // the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
    // row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
    // fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
    // DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
    // while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
    // settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
    // onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
    // SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
    // until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
    // already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
    const grpRow = activeRow;
    const grpCol = activeColIndex;
    onToggleExpand((rows || [])[activeRow], e);
    // guardMoved=true: the group header row is UNCHANGED by its own collapse, so a stale late
    // rAF poll must not steal focus back after the user has already ArrowDown'd to another row.
    recoverGridFocus(String(grpRow), grpCol, null, true);
    return;
  } else if (key === 'Enter' || key === 'F2') {
    e.preventDefault();
    enterControl();
    return;
  } else return;
  // THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
  // focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
  focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
  // WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
  // changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
  // row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
  // B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
  // C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
  // keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
  // toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
  // The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
  if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
    // Mirror getActiveCell's shape (this payload + getActiveCell are documented to speak the
    // SAME language): a header cell has no body-row index, so emit rowIndex:null + isHeader:true
    // rather than a bogus toAbsRow(nextRow) — which would compute a real body-row absolute index
    // for a HEADER move, misleading a consumer into thinking that body row is the active cell.
    onactivecellchange?.(nextIsHeader ? {
      rowIndex: null,
      colIndex: nextCol,
      isHeader: true
    } : {
      rowIndex: toAbsRow(nextRow),
      colIndex: nextCol,
      isHeader: false
    });
  }
};
// WR-03: integrate mouse-click + programmatic focus with the roving model. A click on a
// tabindex="-1" cell (or focus arriving any way other than the keyboard nav path) moves
// DOM focus there but does NOT run onGridKeyDown — so activeRow/activeColIndex would stay
// on the OLD cell and the NEXT arrow key would jump from the stale active cell. Wired as
// ONE @focusin on the <table> root (focusin bubbles): resolve the focused element's owning
// [data-grid-cell], parse its data-row/data-col-index, and write them into the active-cell
// state (mirroring the keyboard path). Clears activeInControl ONLY when the cell ITSELF
// (not an inner control) received focus — focusing a control via Enter keeps the in-control
// flag. NEVER emits activecell-change (a focus sync is not a keyboard navigation event).
const syncActiveFromEvent = (e: any) => {
  if (!isGrid() || !e) return;
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null) return;
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(col)) return;
  // #9: snapshot the PRE-write active position so we can bump the focus-intent epoch ONLY when
  // this focusin genuinely MOVES the active cell (a click landing on a NEW cell). A no-op focusin
  // — focus arriving on the ALREADY-active cell, e.g. a scroll/page-switch poll's own el.focus()
  // or focusActiveCell's synchronous re-seat — must NOT bump, or it would abort a legitimate
  // in-flight recovery on its own settling frames (the poll would see a changed epoch and quit).
  const prevIsHeader = activeIsHeader;
  const prevRow = activeRow;
  const prevCol = activeColIndex;
  const prevLevel = activeHeaderLevel;
  const isHeader = rowAttr === '__header';
  activeIsHeader = isHeader;
  let movedRow = prevRow;
  let movedLevel = prevLevel;
  if (isHeader) {
    // B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
    // roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
    // one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
    const lvlAttr = cellEl.getAttribute('data-header-level');
    const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : headerLeafLevel();
    movedLevel = Number.isFinite(lvl) ? lvl : headerLeafLevel();
    activeHeaderLevel = movedLevel;
  } else {
    const row = parseInt(rowAttr, 10);
    if (Number.isFinite(row)) {
      movedRow = row;
      activeRow = row;
    }
  }
  activeColIndex = col;
  // #9: a genuine active-cell MOVE is a fresh focus intent — supersede any pending async focus
  // poll (scroll-to / page-switch). Compare against the PRE-write snapshot: bump only when the
  // header-flag, column, or (per mode) the header LEVEL / body ROW actually changed.
  if (isHeader !== prevIsHeader || col !== prevCol || (isHeader ? movedLevel !== prevLevel : movedRow !== prevRow)) {
    focusIntentEpoch = focusIntentEpoch + 1;
  }
  // A plain focus collapses any range back to the single active cell — EXCEPT (a) the
  // programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
  // ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
  // focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
  // BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
  // path owns the shift case and flags it here so the collapse is skipped.
  if (rangeTransition) {
    rangeTransition = false;
  } else if (rangeClickPending) {
    rangeClickPending = false;
  } else {
    clearRange();
  }
  // The cell box (not an inner control) receiving focus = navigation mode.
  if (tgt === cellEl) activeInControl = false;
};
// onGridMouseDown: the pointer range seam (phase 51 req-7 / D-07 Shift+Click; §6 260709-3qt
// plain drag-to-select). A focusin event carries no reliable `shiftKey`, so the modifier MUST
// be read off the pointer event — @mousedown fires BEFORE the cell's focusin and DOES carry
// shiftKey. A shift-held mousedown on a BODY cell sets the range's moving corner to that cell
// (keeping the anchor), then flags rangeClickPending so the follow-up focusin does not collapse
// the range. A PLAIN (non-shift) mousedown BEGINS a drag-select anchored at that cell (§6): the
// document pointermove/up listeners paint the range as the pointer moves. The fill handle owns
// its own @pointerdown drag (it stops propagation), so a plain mousedown originating inside it is
// skipped. Do NOT preventDefault — native focus must still land (focusin sync + roving tabindex).
const onGridMouseDown = (e: any) => {
  if (!isGrid() || !e) return;
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  // §6: a plain mousedown inside the fill handle is owned by the handle's own pointerdown drag —
  // never begin a range paint from it (the shift path never lands on the 8px handle).
  if (!e.shiftKey && tgt.closest('[data-fill-handle]')) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
  const row = parseInt(rowAttr, 10);
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(row) || !Number.isFinite(col)) return;
  if (e.shiftKey) {
    // Shift+Click: set the moving corner (keeping the anchor) and flag rangeClickPending so the
    // follow-up focusin does not collapse the range (a focusin carries no reliable shiftKey).
    setRangeFocus(row, col);
    activeIsHeader = false;
    activeRow = row;
    activeColIndex = col;
    rangeClickPending = true;
    return;
  }
  // §6 plain mousedown → begin a document-level drag-select anchored at this cell. The mousedown's
  // native focusin commits the ACTIVE cell to (row,col); beginRangeDrag's first cross-cell
  // pointermove paints the range via setRangeFocus (anchored at the active cell). A mousedown with
  // no move collapses to a single active cell (no range).
  beginRangeDrag(row, col);
};
// onGridDblClick: the double-click-into-edit seam (grid pointer §3+§5, 260708-ni6). Wired as
// ONE @dblclick on the <table> root (mirroring the already-delegated @mousedown/@focusin). A
// double-click on a BODY cell either toggles a group (group-header cell) or opens the editor
// (editable cell); a non-editable body cell is a no-op (the cell stays active — its focusin
// already set the active state + the §1 ring). Header cells return early so they keep their
// native sort/menu/resize semantics. Reuses the SAME closest/parse/finite guards as
// syncActiveFromEvent and the SAME beginEdit / onToggleExpand funnels the keyboard path uses —
// no new edit or expand machinery. isGrid()-gated so 'table' mode never runs it.
const onGridDblClick = (e: any) => {
  if (!isGrid() || !e) return;
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
  const row = parseInt(rowAttr, 10);
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(row) || !Number.isFinite(col)) return;
  // NB the local is `rowObj` (NOT `activeRow`): $data.activeRow lowers to the bare React state
  // binding `activeRow`, so a `const activeRow = …` local self-shadows it (TS2448 TDZ — the
  // visibleColCount `rowList` self-shadow class). ($data.rows || [])[row] is the active flattened
  // row (page-relative non-virtual / full-model virtual — both index $data.rows, matching the C2
  // Enter-on-group path + syncActiveFromEvent's row parse).
  const rowObj = (rows || [])[row];
  if (rowIsGrouped(rowObj)) {
    // Group-header cell → toggle its collapse/expand through the SAME onToggleExpand funnel the
    // chevron uses (mirrors the C2 Enter-on-group path verbatim), then re-seat focus after the
    // re-render (guardMoved=true — the group-header row is unchanged by its own collapse, so a
    // stale late rAF must not steal focus back after a subsequent nav).
    e.preventDefault();
    onToggleExpand(rowObj, e);
    recoverGridFocus(String(row), col, null, true);
    return;
  }
  // Editable body cell → open its editor (seed=null → seed the EXISTING value, the in-place F2/
  // Enter entry). A non-editable body cell is a no-op: the cell stays active (focusin already set
  // it + the §1 ring), matching the spreadsheet display-vs-edit convention.
  const colId = columnIdAt(row, col);
  if (colId != null && columnEditable(colId)) {
    e.preventDefault();
    beginEdit(row, col, null);
  }
};
// onGridClick: the opt-in single-click-to-edit seam (grid pointer §4, 260708-ni6). Only active
// when the `singleClickEdit` prop is true (default false, negative-opt-out). Wired as ONE @click
// on the <table> root — @click fires on a genuine mouseup-no-drag click (NOT @mousedown), which
// honors the deferred §6 drag guard (a mousedown that begins a drag-select must not open an
// editor). A plain click on an EDITABLE body cell opens its editor via the SAME beginEdit funnel;
// shift+click (range extend) and non-editable cells are unaffected. Same closest/parse/header-skip
// /finite guards as onGridDblClick. isGrid()-gated so 'table' mode never runs it.
const onGridClick = (e: any) => {
  if (!isGrid() || !e) return;
  if (!singleClickEdit) return;
  if (e.shiftKey) return;
  // §6 (260709-3qt): a drag-select that MOVED must never open the editor — the editor opens only
  // on a genuine mouseup-no-drag click. beginRangeDrag resets rangeDragMoved=false per gesture, so
  // the flag is always fresh; consume it here so a subsequent plain click still edits.
  if (rangeDragMoved) {
    rangeDragMoved = false;
    return;
  }
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
  const row = parseInt(rowAttr, 10);
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(row) || !Number.isFinite(col)) return;
  // Already editing THIS exact cell → no-op (a click inside an open editor must not re-open it).
  if (editingRow === row && editingCol === col) return;
  const colId = columnIdAt(row, col);
  if (colId != null && columnEditable(colId)) beginEdit(row, col, null);
};
// WR-02: reset the interaction-mode flag when focus leaves the active cell's subtree.
// Without this, activeInControl could stick `true` — a mouse click OUTSIDE the cell, or
// the focused inner control being removed from the DOM — leaving onGridKeyDown wedged in
// the in-cell-trap branch so arrow nav is dead until Escape. Wired as ONE @focusout on
// the <table> root (focusout bubbles, unlike blur). relatedTarget is the element RECEIVING
// focus (null when focus leaves the document / is retargeted across a shadow boundary). If
// focus is NOT moving to a descendant of the active cell, drop the flag. A Tab-cycle WITHIN
// the cell (interaction mode) keeps relatedTarget inside cellEl → no reset.
const onGridFocusOut = (e: any) => {
  if (!isGrid() || !activeInControl) return;
  const next = e ? e.relatedTarget : null;
  const cellEl = currentCellEl();
  if (!cellEl || !next || !cellEl.contains(next)) activeInControl = false;
};
// B25: re-focus a resolved valid cell AFTER a programmatic shrink re-renders. The clamp
// runs synchronously BEFORE the framework commits the new tbody, so a deferred rAF-poll
// resolves the [data-row][data-col-index] cell off gridRoot once it has rendered (the fast
// targets land on attempt 1; React/Solid retry across the async commit). Mirrors
// focusCellWhenReady (B23) — DOM-only (reads gridRoot), so it is React-stale-safe.
// guardMoved (default false): when true, the poll does NOT stomp focus that a later nav has
// already moved to a DIFFERENT, STILL-VALID row — used only by the group-collapse re-seat (the
// target group-header row is unchanged, so a stale late rAF must not steal focus back after the
// user ArrowDown'd away → the non-deterministic treegrid collapsed-nav focus-theft). It is left
// OFF for the B25 shrink-recovery site, whose target is a CLAMPED index of a now-REMOVED cell:
// there focus legitimately sits on the doomed old cell (a different row) mid-async-render on
// React and MUST be recovered onto the clamped survivor, not preserved. Compare data-row (NOT
// node identity) so a stale SAME-row cell on Solid's node-replacing re-render still resolves as
// the target — a genuinely dropped focus is always recovered on both sites.
const recoverGridFocus = (rowKey: any, col: any, level: any, guardMoved = false) => {
  if (!gridRoot) return;
  let attempts = 0;
  const tryFocus = () => {
    if (guardMoved) {
      const ae = gridRoot && gridRoot.getRootNode ? gridRoot.getRootNode().activeElement : null;
      const aeCell = ae && ae.closest ? ae.closest('[data-grid-cell]') : null;
      if (aeCell && gridRoot.contains(aeCell)) {
        const aeRow = aeCell.getAttribute('data-row');
        if (aeRow != null && aeRow !== rowKey) return;
      }
    }
    const el = resolveCellEl(rowKey, col, level);
    if (el) {
      el.focus();
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// D-05: clamp the active cell to bounds on every underlying-data change (re-sort, filter,
// pagination, page-size). KEEP the same indices; clamp ONLY when the grid shrank — NO
// row-id following, NO bounce-to-top on a filter keystroke. Gated by isGrid() so 'table'
// mode is entirely untouched. Invoked at the rowModelVer bump path (refreshRowModel).
const clampActiveCell = (rowCount: any, colCount: any) => {
  if (!isGrid()) return;
  // B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
  // just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
  // async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
  // and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
  // out of bounds on React only). Falls back to the live helpers when called without bounds.
  const colN = colCount != null ? colCount : visibleColCount();
  const rowN = rowCount != null ? rowCount : bodyRowCount();
  // B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
  // shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
  // framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
  // — true on all six, incl React's async setState), so the doomed cell + its focus are still
  // observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
  // programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
  // Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
  // doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
  // The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
  // NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
  // shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
  // DOM coords are always fresh.
  let recoverFocus = false;
  let doomedRow = -1;
  let doomedCol = 0;
  if (gridRoot) {
    const rootNode = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
    const focusedEl = rootNode ? rootNode.activeElement : null;
    const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
    if (focusedCell && gridRoot.contains(focusedCell)) {
      const fRowAttr = focusedCell.getAttribute('data-row');
      const fColAttr = focusedCell.getAttribute('data-col-index');
      if (fRowAttr != null && fRowAttr !== '__header') {
        const fr = parseInt(fRowAttr, 10);
        const fc = parseInt(fColAttr, 10);
        if (Number.isFinite(fr) && fr > rowN - 1) {
          recoverFocus = true;
          doomedRow = fr;
          doomedCol = Number.isFinite(fc) ? fc : 0;
        }
      }
    }
  }
  const maxCol = colN - 1;
  const col = clamp(activeColIndex, 0, maxCol < 0 ? 0 : maxCol);
  if (col !== activeColIndex) activeColIndex = col;
  // B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
  // cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
  // an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
  // re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
  if (rowN <= 0) {
    activeIsHeader = true;
    activeHeaderLevel = headerLeafLevel();
    activeColIndex = 0;
    // B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
    // reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
    // async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
    // class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
    // recovery branch below actually runs on React too.
    gridEmptyFallback = true;
    clampRange(rowN - 1, colN - 1);
    // B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
    // reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
    // Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
    // (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
    // the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
    return;
  }
  // B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
  // re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
  // cell. A user-driven header position (not the empty fallback) is left untouched.
  if (gridEmptyFallback) {
    gridEmptyFallback = false;
    activeIsHeader = false;
    activeRow = 0;
  }
  if (!activeIsHeader) {
    const lastRow = rowN - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const row = clamp(activeRow, 0, maxRow);
    if (row !== activeRow) activeRow = row;
  }
  // B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
  // shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
  // out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
  clampRange(rowN - 1, colN - 1);
  // B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
  // when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
  // into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
  if (recoverFocus) {
    const recRow = clamp(doomedRow, 0, rowN - 1);
    const recCol = clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
    recoverGridFocus(String(recRow), recCol, null);
  }
};
// B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
// flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
// plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
// is reached through the mount-time refreshRowModel closure, so a `$data.gridEmptyFallback` READ
// there binds the async-stale mount-time value on React (setState is async — the rangeActive /
// pendingEditFollow / B23-nextRows stale-read class). With the body re-populated after a filter
// CLEAR, that stale read skipped the recovery branch on React → the roving tab-stop stayed on the
// header fallback (columnheader) instead of re-seating a body cell (the B6 recovery gap). A
// synchronously-written plain `let` is read fresh on all six → the empty→non-empty recovery
// re-seats activeRow 0 on React too. The other 5 targets are byte-behaviorally identical (they
// already read reactive $data synchronously). A top-level reassigned `let` referenced from the
// refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
let gridEmptyFallback = false;
// ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
// A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
// (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
// highlight reattaches to the correct cells across virtualization recycling (the
// activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
// range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
// the row-selection slice (the two never touch each other's state).

// inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
// rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
// the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
// rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
// range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
// (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
// range we just set. The flag suppresses that collapse for the in-flight focus settle (the
// editTransition blur-guard precedent). A top-level let → React hoists to useRef.
let rangeTransition = false;
// rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
// pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
// to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
let rangeClickPending = false;
// B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
// true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
// arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
// on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
// `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
// range-change. This module-let is written synchronously (no setState async), so the second
// clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
// across all six targets. A top-level let → React hoists to useRef.
let rangeActive = false;
const inRange = (rIdx: any, cIdx: any) => {
  const a = rangeAnchor;
  const f = rangeFocus;
  if (!a || !f) return false;
  const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
  const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
  const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
  const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
  return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
};
// getSelectedRange(): the current range as plain integers — { anchor, focus } each a
// { rowIndex, colIndex } pair (or null when no range). T-49-02: positions only, no row
// data, no DOM node. Used by the getSelectedRange $expose verb AND every range-change emit
// (the single payload source) AND copyRange/fillRange (the rectangle they operate over).
export const getSelectedRange = () => {
  // B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
  // payload) never reports a corner past a shrunken model — React-stale-safe (the eager
  // refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
  const a = rangeAnchor;
  const f = rangeFocus;
  if (!a && !f) return {
    anchor: null,
    focus: null
  };
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return {
    anchor: null,
    focus: null
  };
  const clampCorner = (c: any) => c == null ? null : {
    rowIndex: clamp(c.rowIndex, 0, maxRow),
    colIndex: clamp(c.colIndex, 0, maxCol)
  };
  return {
    anchor: clampCorner(a),
    focus: clampCorner(f)
  };
};
// isFillHandleCell(rIdx, cIdx): is this cell the BOTTOM-RIGHT corner of the current range?
// That corner hosts the fill-handle affordance (req-8 / D-04). False without a range — the
// byte-identical-off guard for the handle markup (no range → no handle).
const isFillHandleCell = (rIdx: any, cIdx: any) => {
  const a = rangeAnchor;
  const f = rangeFocus;
  if (!a || !f) return false;
  const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
  const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
  return rIdx === r1 && cIdx === c1;
};
// emitRangeChange(anchor, focus): fire range-change with the FRESH range corners passed by
// the caller — NOT a re-read of $data.rangeAnchor/rangeFocus. The range corners are <data>
// (useState on React), so re-reading right after the same-tick setState returns the STALE
// pre-write value (ROZ138). extendRange/setRangeFocus thread the just-computed locals through
// here so the emitted payload matches the write. The single call site keeps the count
// predictable (React multi-emit dedup, D-07). One-way notification.
const emitRangeChange = (anchor: any, focus: any) => {
  onrangechange?.({
    anchor,
    focus
  });
};
// extendRange(dRow, dCol): move rangeFocus by the (row,col) delta, clamped to the grid
// bounds, seeding rangeAnchor from the active cell when no range exists yet (Shift+Arrow
// from a bare active cell starts a 1×N / N×1 rectangle anchored at that cell). Body cells
// only (header rows are not range-selectable). Emits range-change from this single site.
const extendRange = (dRow: any, dCol: any) => {
  if (activeIsHeader) return;
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return;
  // Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
  let anchor = rangeAnchor;
  let focus = rangeFocus;
  const hadRange = !!(anchor && focus);
  if (!anchor || !focus) {
    anchor = {
      rowIndex: activeRow,
      colIndex: activeColIndex
    };
    focus = {
      rowIndex: activeRow,
      colIndex: activeColIndex
    };
  }
  const nextRow = clamp(focus.rowIndex + dRow, 0, maxRow);
  const nextCol = clamp(focus.colIndex + dCol, 0, maxCol);
  const nextFocus = {
    rowIndex: nextRow,
    colIndex: nextCol
  };
  rangeAnchor = anchor;
  rangeFocus = nextFocus;
  rangeActive = true;
  // Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
  // from the range's leading edge, the spreadsheet convention).
  activeRow = nextRow;
  activeColIndex = nextCol;
  // Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
  // settle on the new focus corner is part of THIS range extension, not a fresh navigation.
  rangeTransition = true;
  focusActiveCell(nextRow, nextCol, false);
  // B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
  // and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
  // change → no emit. Seeding a brand-new range (no prior range) is always a change (the
  // rectangle came into existence) even if its first corner is a degenerate 1×1.
  if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
    emitRangeChange(anchor, nextFocus);
  }
};
// setRangeFocus(rIdx, cIdx): set the moving corner to an explicit cell (Shift+Click),
// seeding the anchor from the active cell when no range exists yet. Clamped to bounds.
// Emits range-change from this single site.
const setRangeFocus = (rIdx: any, cIdx: any) => {
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return;
  let anchor = rangeAnchor;
  if (!anchor) anchor = {
    rowIndex: activeRow,
    colIndex: activeColIndex
  };
  const r = clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
  const c = clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
  const nextFocus = {
    rowIndex: r,
    colIndex: c
  };
  rangeAnchor = anchor;
  rangeFocus = nextFocus;
  rangeActive = true;
  emitRangeChange(anchor, nextFocus);
};
// selectAllBody(): the §8 (260709-3qt) Ctrl+A whole-body select — set the range to span EVERY
// body cell (anchor at the first body cell (0,0), moving corner at the last (maxRow, maxCol)),
// driving the SAME range corners shift+arrow / setRangeFocus use. Emits range-change from a single
// site (the emitRangeChange contract — pass the FRESH corners, never a $data re-read). No-op on an
// empty grid. Body cells only — a header-active Ctrl+A is gated OUT by the caller (never builds a
// range from a header). rangeActive is set synchronously so a follow-up clearRange collapses it.
const selectAllBody = () => {
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return;
  const anchor = {
    rowIndex: 0,
    colIndex: 0
  };
  const focus = {
    rowIndex: maxRow,
    colIndex: maxCol
  };
  rangeAnchor = anchor;
  rangeFocus = focus;
  rangeActive = true;
  emitRangeChange(anchor, focus);
};
// clearRange(): drop the rectangle (a non-shift navigation / edit-entry collapses any
// range back to a single active cell). Cheap no-op when no range is set (the guard keeps a
// plain navigation with no active range from emitting). B19: when a range DID exist, emit
// range-change with null corners so a consumer mirroring the selection through the event sees
// the drop — without this they hold a STALE rectangle after every non-shift navigation /
// edit-entry collapse (getSelectedRange already reports null, but the event never fired).
const clearRange = () => {
  // B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
  // in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
  // move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
  // would let the SECOND call through and emit a duplicate range-change. rangeActive flips
  // synchronously → the second call returns here.
  if (!rangeActive) return;
  rangeActive = false;
  rangeAnchor = null;
  rangeFocus = null;
  emitRangeChange(null, null);
};
// B8: clamp the range corners to the current grid bounds after an underlying-data change
// (sort/filter/paginate/page-size all re-derive the row model). A range whose rows now exceed
// the shrunken model would otherwise leave STALE/phantom corners → a copy serializes empty
// rows past the model's end (and getSelectedRange reports out-of-bounds corners). We CLAMP each
// corner into [0,maxRow]×[0,maxCol] (preserving a valid rectangle — a corner that clamps onto
// another keeps the range non-empty); when no selectable body cell remains the rectangle is
// dropped. Does NOT emit range-change here — the clamp is a reconcile, not a user selection
// move (the emit-on-change work, B18/B19, lands in plan 63-05). Called from clampActiveCell.
const clampRange = (maxRowArg: any, maxColArg: any) => {
  const a = rangeAnchor;
  const f = rangeFocus;
  if (!a && !f) return;
  // Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
  // shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
  const maxRow = maxRowArg != null ? maxRowArg : bodyRowCount() - 1;
  const maxCol = maxColArg != null ? maxColArg : visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) {
    rangeAnchor = null;
    rangeFocus = null;
    rangeActive = false;
    return;
  }
  if (a) {
    const ar = clamp(a.rowIndex, 0, maxRow);
    const ac = clamp(a.colIndex, 0, maxCol);
    if (ar !== a.rowIndex || ac !== a.colIndex) rangeAnchor = {
      rowIndex: ar,
      colIndex: ac
    };
  }
  if (f) {
    const fr = clamp(f.rowIndex, 0, maxRow);
    const fc = clamp(f.colIndex, 0, maxCol);
    if (fr !== f.rowIndex || fc !== f.colIndex) rangeFocus = {
      rowIndex: fr,
      colIndex: fc
    };
  }
};
// ══ Clipboard (TSV copy/paste) + drag-fill (phase 51 plan 04 / req-8 / D-03 / D-04) ══════
// The async Clipboard API (grantPermissions confirmed in 51-01). Copy = range→TSV; paste =
// TSV→cells under the D-03 skip rule (editable AND validator-passing cells only) with an
// N-of-M aria-live announce + one cell-edit-commit per committed cell; drag-fill = value-copy
// ONLY (D-04, NO series detection). T-51-01 (BLOCKING-high): pasted TSV is UNTRUSTED — every
// cell is written as plain string DATA through the per-column validator and rendered via the
// SAME {{ }}/rozieDisplay text path as #cell (never innerHTML / a template / a selector); the
// cell-resolution query interpolates integer indices only (resolveCellEl, T-49-01).

// announce(msg): write the polite aria-live PASTE-announce region (D-03 — "N of M cells
// pasted"). SEPARATE from the validation invalidMsg region (different semantics). '' clears it.
const announce = (msg: any) => {
  pasteAnnounce = msg != null ? msg : '';
};
// B11: copy / paste (and the Cut verb plan 63-09 adds) are NO-OPS while a HEADER cell is
// active. A header has no body value to copy, and a paste anchored at a header would silently
// write body row 0 at the header's column (a silent body mutation, borderline P0). This is the
// SINGLE reusable guard every clipboard entry path checks — copyRange/pasteRange self-guard
// with it AND the onGridKeyDown Ctrl+C/Ctrl+V branches gate on it (so the native shortcut is
// left untouched on a header). Plan 63-09's Cut reuses this exact predicate.
const clipboardActiveAllowed = () => !activeIsHeader;
// fieldOfColId: the row-object key (accessorKey) to write for a column id — the same
// accessorKey-or-id rule the edit funnels use. Used by paste/fill to apply values by field.
const fieldOfColId = (colId: any) => {
  const d = defFor(colId);
  return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
// normalizedRange(): the current rectangle as { r0, r1, c0, c1 } (min/max of anchor+focus),
// or null when no range. The shared rectangle source for copy/paste/fill. B8: the corners are
// CLAMPED to the CURRENT grid bounds ON READ (read at call time → React-stale-safe), so a copy
// after a filter-to-fewer can never serialize phantom rows past the shrunken model even when
// the stored corners were not eagerly re-clamped (refreshRowModel's clamp is async-defeated on
// React; this read-time clamp is the cross-target guarantee). Returns null when no body cell
// remains.
const normalizedRange = () => {
  const a = rangeAnchor;
  const f = rangeFocus;
  if (!a || !f) return null;
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return null;
  const ar = clamp(a.rowIndex, 0, maxRow);
  const ac = clamp(a.colIndex, 0, maxCol);
  const fr = clamp(f.rowIndex, 0, maxRow);
  const fc = clamp(f.colIndex, 0, maxCol);
  return {
    r0: ar < fr ? ar : fr,
    r1: ar > fr ? ar : fr,
    c0: ac < fc ? ac : fc,
    c1: ac > fc ? ac : fc
  };
};
// B10: escape a TSV field per the spreadsheet convention — a field containing a tab, a CR/LF,
// or a double-quote is wrapped in double-quotes with internal quotes DOUBLED; an ordinary
// field is emitted verbatim. parseTsv() unescapes symmetrically, so a cell carrying a tab /
// newline / quote round-trips without smearing into adjacent cells (T-63-03-02).
const escapeTsvField = (s: any) => {
  if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
    return '"' + s.replace(/"/g, '""') + '"';
  }
  return s;
};
// rangeToTsv(): serialize the current range to TSV — rows joined by '\n', cells by '\t',
// reading each cell's value off the visible model by index (cellValueAt). A single active
// cell (no range) serializes that one cell. Each field is B10-escaped. Pure read — never writes.
const rangeToTsv = () => {
  const box = normalizedRange();
  const r0 = box ? box.r0 : activeRow;
  const r1 = box ? box.r1 : activeRow;
  const c0 = box ? box.c0 : activeColIndex;
  const c1 = box ? box.c1 : activeColIndex;
  const lines = [];
  for (let r = r0; r <= r1; r++) {
    const cells = [];
    for (let c = c0; c <= c1; c++) {
      const v = cellValueAt(r, c);
      cells.push(escapeTsvField(v == null ? '' : String(v)));
    }
    lines.push(cells.join('\t'));
  }
  return lines.join('\n');
};
// parseTsv(text): a TSV string → string[][] (rows of cells). Tolerates \r\n; a trailing
// newline does not add a phantom empty row. Pure — produces plain string DATA only (T-51-01:
// the cells are NEVER eval'd / interpolated into a selector / rendered as markup).
const parseTsv = (text: any) => {
  const str = text != null ? String(text) : '';
  // CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
  // large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
  // single-pass scan allocates a cell-per-character grid.
  if (str === '' || str.length > 2000000) return [];
  // B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
  // cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
  // newlines, and doubled quotes ("") inside it are literal content until the closing quote;
  // an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
  const rows = [];
  let row = [];
  let field = '';
  let inQuotes = false;
  let i = 0;
  const n = str.length;
  while (i < n) {
    const ch = str[i];
    if (inQuotes) {
      if (ch === '"') {
        if (i + 1 < n && str[i + 1] === '"') {
          field = field + '"';
          i = i + 2;
          continue;
        }
        inQuotes = false;
        i = i + 1;
        continue;
      }
      field = field + ch;
      i = i + 1;
      continue;
    }
    if (ch === '"' && field === '') {
      inQuotes = true;
      i = i + 1;
      continue;
    }
    if (ch === '\t') {
      row.push(field);
      field = '';
      i = i + 1;
      continue;
    }
    if (ch === '\r') {
      if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
      row.push(field);
      field = '';
      rows.push(row);
      row = [];
      i = i + 1;
      continue;
    }
    if (ch === '\n') {
      row.push(field);
      field = '';
      rows.push(row);
      row = [];
      i = i + 1;
      continue;
    }
    field = field + ch;
    i = i + 1;
  }
  // Flush the trailing field + row.
  row.push(field);
  rows.push(row);
  // Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
  if (rows.length > 1) {
    const last = rows[rows.length - 1];
    if (last.length === 1 && last[0] === '') rows.pop();
  }
  return rows;
};
// copyRange(): write the current range as TSV to the clipboard (async). No-op when the
// async Clipboard API is unavailable (older/insecure contexts) — a copy is best-effort.
const copyRange = () => {
  // B11: never copy from a header-active state (the reusable clipboard guard).
  if (!clipboardActiveAllowed()) return;
  if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
  try {
    const p = navigator.clipboard.writeText(rangeToTsv());
    if (p && p.catch) p.catch(() => {});
  } catch (err: any) {/* best-effort copy */}
};
// applyGridToRange(grid, originRow, originCol): the SHARED write path for paste + fill. Walks
// the grid (string[][]) anchored at (originRow, originCol), CLAMPED to the grid bounds (no
// unbounded loop — T-51-02). For each target cell: count it (total); SKIP if the column is
// non-editable (D-03) or the per-column validator rejects the value (D-03, T-51-01 — the
// value passes runValidator as plain string DATA before any write); else stage it into ONE
// running fresh array (replaceRowValue) and record the committed cell. After the walk: ONE
// writeData (the single r-model:data write), ONE cell-edit-commit per COMMITTED cell, and the
// N-of-M aria-live announce. Returns { wrote, total }.
const applyGridToRange = (grid: any, originRow: any, originCol: any) => {
  const maxRow = bodyRowCount() - 1;
  const maxCol = visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return {
    wrote: 0,
    total: 0
  };
  let total = 0;
  let wrote = 0;
  const committed = [];
  // Build the fresh data array incrementally so the whole paste is ONE writeData.
  let next = currentData();
  for (let gr = 0; gr < grid.length; gr++) {
    const r = originRow + gr;
    if (r > maxRow) break;
    const cols = grid[gr] || [];
    for (let gc = 0; gc < cols.length; gc++) {
      const c = originCol + gc;
      if (c > maxCol) break;
      total = total + 1;
      const colId = columnIdAt(r, c);
      if (colId == null || !columnEditable(colId)) continue;
      const rowObj = rowOriginalAt(r);
      // B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
      // single-cell commit coercion) — a numeric column commits a real Number, an empty cell
      // commits null; every other editor type passes through verbatim. No mixed/garbage types
      // ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
      const value = coerceCellValue(colId, cols[gc]);
      // T-51-01: validate the pasted value as plain DATA before any write.
      if (runValidator(colId, value, rowObj) !== true) continue;
      const field = fieldOfColId(colId);
      const srcIndex = sourceIndexOfRow(r);
      const oldValue = rowObj ? rowObj[field] : null;
      next = replaceRowValue(next, srcIndex, field, value);
      committed.push({
        rowId: rowIdAt(r),
        columnId: colId,
        oldValue,
        newValue: value
      });
      wrote = wrote + 1;
    }
  }
  if (wrote > 0) {
    editTransition = true;
    writeData(next);
    editTransition = false;
    // One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
    for (let i = 0; i < committed.length; i++) oncelleditcommit?.(committed[i]);
  }
  // WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
  // targeted real cells but every one was skipped (validation-failed / non-editable), announce a
  // distinct validation-failed message instead of a misleading "0 of M cells pasted".
  if (wrote > 0) announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) announce('No cells pasted — ' + total + ' cells were invalid or read-only');
  return {
    wrote,
    total
  };
};
// rowOriginalAt / rowIdAt: the underlying row object / id at a visible-model body index.
const rowOriginalAt = (rowIndex: any) => {
  const rowList = rows || [];
  const row = rowList[rowIndex];
  return row ? row.original : null;
};
const rowIdAt = (rowIndex: any) => {
  const rowList = rows || [];
  const row = rowList[rowIndex];
  return row ? row.id : null;
};
// C3: tile a parsed clipboard `grid` (string[][]) to fill a destination `box` — the spreadsheet
// paste-into-range semantics. The target rectangle is the MAX of the box dims and the source
// dims per axis, so a SMALLER clipboard TILES across a LARGER selection (a single 1×1 cell fills
// the whole range; a 2×2 block repeats — tiled[dr][dc] = src[dr % srcRows][dc % srcCols]), while a
// clipboard LARGER than the selection pastes its full block from the top-left (preserving the
// no-range "clipboard-sized block at the active cell" behavior — a 1×1 destBox + a 1×N clipboard
// yields the full 1×N block, byte-for-byte the prior path). Pure — returns a fresh grid; applies
// nothing. A ragged/short source row defaults the missing cell to '' (coerced per column on write).
const tileGridToBox = (grid: any, box: any) => {
  const srcRows = grid.length;
  // srcCols is the MAX row width across ALL rows (not grid[0].length): a RAGGED clipboard
  // (a later row WIDER than the first, e.g. TSV "a\tb\nc\td\te") would otherwise never read
  // the extra column and silently drop those cells. A row SHORTER than srcCols tiles its
  // missing cells as '' (the `v != null ? v : ''` coercion below), never undefined.
  let srcCols = 0;
  for (let i = 0; i < srcRows; i++) {
    const w = grid[i] && grid[i].length ? grid[i].length : 0;
    if (w > srcCols) srcCols = w;
  }
  if (srcRows <= 0 || srcCols <= 0) return grid;
  const boxRows = box.r1 - box.r0 + 1;
  const boxCols = box.c1 - box.c0 + 1;
  const rows = boxRows > srcRows ? boxRows : srcRows;
  const cols = boxCols > srcCols ? boxCols : srcCols;
  const out = [];
  for (let r = 0; r < rows; r++) {
    const srcLine = grid[r % srcRows] || [];
    const line = [];
    for (let c = 0; c < cols; c++) {
      const v = srcLine[c % srcCols];
      line.push(v != null ? v : '');
    }
    out.push(line);
  }
  return out;
};
// pasteRange(): read TSV from the clipboard (async), parse it, TILE it over the destination
// (C3), and apply it anchored at the destination top-left under the D-03 skip rule. The grid is
// clamped to the grid bounds (T-51-02). A failed/empty read is a silent no-op.
const pasteRange = () => {
  // B11: never paste into a header-active state (the reusable clipboard guard) — a header
  // anchor would silently write body row 0 at the header's column.
  if (!clipboardActiveAllowed()) return;
  if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
  // CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
  // C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
  // single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
  // on React; re-reading them inside the async .then() returns the mount-render stale value, so a
  // selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
  // the box + anchor now and pass them into tileGridToBox / applyGridToRange.
  const box = normalizedRange();
  const anchorRow = box ? box.r0 : activeRow;
  const anchorCol = box ? box.c0 : activeColIndex;
  const destBox = box || {
    r0: anchorRow,
    r1: anchorRow,
    c0: anchorCol,
    c1: anchorCol
  };
  let p: any = null;
  try {
    p = navigator.clipboard.readText();
  } catch (err: any) {
    return;
  }
  if (!p || !p.then) return;
  p.then((text: any) => {
    const grid = parseTsv(text);
    if (!grid.length) return;
    // C3: tile the clipboard block to fill the destination range (single→range fill,
    // smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
    const tiled = tileGridToBox(grid, destBox);
    applyGridToRange(tiled, anchorRow, anchorCol);
  }).catch(() => {});
};
// cutRange(): C3 Cut — copy the current range to the clipboard (rangeToTsv — the SAME escaped
// serialization copyRange uses) THEN CLEAR the source cells through the SAME write-funnel as
// paste/fill: applyGridToRange of an empty-string grid sized to the range → coerceCellValue('')
// per column (null on a numeric column, '' on text) + the D-03 editable/validator skip rule +
// ONE writeData + one cell-edit-commit per cleared cell + the N-of-M announce. A read-only /
// required cell is left intact (the funnel skips it). B11: a no-op while a header cell is active
// (reuses clipboardActiveAllowed — Cut can never silently clear a body cell from a header anchor).
// The clear is SYNCHRONOUS and runs AFTER rangeToTsv has already serialized, so the copy reads the
// pre-clear values; the clipboard write is best-effort/async and never blocks the clear.
const cutRange = () => {
  if (!clipboardActiveAllowed()) return;
  // Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
  const box = normalizedRange();
  const r0 = box ? box.r0 : activeRow;
  const r1 = box ? box.r1 : activeRow;
  const c0 = box ? box.c0 : activeColIndex;
  const c1 = box ? box.c1 : activeColIndex;
  // Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
  if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
    try {
      const cp = navigator.clipboard.writeText(rangeToTsv());
      if (cp && cp.catch) cp.catch(() => {});
    } catch (err: any) {/* best-effort copy */}
  }
  // Clear the source: a grid of empty strings sized to the range, applied at the top-left.
  const grid = [];
  for (let r = r0; r <= r1; r++) {
    const cols = [];
    for (let c = c0; c <= c1; c++) cols.push('');
    grid.push(cols);
  }
  applyGridToRange(grid, r0, c0);
};
// clearActiveRange(): the §7 (260709-3qt) Delete/Backspace clear — cutRange() MINUS the clipboard
// copy. Clears the active cell / selected range through the SAME write-funnel as Cut/paste/fill:
// applyGridToRange of an empty-string grid sized to the range → coerceCellValue('') per column
// (null on a numeric column, '' on text) + the D-03 editable/validator/read-only skip rule + ONE
// writeData + one cell-edit-commit per cleared cell + the N-of-M announce. B11: a no-op while a
// header cell is active (reuses clipboardActiveAllowed — Delete can never silently clear a body
// cell from a header anchor). NO undo — the grid is controlled (writeData → $model.data; every
// clear fires cell-edit-commit), so undo is the consumer's responsibility, the SAME contract
// Cut/Paste/Fill already carry (design §7, approved 2026-07-09).
const clearActiveRange = () => {
  if (!clipboardActiveAllowed()) return;
  // Snapshot the source rectangle synchronously (the ROZ138 concern cutRange/pasteRange share).
  const box = normalizedRange();
  const r0 = box ? box.r0 : activeRow;
  const r1 = box ? box.r1 : activeRow;
  const c0 = box ? box.c0 : activeColIndex;
  const c1 = box ? box.c1 : activeColIndex;
  const grid = [];
  for (let r = r0; r <= r1; r++) {
    const cols = [];
    for (let c = c0; c <= c1; c++) cols.push('');
    grid.push(cols);
  }
  applyGridToRange(grid, r0, c0);
};
// tileIndex(i, lo, hi): map an index into the inclusive [lo,hi] source span by TILING (repeat
// the source block), handling indices below lo (negative offset) correctly. A 1-wide source
// (lo===hi) always returns lo. Used by fillRange to resolve, per target cell, WHICH source
// cell it copies — so each column copies its OWN source value down its OWN column.
const tileIndex = (i: any, lo: any, hi: any) => {
  const span = hi - lo + 1;
  if (span <= 1) return lo;
  let k = (i - lo) % span;
  if (k < 0) k = k + span;
  return lo + k;
};
// fillRange(sourceBox): drag-fill (D-04 — VALUE-COPY ONLY, no series detection). B7: the fill
// SOURCE is the PRE-DRAG rectangle (`sourceBox`, captured at pointerdown before the drag grew
// the range); each target cell copies the source cell in its OWN column (and row, when the
// source spans rows), TILED across the source dimensions. This fixes two data-loss bugs: (1) a
// single-scalar broadcast clobbered the other columns' data, and (2) reading box.r0/box.c0
// flipped to the WRONG corner on an up/left drag (the box top-left is a TARGET cell there, not
// the source). `sourceBox` falls back to the box's top-left 1×1 for a no-source fill. Honors the
// SAME editable + validation + type-coercion skip rule as paste (via applyGridToRange): one
// writeData + one cell-edit-commit per committed cell + the N-of-M announce. No-op without a range.
const fillRange = (sourceBox: any, endCell: any) => {
  // B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
  // the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
  // React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
  // grows), so deriving the box from the threaded endpoints is what makes the fill cover the
  // dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
  let box;
  if (sourceBox && sourceBox.r0 != null && endCell) {
    let r0 = sourceBox.r0;
    let r1 = sourceBox.r1;
    let c0 = sourceBox.c0;
    let c1 = sourceBox.c1;
    if (endCell.r < r0) r0 = endCell.r;
    if (endCell.r > r1) r1 = endCell.r;
    if (endCell.c < c0) c0 = endCell.c;
    if (endCell.c > c1) c1 = endCell.c;
    box = {
      r0,
      r1,
      c0,
      c1
    };
  } else {
    box = normalizedRange();
  }
  if (!box) return;
  const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
    r0: box.r0,
    r1: box.r0,
    c0: box.c0,
    c1: box.c0
  };
  const grid = [];
  for (let r = box.r0; r <= box.r1; r++) {
    const cols = [];
    for (let c = box.c0; c <= box.c1; c++) {
      const sr = tileIndex(r, src.r0, src.r1);
      const sc = tileIndex(c, src.c0, src.c1);
      const v = cellValueAt(sr, sc);
      cols.push(v == null ? '' : String(v));
    }
    grid.push(cols);
  }
  applyGridToRange(grid, box.r0, box.c0);
};
// onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
// range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
// cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
// pointermove extends the range to the cell under the pointer; pointerup commits the fill.
let fillDragging = false;
// CR-04: track the live fill-drag document listeners in module-lets so $onUnmount can remove
// them if the component unmounts MID-DRAG (the `up` handler clears them on a normal release,
// but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
let fillDragMove: any = null;
let fillDragUp: any = null;
const teardownFillDrag = () => {
  if (typeof document !== 'undefined') {
    if (fillDragMove) document.removeEventListener('pointermove', fillDragMove);
    if (fillDragUp) document.removeEventListener('pointerup', fillDragUp);
  }
  fillDragMove = null;
  fillDragUp = null;
  fillDragging = false;
};
const cellIndexFromPoint = (clientX: any, clientY: any) => {
  if (typeof document === 'undefined' || !document.elementFromPoint) return null;
  let el = document.elementFromPoint(clientX, clientY);
  // Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
  // a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
  // and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
  // deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
  while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
    const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
    if (!inner || inner === el) break;
    el = inner;
  }
  if (!el || !el.closest) return null;
  const cellEl = el.closest('[data-grid-cell]');
  if (!cellEl) return null;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
  const r = parseInt(rowAttr, 10);
  const c = parseInt(colAttr, 10);
  if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
  return {
    r,
    c
  };
};
const onFillHandlePointerDown = (e: any) => {
  if (!e) return;
  if (e.preventDefault) e.preventDefault();
  if (e.stopPropagation) e.stopPropagation();
  // #leak: tear down any orphaned PRIOR gesture BEFORE reassigning the module-let handlers. If a
  // pointerup was missed (pointer released off-window, context menu, alt-tab), the prior fillDrag's
  // document pointermove/pointerup stay attached; overwriting fillDragMove/fillDragUp below would
  // strand them (removeEventListener could never reach the old refs) → a permanent global
  // pointermove leak. teardownFillDrag is idempotent (no-op when nothing is attached).
  teardownFillDrag();
  fillDragging = true;
  // B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
  // range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
  // up/left drag copies from the real origin (not the post-drag corner that would flip to a
  // target cell). Captured per-gesture in the closure (no module-let needed).
  const sourceBox = normalizedRange();
  // B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
  // the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
  let lastCell = sourceBox ? {
    r: sourceBox.r1,
    c: sourceBox.c1
  } : null;
  const move = (ev: any) => {
    if (!fillDragging) return;
    const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
    // B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
    // pointermove (the pointer fires many per cell) spams the event with identical payloads.
    // Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
    // seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
    // or re-enters the same cell is suppressed (the range is unchanged).
    if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
      lastCell = cell;
      setRangeFocus(cell.r, cell.c);
    }
  };
  const up = () => {
    // teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
    teardownFillDrag();
    // A plain click on the fill handle (pointerdown+up with NO intervening drag) leaves lastCell
    // at the source box's own origin corner (r1,c1), so fillRange(sourceBox, corner) would
    // recommit the source range onto ITSELF — a no-op write that pollutes undo history and fires
    // spurious per-cell cell-edit-commit events (oldValue === newValue). Only fill when the drag
    // actually reached a cell past the source origin.
    if (lastCell && sourceBox && (lastCell.r !== sourceBox.r1 || lastCell.c !== sourceBox.c1)) {
      fillRange(sourceBox, lastCell);
    }
  };
  // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
  fillDragMove = move;
  fillDragUp = up;
  if (typeof document !== 'undefined') {
    document.addEventListener('pointermove', move);
    document.addEventListener('pointerup', up);
  }
};
// §6 (260709-3qt) drag-to-select — mirror the fill-drag listener discipline. rangeDragging gates
// the live gesture; rangeDragMove/rangeDragUp track the document pointermove/pointerup handlers so
// a mid-drag unmount ($onUnmount → teardownRangeDrag) can remove them (CR-04). rangeDragMoved flips
// true once the drag enters a DIFFERENT cell than its mousedown anchor; onGridClick reads it to
// suppress a singleClickEdit editor-open after a drag (reset per-gesture in beginRangeDrag). Each
// top-level let → React hoists to useRef.
let rangeDragging = false;
let rangeDragMove: any = null;
let rangeDragUp: any = null;
let rangeDragMoved = false;
// ══ Mouse drag-to-select (grid cell-interaction §6, 260709-3qt) ═════════════════════════
// A plain (non-shift) mousedown on a body cell begins a document-level drag: the FIRST
// pointermove that reaches a DIFFERENT body cell paints the range moving corner via the
// SHARED-scope setRangeFocus (the SAME range model shift+click / shift+arrow drive), pointerup
// ends it. Mirrors fillDrag.rzts's listener discipline VERBATIM (document pointermove/pointerup
// tracked in module-lets so a mid-drag unmount can remove them — CR-04), and REUSES fillDrag's
// shadow-piercing cellIndexFromPoint (shared scope) so the Lit shadow target is covered uniformly.

// teardownRangeDrag(): remove the live drag listeners, null them, clear the dragging flag. The
// `up` handler calls it on a normal release; $onUnmount calls it if we unmount MID-DRAG (mirrors
// teardownFillDrag). rangeDragMoved is NOT reset here — it is read by onGridClick AFTER pointerup
// (to suppress a singleClickEdit editor-open) and reset per-gesture in beginRangeDrag.
const teardownRangeDrag = () => {
  if (typeof document !== 'undefined') {
    if (rangeDragMove) document.removeEventListener('pointermove', rangeDragMove);
    if (rangeDragUp) document.removeEventListener('pointerup', rangeDragUp);
  }
  rangeDragMove = null;
  rangeDragUp = null;
  rangeDragging = false;
};
// beginRangeDrag(anchorR, anchorC): start a drag-select anchored at the mousedown cell. The
// mousedown's native focus/focusin already committed the ACTIVE cell to (anchorR, anchorC), so
// setRangeFocus (which seeds the anchor from the ACTIVE cell) spans mousedown-cell→pointer-cell —
// we NEVER write $data.rangeAnchor directly (it is React-stale, ROZ138). rangeDragMoved starts
// false and flips true only once the pointer reaches a DIFFERENT cell, so a mousedown-with-no-move
// leaves a single active cell + no range (a normal click). lastCell dedups the many pointermove
// events per cell (setRangeFocus emits range-change — only extend on a NEW cell, mirroring fillDrag's
// B20 dedup). Captured per-gesture in the closure (no module-let needed for lastCell).
const beginRangeDrag = (anchorR: any, anchorC: any) => {
  // #leak: tear down any orphaned PRIOR range gesture BEFORE reassigning the module-let handlers.
  // A missed pointerup (off-window release, context menu, alt-tab) leaves the prior drag's document
  // pointermove/pointerup attached; overwriting rangeDragMove/rangeDragUp below would strand them
  // (removeEventListener could never reach the old refs) → a permanent global pointermove leak.
  // teardownRangeDrag is idempotent (no-op when nothing is attached) and does NOT touch
  // rangeDragMoved, which is reset per-gesture immediately below.
  teardownRangeDrag();
  rangeDragging = true;
  rangeDragMoved = false;
  let lastCell = {
    r: anchorR,
    c: anchorC
  };
  const move = (ev: any) => {
    if (!rangeDragging) return;
    const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
    if (cell && (cell.r !== lastCell.r || cell.c !== lastCell.c)) {
      lastCell = cell;
      rangeDragMoved = true;
      setRangeFocus(cell.r, cell.c);
    }
  };
  const up = () => {
    // teardownRangeDrag clears rangeDragging + removes both listeners (the fill-drag CR-04 path).
    teardownRangeDrag();
  };
  // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
  rangeDragMove = move;
  rangeDragUp = up;
  if (typeof document !== 'undefined') {
    document.addEventListener('pointermove', move);
    document.addEventListener('pointerup', up);
  }
};
// ══ Editable-cell lifecycle (phase 51 plan 02 — RESEARCH Pattern 1/3/4/5) ════════════════
// Single-cell, non-virtual. Index-based state (editingRow/editingCol over the visible model),
// the display↔editor branch in the keyed <td>, F2/Enter/printable entry off the reserved
// onGridKeyDown seam, commit on Enter/Tab/blur, cancel+revert on Escape, sync validation with
// D-01 keep-open. All gated by columnEditable() / the editing index pair so a table with no
// editable columns lowers byte-identical (the editor branch r-if is always false).

// The column id at the active cell (the active row's visible cell list @ activeColIndex).
// Null when out of range (no body rows, or active cell is a header / select column).
const activeCellColumnId = () => {
  if (activeIsHeader) return null;
  const rowList = rows || [];
  const row = rowList[activeRow];
  if (!row) return null;
  const cells = visibleCellsFor(row);
  const cell = cells[activeColIndex];
  return cell && cell.column ? cell.column.id : null;
};
// isActiveCellEditable: the active cell sits in an editable column AND is a body cell
// (req-1). Gates the F2/Enter/printable edit-entry branches in onGridKeyDown; a
// non-editable active cell falls through to the reserved enterControl path.
const isActiveCellEditable = () => {
  const colId = activeCellColumnId();
  return colId != null && columnEditable(colId);
};
// isEditing: is the cell at (rowIndex, colIndex) over the visible model in edit? ONE
// predicate covers BOTH modes (RESEARCH Pattern 6):
//  - row mode (req-6): editingRowIndex === rowIndex AND the column at colIndex is editable —
//    so EVERY editable cell in the row enters edit simultaneously (the editor template branch
//    re-uses this gate verbatim, no template fork);
//  - single-cell mode (req-1/3): the editingRow/editingCol pair matches exactly.
// Pure index compare (editingRowIndex null + editingRow -1 = none) → the byte-identical-off
// guard for the editor template branch. $data.editVer is read first so the per-cell branch
// re-derives on Svelte/Solid when editing state mutates from a foreign slot-callback scope.
// Called per-cell in both <td> bodies with the body-specific row index (rowIndexOf(row)
// non-virtual, wr.vi.index virtual).
const isEditing = (rowIndex: any, colIndex: any) => {
  if (editVer < 0) return false;
  if (editingRowIndex != null && editingRowIndex === rowIndex) {
    const colId = columnIdAt(rowIndex, colIndex);
    return colId != null && columnEditable(colId);
  }
  return editingRow === rowIndex && editingCol === colIndex;
};
// cellAriaInvalid (req-5/D-01): the STRING 'true' ONLY for the editing cell while it holds
// an invalid value — drives :aria-invalid on the <td>. Returns null otherwise so the bound
// attribute DROPS (the rozieAttr nullish-attr path), keeping non-editing cells byte-clean.
// Returns the literal 'true' (NOT boolean true) so rozieAttr's string-literal-union preserve
// keeps React's aria-invalid (Booleanish incl. 'true') happy instead of widening to string.
const cellAriaInvalid = (rowIndex: any, colIndex: any): 'true' | null => isEditing(rowIndex, colIndex) && !!invalidMsg ? 'true' : null;
// runValidator: the sync per-column validator (req-5). Reads col.meta.validate; not a
// function → valid (true). Calls it (defensively wrapped — a thrown/non-true/non-string
// return coerces to a generic message so a misbehaving validator can never wedge the
// keymap, Security V5 DoS). A string return is the error message (commit rejected, D-01).
const runValidator = (colId: any, value: any, row: any) => {
  const m = editMetaOf(colId);
  const v = m ? m.validate : null;
  if (typeof v !== 'function') return true;
  let r: any = null;
  try {
    r = v(value, row);
  } catch (err: any) {
    return 'Invalid value';
  }
  if (r === true) return true;
  if (typeof r === 'string') return r;
  return 'Invalid value';
};
// setInvalid: record the current validation error (drives the aria-live region +
// :aria-invalid wired in Task 3). Empty string clears it.
const setInvalid = (msg: any) => {
  invalidMsg = msg != null ? msg : '';
};
// replaceRowValue: build a FRESH array with ONE row object replaced (the column's field
// set to the new value); the rest share by reference (the family immutable whole-array
// replace — in-place mutation is silently dropped on React/Solid/Angular/Lit). rowIndex
// is over currentData() (== the visible model order for the non-virtual, unsorted/
// unfiltered single-cell case; the row id is carried for the commit payload).
const replaceRowValue = (rows: any, rowIndex: any, field: any, value: any) => {
  const src = rows || [];
  const out = [];
  for (let i = 0; i < src.length; i++) {
    if (i === rowIndex) {
      // WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
      // and would copy inherited enumerable props of typed/class-instance row objects.
      out.push({
        ...(src[i] || {}),
        [field]: value
      });
    } else {
      out.push(src[i]);
    }
  }
  return out;
};
// Map a visible-model body-row index ($data.rows index) to its underlying currentData()
// index via the row's original object identity (sorting/filtering/pagination may reorder
// the visible model away from the source array order). Falls back to the same index.
const sourceIndexOfRow = (visibleRowIndex: any) => {
  const rowList = rows || [];
  const row = rowList[visibleRowIndex];
  if (!row) return visibleRowIndex;
  const orig = row.original;
  const data = currentData() || [];
  const idx = data.indexOf(orig);
  return idx >= 0 ? idx : visibleRowIndex;
};
// The column id / field (accessorKey) / current value / row object / row id for the cell
// in EDIT — keyed off the authoritative editing pair ($data.editingRow/editingCol), NOT
// the active-cell indices (which can drift from the editing cell on a Tab-advance, and are
// async-stale right after a setState on React — ROZ138). Called only from commitEdit.
const editingColumnId = () => {
  const rowList = rows || [];
  const row = rowList[editingRow];
  if (!row) return null;
  const cells = visibleCellsFor(row);
  const cell = cells[editingCol];
  return cell && cell.column ? cell.column.id : null;
};
const editingColumnField = () => {
  const colId = editingColumnId();
  if (colId == null) return null;
  const d = defFor(colId);
  return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};
const editingCellValue = () => {
  const rowList = rows || [];
  const row = rowList[editingRow];
  if (!row) return null;
  const cells = visibleCellsFor(row);
  const cell = cells[editingCol];
  return cell ? cell.getValue() : null;
};
const editingRowOriginal = () => {
  const rowList = rows || [];
  const row = rowList[editingRow];
  return row ? row.original : null;
};
const editingRowId = () => {
  const rowList = rows || [];
  const row = rowList[editingRow];
  return row ? row.id : null;
};
// Focus the freshly-mounted editor (Pitfall 1, ROZ123): after beginEdit flips the editing
// state, the editor <input> does not exist until the framework commits the r-if branch
// (React setState async; Solid/Lit/Svelte next reactive tick). Poll for the
// [data-editing-cell] element off gridRoot for ~30 frames — the five fast targets resolve
// on attempt 1, React retries across its async commit. NEVER read $refs eagerly.
// B2: selectAll gates the post-focus el.select(). Select-all is right when entering
// edit IN PLACE (F2/Enter/click/row-edit/validation-reject — no seeded char, the user
// retypes), but WRONG on a type-to-edit entry where a printable key already seeded the
// draft (selecting the seeded char makes the next keystroke replace it: Zeta → eta).
// beginEdit threads `seed == null` so a seeded entry skips the select and the caret sits
// AFTER the seeded char; every other caller keeps the default select-all.
// Editor-owns-focus contract (quick 260711-i5m): REVERTS the g52 shadow-piercing helper
// (commit 5fa30045) that recursed into descendant shadow roots. Built-in editors are
// host-DOM — the plain direct query resolves them on all 6 targets (no shadow to cross). A
// #editor DROP-IN now owns its OWN focus via the reactive `autofocus` prop (EditorText's
// $onMount + lazy $watch), so the host never needs to reach across a Lit drop-in's nested
// shadow root at all — see the !hasEditorSlot gate below, which skips the host focus call
// entirely for a drop-in target.
const focusEditorWhenReady = (selectAll = true) => {
  if (!gridRoot) return;
  // Editor-owns-focus contract: when the CURRENT focus target is a #editor drop-in, the host
  // does NOT reach into its DOM — the drop-in self-focuses via its own autofocus prop.
  if (editFocusColId != null && hasEditorSlot(editFocusColId)) return;
  let attempts = 0;
  const tryFocus = () => {
    const el = gridRoot ? gridRoot.querySelector('[data-editing-cell]') : null;
    // Do NOT stomp focus a later interaction already placed in a DIFFERENT column's editor of
    // this row: focusEditorWhenReady only needs to get focus INTO the (first) freshly-mounted
    // editor; if focus already sits in another editable cell, a late rAF re-focus would steal it
    // back to the first editor and break row-mode Tab containment (the non-deterministic B21
    // focus-theft). Compare the OWNING cell's data-col-index (NOT node identity) so a stale
    // SAME-column editor node on Solid's node-replacing re-render still resolves as the target —
    // a genuinely dropped focus is still recovered.
    const ae = gridRoot && gridRoot.getRootNode ? gridRoot.getRootNode().activeElement : null;
    if (ae && el && ae !== el && ae.closest && gridRoot.contains(ae) && ae.hasAttribute && ae.hasAttribute('data-editing-cell')) {
      const aeCell = ae.closest('[data-grid-cell]');
      const elCell = el.closest ? el.closest('[data-grid-cell]') : null;
      const aeCol = aeCell ? aeCell.getAttribute('data-col-index') : null;
      const elCol = elCell ? elCell.getAttribute('data-col-index') : null;
      if (aeCol != null && aeCol !== elCol) return;
    }
    if (el) {
      el.focus();
      if (selectAll && el.select) {
        try {
          el.select();
        } catch (e: any) {}
      }
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// Column id + current value at an EXPLICIT (rowIndex, colIndex) over the visible model —
// used by beginEdit so it never re-reads $data.activeRow/activeColIndex (which are async-
// stale right after a Tab-advance sets them on React — ROZ138).
const columnIdAt = (rowIndex: any, colIndex: any) => {
  const rowList = rows || [];
  const row = rowList[rowIndex];
  if (!row) return null;
  const cells = visibleCellsFor(row);
  const cell = cells[colIndex];
  return cell && cell.column ? cell.column.id : null;
};
const cellValueAt = (rowIndex: any, colIndex: any) => {
  const rowList = rows || [];
  const row = rowList[rowIndex];
  if (!row) return null;
  const cells = visibleCellsFor(row);
  const cell = cells[colIndex];
  return cell ? cell.getValue() : null;
};
// beginEdit: open the editor on the (rowIndex, colIndex) cell (req-1/3, D-05). seed===null
// → seed the EXISTING value (F2/Enter in-place edit); a printable char → REPLACE (the
// editor opens holding just that char). Resolves the column from the PASSED indices (not
// $data) so a Tab-advance that just setState'd activeRow/Col works on React. Clears any
// prior invalid state. Focus moves into the editor.
const beginEdit = (rowIndex: any, colIndex: any, seed: any) => {
  const colId = columnIdAt(rowIndex, colIndex);
  if (colId == null || !columnEditable(colId)) return;
  // A new edit session starts — reset the sync idempotency latch so THIS session's eventual
  // commit is not silently no-op'd by a PRIOR session's already-set latch.
  committedThisSession = false;
  setInvalid('');
  // Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
  // editor clears any row-edit state so isEditing never resolves both modes for one cell.
  editingRowIndex = null;
  rowDraft = {};
  editingRow = rowIndex;
  editingCol = colIndex;
  draftValue = seed != null ? seed : cellValueAt(rowIndex, colIndex);
  activeInControl = true;
  editVer = editVer + 1;
  // Editor-owns-focus contract (quick 260711-i5m): THIS cell's column is the current
  // focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope prop
  // from it. Cleared on endEdit.
  editFocusColId = colId;
  // B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
  // seeded char so subsequent typing appends instead of replacing it.
  focusEditorWhenReady(seed == null);
};
// Return focus to a body cell AFTER the editor unmounts (commit/cancel). The display↔
// editor re-render must commit before the <td> is focusable with its roving tabindex —
// on React/Solid/Lit that commit is async, so a synchronous focusActiveCell can run while
// the cell is still the editor (or mid-swap) and focus is lost. Bounded rAF-poll resolves
// the [data-row][data-col-index] cell off gridRoot for ~30 frames (the fast targets land
// on attempt 1; React/Solid retry across the async commit). Mirrors focusEditorWhenReady.
const focusCellWhenReady = (row: any, col: any) => {
  if (!gridRoot) return;
  let attempts = 0;
  const tryFocus = () => {
    const el = resolveCellEl(String(row), col);
    if (el) {
      el.focus();
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// B23: the index of a committed row WITHIN a given (fresh) visible-model array, resolved by
// row IDENTITY. table-core's default getRowId is source-index-based, so a row's id is stable
// across a re-sort (only its VISIBLE position moves); a committed edit replaces the row object
// via a fresh spread (the `original` reference changes), so match by `id` FIRST, `original`
// only as a fallback. Returns -1 when the row filtered out of the view. PURE (the caller passes
// the FRESH row list — refreshRowModel's just-pulled `nextRows`, never the React-stale state).
const indexOfRowIn = (rows: any, rowOriginal: any, rowId: any) => {
  const list = rows || [];
  for (let i = 0; i < list.length; i++) {
    const r = list[i];
    if (!r) continue;
    if (rowId != null && r.id === rowId) return i;
    if (rowOriginal != null && r.original === rowOriginal) return i;
  }
  return -1;
};
// endEdit: tear down the editor (shared by commit/cancel). Clears the editing pair +
// draft + invalid state and returns to navigation mode. Does NOT move focus (callers
// decide where focus lands — commit/cancel return it to the owning cell).
const endEdit = () => {
  editingRow = -1;
  editingCol = -1;
  draftValue = null;
  invalidMsg = '';
  activeInControl = false;
  editVer = editVer + 1;
  editFocusColId = null;
};
// endRowEdit: tear down full-row edit (shared by commitRow/cancelRow). Clears the row
// index + the per-cell drafts + invalid state and returns to navigation mode. Does NOT
// move focus (callers return it to the active cell). Mirrors endEdit for the row mode.
const endRowEdit = () => {
  editingRowIndex = null;
  rowDraft = {};
  invalidMsg = '';
  activeInControl = false;
  editVer = editVer + 1;
  editFocusColId = null;
};
// editorAutofocusFor (quick 260711-i5m, editor-owns-focus contract): the reactive `autofocus`
// #editor scope prop for a given (colId, rowIndex) — true for EXACTLY the current focus-
// target cell, re-deriving on every editVer bump (mirrors isEditing's reactive gate so
// Svelte/Solid re-run this per-cell on a foreign-slot-callback state mutation). Works for
// BOTH single-cell ($data.editingRow) and row mode ($data.editingRowIndex) since
// $data.editFocusColId is set by both beginEdit and beginRowEdit/commitRow/rowEditTab.
const editorAutofocusFor = (colId: any, rowIndex: any) => {
  if (editVer < 0) return false;
  if (editingRowIndex != null) {
    if (editingRowIndex !== rowIndex) return false;
  } else {
    if (editingRow !== rowIndex) return false;
  }
  return editFocusColId != null && editFocusColId === colId;
};
// B3: coerce the committed value by the column's built-in editor type at the single
// commit funnel. A 'number' editor commits a real Number; an empty/whitespace/non-numeric
// draft commits null (never '' / never NaN — Number('') === 0 is a silent footgun). Every
// other editor type commits the value verbatim. Idempotent for the #editor drop-in path
// (an already-numeric override passes through; an explicit null stays null).
const coerceCellValue = (colId: any, raw: any) => {
  if (editorTypeOf(colId) !== 'number') return raw;
  if (raw == null) return null;
  if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
  const s = String(raw).trim();
  if (s === '') return null;
  const n = Number(s);
  return Number.isNaN(n) ? null : n;
};
// commitEdit: validate the draft (req-5); on success replace one row in a fresh array,
// funnel it through writeData (the controlled r-model:data write, req-4), emit EXACTLY
// ONE cell-edit-commit from THIS single call site (React multi-emit dedup, D-07), then
// return focus to the cell. On a validation FAILURE keep the editor OPEN (D-01) — set
// invalid, re-trap focus, never write the model. Captures the optional override value
// (the #editor slot's commit(v) call) else the live draft.
// Returns true when the commit succeeded (model written, editor closed); false when a
// validation failure kept the editor OPEN (D-01). Callers MUST use this return value, not
// a synchronous re-read of $data.editingRow — React's endEdit setState is async, so an
// immediate re-read of editingRow still shows the OLD value (the ROZ138 stale-read class).
const commitEdit = (overrideValue = undefined, skipFocusReturn = false) => {
  if (editingRow < 0) return false;
  // Sync idempotency latch (drop-in double cell-edit-commit fix): a second commitEdit call
  // within the SAME edit session — the deferred drop-in's unmount-blur re-entry, which on
  // React fires while $data.editingRow is still async-stale ≥ 0 — no-ops here instead of
  // re-validating/re-writing/re-emitting. Reset by beginEdit/beginRowEdit/editCell.
  if (committedThisSession) return false;
  const colId = editingColumnId();
  if (colId == null) {
    endEdit();
    return false;
  }
  const field = editingColumnField();
  const oldValue = editingCellValue();
  const rowOriginal = editingRowOriginal();
  const rowId = editingRowId();
  // B3: coerce by the column's editor type BEFORE validation + write so the validator
  // and the model both see the typed value (number/null), not the raw draft string.
  const rawValue = overrideValue !== undefined ? overrideValue : draftValue;
  const newValue = coerceCellValue(colId, rawValue);
  const err = runValidator(colId, newValue, rowOriginal);
  if (err !== true) {
    // D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
    setInvalid(err);
    focusEditorWhenReady();
    return false;
  }
  setInvalid('');
  // #5: a no-op commit (the coerced value is UNCHANGED — a bare Enter/Tab/blur that edited
  // nothing) must do NO model write, NO history record, and NO commit event: writeData →
  // recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row identity, so an
  // unconditional write on a no-op would destroy redo + spuriously re-render + emit a no-op
  // cell-edit-commit. Compute `changed` and gate the write/emit on it; ALWAYS close the editor.
  const changed = !Object.is(newValue, oldValue);
  // Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
  const focusRow = editingRow;
  const focusCol = editingCol;
  // Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
  // must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
  editTransition = true;
  // Sync idempotency latch: flip BEFORE writeData/endEdit so the async unmount-blur re-entry
  // (which fires AFTER this call returns, once editTransition is already back to false) finds
  // it set at the top-of-function guard above and no-ops. Set on BOTH paths so a no-op commit
  // is just as re-entry-safe as a real one.
  committedThisSession = true;
  if (changed) {
    const srcIndex = sourceIndexOfRow(editingRow);
    const next = replaceRowValue(currentData(), srcIndex, field, newValue);
    writeData(next);
    // Exactly one emit per commit, from this single call site (writeData does NOT emit).
    oncelleditcommit?.({
      rowId,
      columnId: colId,
      oldValue,
      newValue
    });
  }
  endEdit();
  editTransition = false;
  if (changed) {
    // Defer the focus return so the display↔editor re-render commits first (async on
    // React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
    // editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
    // Tab-advance (the caller immediately opens the next editor and focuses THAT).
    // B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
    // RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
    // index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
    // consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
    // fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
    // active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
    // index → byte-behaviorally identical to before.
    if (skipFocusReturn !== true) pendingEditFollow = {
      rowOriginal,
      rowId,
      col: focusCol
    };
  } else if (skipFocusReturn !== true) {
    // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume
    // a pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
    // relocate (no write), so the B23 relocation hazard that forces the pendingEditFollow path on
    // a real commit does not apply here: the fixed (focusRow, focusCol) is correct and safe.
    focusCellWhenReady(focusRow, focusCol);
  }
  return true;
};
// toggleActiveBooleanCell (design doc 2026-07-05, Change 1): the spreadsheet-standard
// single-keystroke boolean toggle. Flips the ACTIVE cell's value and commits it through the
// EXACT SAME write funnel commitEdit uses (replaceRowValue → writeData → single $emit) but
// WITHOUT opening an editor — there is no editingRow/editingCol involvement at all, so this
// operates entirely off $data.activeRow/activeColIndex. Gated in onGridKeyDown to
// editor:'checkbox' columns only (Space/Enter/F2), full-row edit mode is unaffected (the
// editingRowIndex early return in onGridKeyDown already excludes it).
const toggleActiveBooleanCell = () => {
  const colId = columnIdAt(activeRow, activeColIndex);
  if (colId == null || !columnEditable(colId)) return;
  const rowList = rows || [];
  const row = rowList[activeRow];
  if (!row) return;
  const rowOriginal = row.original;
  const rowId = row.id;
  const oldValue = cellValueAt(activeRow, activeColIndex);
  const newValue = !oldValue;
  // D-01: same discipline as commitEdit — a rejecting validator blocks the toggle. There is
  // no editor to keep open here, so the toggle simply does not apply (no model write).
  const err = runValidator(colId, newValue, rowOriginal);
  if (err !== true) {
    setInvalid(err);
    return;
  }
  setInvalid('');
  const def = defFor(colId);
  const field = def && def.accessorKey != null ? def.accessorKey : colId;
  const srcIndex = sourceIndexOfRow(activeRow);
  // Sync idempotency latch: this toggle is a commit-equivalent (mirrors commitEdit's D-07
  // single-emit discipline) — flip it too so a stray re-entry after this toggle no-ops.
  committedThisSession = true;
  writeData(replaceRowValue(currentData(), srcIndex, field, newValue));
  // Exactly one emit per toggle, from this single call site (writeData does NOT emit) —
  // mirrors commitEdit's D-07 single-emit discipline.
  oncelleditcommit?.({
    rowId,
    columnId: colId,
    oldValue,
    newValue
  });
  // Follow the toggled row's focus through a boolean sort/filter relocation AND a
  // fine-grained keyed-row replace (Solid) — the SAME recovery commitEdit relies on; even
  // with no editor to unmount, writeData's re-render can still drop focus.
  pendingEditFollow = {
    rowOriginal,
    rowId,
    col: activeColIndex
  };
};
// cancelEdit: discard the draft (D-05 — revert to the pre-edit value, no model write) and
// return focus to the owning cell.
const cancelEdit = () => {
  if (editingRow < 0) return;
  // CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
  // Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
  // an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
  // instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
  const focusRow = editingRow;
  const focusCol = editingCol;
  editTransition = true;
  endEdit();
  editTransition = false;
  focusCellWhenReady(focusRow, focusCol);
};
// ══ Full-row edit lifecycle (phase 51 plan 03 / req-6 / D-06, RESEARCH Pattern 6) ════════
// Shift+F2 (and the editRow $expose verb) put EVERY editable cell in the active row into
// edit at once; one save commits the whole row in ONE writeData (a single fresh-array row
// replace) + ONE row-edit-commit event; Escape reverts the whole row as a unit. Per-column
// validation still runs on each edited cell at commit (D-01 keep-open if ANY fails). The
// editor template branch (isEditing's row arm) is re-used verbatim — no per-mode fork.

// The editable [columnId, field] pairs for a body row at the given visible-model index,
// in visible-cell order. field is the column's accessorKey (the row-object key to write).
const editableColumnsForRow = (rowIndex: any) => {
  const rowList = rows || [];
  const row = rowList[rowIndex];
  if (!row) return [];
  const cells = visibleCellsFor(row);
  const out = [];
  for (let c = 0; c < cells.length; c++) {
    const cell = cells[c];
    const colId = cell && cell.column ? cell.column.id : null;
    if (colId == null || !columnEditable(colId)) continue;
    const d = defFor(colId);
    const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
    // colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
    // Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
    // can address a SPECIFIC editor by column, not just the first [data-editing-cell].
    out.push({
      colId,
      field,
      colIndex: c
    });
  }
  return out;
};
// B21/B22: focus the row-mode editor at a given VISIBLE col index. In full-row edit every
// editable cell is already mounted as an editor, so this resolves the cell off gridRoot and
// focuses its [data-editing-cell] control. Bounded rAF-poll (mirrors focusEditorWhenReady)
// so a React re-render that recreates the input across the focus call still lands it. select-
// all on text/number editors (a no-op try/catch on select/checkbox).
// Editor-owns-focus contract (quick 260711-i5m): when the TARGET column is a #editor
// drop-in, the host does NOT reach into its DOM (early return, before starting the rAF poll
// at all) — the drop-in self-focuses via its own reactive `autofocus` prop, which the caller
// (commitRow's B22 reject path / rowEditTab) already flips via $data.editFocusColId. Built-in
// columns are unaffected (hasEditorSlot is false for them) — unchanged host direct-focus.
const focusRowEditorAt = (rowIndex: any, colIndex: any) => {
  if (!gridRoot) return;
  const colId = columnIdAt(rowIndex, colIndex);
  if (colId != null && hasEditorSlot(colId)) return;
  let attempts = 0;
  const tryFocus = () => {
    const cellEl = resolveCellEl(String(rowIndex), colIndex);
    const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
    if (ed) {
      ed.focus();
      if (ed.select) {
        try {
          ed.select();
        } catch (e: any) {}
      }
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// beginRowEdit(row): enter full-row edit on a body row (req-6). Seeds rowDraft from each
// editable column's CURRENT value (so an immediate save is a no-op), clears any single-cell
// edit (mutual exclusivity), and focuses the first editable cell's editor (the bounded
// rAF-poll resolves the first [data-editing-cell] off gridRoot — same mechanism as
// focusEditorWhenReady). Accepts the row OBJECT (the template/Shift+F2 path) — index-resolved
// internally via rowIndexOf so it stays in the editingRow/activeRow index space.
const beginRowEdit = (row: any) => {
  const rowIndex = rowIndexOf(row);
  if (rowIndex < 0) return;
  const editable = editableColumnsForRow(rowIndex);
  if (editable.length === 0) return;
  // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
  committedThisSession = false;
  // Clear any single-cell editor first (mutual exclusivity).
  editingRow = -1;
  editingCol = -1;
  draftValue = null;
  setInvalid('');
  // Seed each editable cell's draft from its current value.
  const draft = {};
  const rowList = rows || [];
  const r = rowList[rowIndex];
  const orig = r ? r.original : null;
  for (let i = 0; i < editable.length; i++) {
    const ec = editable[i];
    draft[ec.colId] = orig ? orig[ec.field] : null;
  }
  rowDraft = draft;
  editingRowIndex = rowIndex;
  activeInControl = true;
  editVer = editVer + 1;
  // Editor-owns-focus contract (quick 260711-i5m): the row's FIRST editable column is the
  // initial focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope
  // prop from it (a built-in column is also host-focused below via focusEditorWhenReady; a
  // drop-in column self-focuses via its own $onMount, gated off the host reach-in in Task 3).
  editFocusColId = editable[0].colId;
  focusEditorWhenReady();
};
// commitRow(): validate EVERY edited column (D-01 — keep the row open if ANY fails: set
// invalid + announce, NEVER write the model); on all-valid build ONE fresh array replacing
// the single row object with all rowDraft values applied at once, call writeData ONCE, then
// emit ONE row-edit-commit from THIS single call site, clear the row state, return focus.
// Returns true on a written commit, false when a validation failure kept the row open.
const commitRow = () => {
  if (editingRowIndex == null) return false;
  const rowIndex = editingRowIndex;
  const editable = editableColumnsForRow(rowIndex);
  if (editable.length === 0) {
    endRowEdit();
    return false;
  }
  const rowList = rows || [];
  const r = rowList[rowIndex];
  const rowOriginal = r ? r.original : null;
  const rowId = r ? r.id : null;
  const draft = rowDraft || {};
  // Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
  // B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
  // 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
  // commitEdit already coerces via coerceCellValue; the row path silently committed strings →
  // a number column ended up holding '99'). Coerce once here so the validator and the model both
  // see the typed value, identical to the single-cell funnel.
  for (let i = 0; i < editable.length; i++) {
    const ec = editable[i];
    const err = runValidator(ec.colId, coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
    if (err !== true) {
      setInvalid(err);
      // Editor-owns-focus contract (quick 260711-i5m): the OFFENDING column becomes the new
      // reactive focus target BEFORE the host-focus call below — a #editor drop-in already
      // mounted (full-row edit opens every editable cell at once) picks this up via its own
      // lazy $watch on the `autofocus` scope prop flipping false→true. Bump editVer so the
      // coarse-render targets (React/Vue/Angular/Svelte) re-derive the slot binding (Solid's
      // fine-grained accessor re-runs without the bump, but the bump keeps all 6 in lockstep).
      editFocusColId = ec.colId;
      editVer = editVer + 1;
      // B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
      // unconditionally the first editor (focusEditorWhenReady resolves the first
      // [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
      // Gated (Task 3) so a #editor drop-in self-focuses instead of a host DOM reach-in.
      focusRowEditorAt(rowIndex, ec.colIndex);
      return false;
    }
  }
  setInvalid('');
  // Build the changes payload (only the columns whose value actually changed) + the field→
  // value map for the single row-object replace.
  const changes = [];
  const fieldValues = {};
  for (let i = 0; i < editable.length; i++) {
    const ec = editable[i];
    // B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
    // string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
    const newValue = coerceCellValue(ec.colId, draft[ec.colId]);
    const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
    fieldValues[ec.field] = newValue;
    if (oldValue !== newValue) changes.push({
      columnId: ec.colId,
      oldValue,
      newValue
    });
  }
  // Snapshot the active cell to return focus to (the whole row is in edit, so the active-cell
  // row/column is the roving focus target), BEFORE endRowEdit clears editing state.
  const focusRow = activeRow;
  const focusCol = activeColIndex;
  // #5: a no-op row commit (NO column's value actually changed — a bare Enter/save/outside-click
  // that edited nothing) must do NO model write, NO history record, NO row-edit-commit event:
  // writeData → recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row
  // identity, so an unconditional write on a no-op destroys redo + spuriously re-renders + emits
  // a no-op row-edit-commit. Gate the write/emit on `changes.length`; ALWAYS close the editor.
  const changed = changes.length > 0;
  editTransition = true;
  if (changed) {
    // ONE fresh-array replace of the SINGLE row object with all field values applied at once.
    const srcIndex = sourceIndexOfRow(rowIndex);
    const next = replaceRowValues(currentData(), srcIndex, fieldValues);
    writeData(next);
    // EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
    onroweditcommit?.({
      rowId,
      changes
    });
  }
  endRowEdit();
  editTransition = false;
  if (changed) {
    // WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
    // like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
    // col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
    // $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
    // DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
    // refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
    // display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
    // the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
    // poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
    pendingEditFollow = {
      rowOriginal,
      rowId,
      col: focusCol
    };
  } else {
    // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume a
    // pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
    // relocate (no write), so the B23 relocation hazard does not apply: (focusRow, focusCol) is safe.
    focusCellWhenReady(focusRow, focusCol);
  }
  return true;
};
// cancelRow(): revert the whole row as a unit (D-06 — drop every draft, NO model write) and
// return focus to the active cell.
const cancelRow = () => {
  if (editingRowIndex == null) return;
  const focusRow = activeRow;
  const focusCol = activeColIndex;
  editTransition = true;
  endRowEdit();
  editTransition = false;
  focusCellWhenReady(focusRow, focusCol);
};
// replaceRowValues: like replaceRowValue but applies a MAP of field→value to ONE row object
// in a single fresh-array replace (req-6 — the whole-row commit is ONE write, not per cell).
const replaceRowValues = (rows: any, rowIndex: any, fieldValues: any) => {
  const src = rows || [];
  const fv = fieldValues || {};
  const out = [];
  for (let i = 0; i < src.length; i++) {
    if (i === rowIndex) {
      // WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
      // prototype-walking copy. Spread copies own enumerable props only.
      out.push({
        ...(src[i] || {}),
        ...fv
      });
    } else {
      out.push(src[i]);
    }
  }
  return out;
};
// Compute the next editable cell for Tab-advance (req-3, RESEARCH Open-Q3 deterministic
// rule): skip non-editable columns within the row; wrap to the NEXT row's first editable
// cell at the row's end; stop (return null) at grid end. Pure index math over the visible
// model. Returns { row, col } or null.
const nextEditableCell = (fromRow: any, fromCol: any) => {
  const rowList = rows || [];
  const rowCount = rowList.length;
  if (rowCount === 0) return null;
  let r = fromRow;
  let c = fromCol + 1;
  while (r < rowCount) {
    const row = rowList[r];
    const cells = row ? visibleCellsFor(row) : [];
    while (c < cells.length) {
      const cell = cells[c];
      const cid = cell && cell.column ? cell.column.id : null;
      if (cid != null && columnEditable(cid)) return {
        row: r,
        col: c
      };
      c = c + 1;
    }
    r = r + 1;
    c = 0;
  }
  return null;
};
// B4: the mirror of nextEditableCell — the PREVIOUS editable cell for a Shift+Tab
// backward move. Skips non-editable columns leftward within the row; wraps to the END
// of the prior row; stops (returns null) at grid start. Pure index math over the visible
// model. Returns { row, col } or null.
const prevEditableCell = (fromRow: any, fromCol: any) => {
  const rowList = rows || [];
  const rowCount = rowList.length;
  if (rowCount === 0) return null;
  let r = fromRow;
  let c = fromCol - 1;
  while (r >= 0) {
    const row = rowList[r];
    const cells = row ? visibleCellsFor(row) : [];
    while (c >= 0) {
      const cell = cells[c];
      const cid = cell && cell.column ? cell.column.id : null;
      if (cid != null && columnEditable(cid)) return {
        row: r,
        col: c
      };
      c = c - 1;
    }
    r = r - 1;
    if (r >= 0) {
      const prow = rowList[r];
      const pcells = prow ? visibleCellsFor(prow) : [];
      c = pcells.length - 1;
    }
  }
  return null;
};
// Transient guard: true while an editor commit/cancel/Tab-advance is tearing the current
// editor down. The unmounting editor fires a `blur` as it leaves the DOM — without this
// guard onEditorBlur would re-enter commitEdit on the (already-resolved or newly-opened)
// cell, double-counting cell-edit-commit. A top-level `let` (React hoists to useRef).
let editTransition = false;
// B23: a pending "follow the committed row's focus" request, set by commitEdit (a single-cell
// commit that may relocate the row under an active sort/filter) and consumed ONCE by the next
// refreshRowModel pass — which runs with the FRESH re-derived row model, so it can resolve the
// committed row's NEW display index (React-stale-safe) and re-seat focus there. Shape:
// { rowOriginal, rowId, col } or null. A top-level `let` (React hoists to useRef → persists).
let pendingEditFollow: any = null;
// Sync idempotency latch for a cell commit (drop-in double cell-edit-commit fix, 260705):
// commitEdit's `$data.editingRow < 0` re-entry guard is ASYNC-STALE on React — a deferred
// drop-in editor's unmount-blur (onBlur → $props.commit → commitEdit) fires AFTER commitEdit
// has already returned (editTransition is a SYNC latch, cleared before the async blur), while
// `$data.editingRow` in that stale closure still reads the OLD (pre-endEdit) value, so the
// second commit slips through and re-emits `cell-edit-commit`. A top-level `let` is written/read
// synchronously by plain assignment (unaffected by React's setState batching — that's the point)
// so it stays correct across the async window editTransition/editingRow cannot cover. Set true on
// a SUCCESSFUL commitEdit/toggleActiveBooleanCell; reset to false wherever a NEW edit session
// begins (beginEdit/beginRowEdit/editCell) so the next legitimate commit fires exactly once.
// A top-level `let` (React hoists to useRef → persists).
let committedThisSession = false;
// ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
// In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
// (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
// rowDraft — so the four editors open simultaneously never clobber one shared value. These
// helpers let the ONE editor template branch serve BOTH modes (no per-mode template fork):
// the template binds editorValueFor(colId)/editorCheckedFor(colId) and writes via
// onCellEditorInput(colId, evt)/onCellEditorCheckbox(colId, evt).
const inRowEdit = () => editingRowIndex != null;
const editorValueFor = (colId: any) => inRowEdit() ? rowDraft ? rowDraft[colId] : null : draftValue;
const editorCheckedFor = (colId: any) => !!(inRowEdit() ? rowDraft ? rowDraft[colId] : null : draftValue);
// #editor custom-slot callbacks (req-2/6): the consumer's slot calls commit(value)/cancel().
// In SINGLE-CELL mode commit(v) commits that cell (commitEdit override); in ROW mode commit(v)
// only WRITES this column's draft (the row commits as a unit later — never per cell). cancel()
// reverts the cell (single) or the whole row (row mode). Factory-bound per columnId so the
// row-mode commit targets the right draft key.
const editorCommitFor = (colId: any) => (value: any) => {
  if (inRowEdit()) {
    setRowDraft(colId, value);
    return;
  }
  commitEdit(value);
};
const editorCancelFor = () => () => {
  if (inRowEdit()) {
    cancelRow();
    return;
  }
  cancelEdit();
};
// Editor input handlers (the global-filter `evt.target.value` idiom — an untyped param
// neutralizes to `any`, so reading .value/.checked typechecks ×6; an inline
// `$data.x = $event.target.value` binding does NOT neutralize and breaks Lit/React JSX).
// Column-aware: in row mode they write rowDraft[colId] (a FRESH object so Solid/Svelte/React
// re-derive); single-cell they write the shared draftValue.
const onCellEditorInput = (colId: any, evt: any) => {
  const v = evt && evt.target ? evt.target.value : '';
  if (inRowEdit()) {
    setRowDraft(colId, v);
    return;
  }
  draftValue = v;
};
const onCellEditorCheckbox = (colId: any, evt: any) => {
  const v = !!(evt && evt.target && evt.target.checked);
  if (inRowEdit()) {
    setRowDraft(colId, v);
    return;
  }
  draftValue = v;
};
// setRowDraft: write ONE key into a FRESH rowDraft object (whole-object replace — an
// in-place mutation is silently dropped on React/Solid; the family immutable rule).
const setRowDraft = (colId: any, value: any) => {
  const src = rowDraft || {};
  const next = {};
  for (const k in src) next[k] = src[k];
  next[colId] = value;
  rowDraft = next;
};
// B21: contain a Tab WITHIN the editing row (editMode='row'). Resolve the editable cells'
// visible col indices for the editing row, find the current editor's col (off the blurring
// editor's owning [data-grid-cell]), then move to the next/prev editable col WITH WRAP so
// focus never leaves the row. A no-op when no row is editing / the row has no editable cells.
const rowEditTab = (target: any, backward: any) => {
  const rowIndex = editingRowIndex;
  if (rowIndex == null) return;
  const editable = editableColumnsForRow(rowIndex);
  if (editable.length === 0) return;
  const cols = editable.map((ec: any) => ec.colIndex);
  const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
  const curAttr = cell ? cell.getAttribute('data-col-index') : null;
  const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
  let pos = cols.indexOf(cur);
  if (pos < 0) pos = 0;
  const len = cols.length;
  const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
  // Editor-owns-focus contract (quick 260711-i5m): the Tab target becomes the new reactive
  // focus target BEFORE the host-focus call below, so Tab onto an already-mounted #editor
  // drop-in (row mode) also refocuses it via its own lazy $watch.
  editFocusColId = editable[nextPos].colId;
  editVer = editVer + 1;
  focusRowEditorAt(rowIndex, cols[nextPos]);
};
// onEditorKeyDown: the editor-LOCAL keymap (req-3). Enter → commit + stay (focus returns
// to the cell); Tab → commit + advance to the next editable cell; Escape → cancel +
// revert. preventDefault on handled keys so the grid keymap / native Tab don't double-act.
const onEditorKeyDown = (e: any) => {
  if (!e) return;
  const key = e.key;
  // Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
  // model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
  // row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
  // preventDefault it here.
  if (inRowEdit()) {
    if (key === 'Enter') {
      e.preventDefault();
      commitRow();
    } else if (key === 'Escape') {
      e.preventDefault();
      cancelRow();
    }
    // B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
    // editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
    // Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
    // first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
    // tab order across a Lit shadow boundary).
    else if (key === 'Tab') {
      e.preventDefault();
      rowEditTab(e.target, e.shiftKey);
    }
    return;
  }
  if (key === 'Enter') {
    e.preventDefault();
    commitEdit(undefined);
  } else if (key === 'Tab') {
    e.preventDefault();
    // Resolve the advance target from the EDITING pair (the cell that is open), not the
    // active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
    // moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
    // the editing pair BEFORE commit (commitEdit resets it to -1).
    const fromRow = editingRow;
    const fromCol = editingCol;
    const target = e.shiftKey ? prevEditableCell(fromRow, fromCol) : nextEditableCell(fromRow, fromCol);
    // skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
    // straight into the next editable cell's editor below. Use the RETURN value (not a
    // re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
    // failure returns false and keeps the editor open (the user must fix the value first).
    const committed = commitEdit(undefined, true);
    if (committed && target) {
      activeRow = target.row;
      activeColIndex = target.col;
      beginEdit(target.row, target.col, null);
    } else if (committed) {
      // B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
      // grid by returning it to the just-committed cell instead of letting it drop to <body>.
      focusCellWhenReady(fromRow, fromCol);
    }
  } else if (key === 'Escape') {
    e.preventDefault();
    cancelEdit();
  }
};
// onEditorBlur: commit on a genuine click/focus-away (D-01 — an invalid value keeps the
// editor open via commitEdit's reject path). SKIP when:
//  - editTransition is set (a synchronous commit/cancel teardown is unmounting the editor), or
//  - the blur is part of a controlled keyboard transition: focus is moving to a grid cell
//    or another editor inside our gridRoot (Tab-advance, Enter/Escape focus-return). On the
//    async-render targets the unmount-blur can fire AFTER the synchronous flag cleared, so
//    the relatedTarget/containment check is the load-bearing guard, not the flag alone.
const onEditorBlur = (e: any) => {
  // Full-row mode (req-6): a blur that stays WITHIN the row editor — Tab/click between the
  // row's OWN fields — is a normal focus move and must NOT commit (a per-cell blur-commit
  // would split the row into N writes + N events, violating the one-write/one-event contract).
  // But an OUTSIDE-click blur (#7) MUST commit the row: otherwise the model is never written
  // AND editingRowIndex stays set, freezing onGridKeyDown's editingRowIndex early-return so
  // arrow-nav is dead the moment the user clicks back into the grid. Mirror the single-cell
  // branch's relatedTarget shape to tell an in-row focus move from a genuine click-away.
  if (inRowEdit()) {
    // Guard the teardown blur: commitRow's writeData/endRowEdit re-render unmounts the row's
    // editors → a same-tick re-render blur must NOT re-enter commitRow (double row-edit-commit).
    // commitRow sets editTransition synchronously BEFORE writeData, so it is set here during the
    // teardown window (the async unmount-blur that fires after endRowEdit finds editingRowIndex
    // already null → inRowEdit() false → the single-cell tail's editingRow<0 guard returns).
    if (editTransition) return;
    const rowNext = e ? e.relatedTarget : null;
    const rowNextCell = rowNext && rowNext.closest ? rowNext.closest('[data-grid-cell]') : null;
    const rowNextRow = rowNextCell ? rowNextCell.getAttribute('data-row') : null;
    // Focus landing on a cell of the SAME editing row (Tab/click between the row's own fields) →
    // controlled in-row move, do NOT commit. Anything else — a null relatedTarget, another row,
    // a toolbar/widget, or outside the grid entirely — is an outside-click → commit the row as a
    // unit. commitRow clears editingRowIndex, releasing onGridKeyDown's early-return so nav
    // resumes; a no-op row (nothing changed) takes commitRow's clean #5 no-write/no-emit path.
    if (rowNextRow != null && rowNextRow === String(editingRowIndex)) return;
    commitRow();
    return;
  }
  if (editingRow < 0 || editTransition) return;
  const next = e ? e.relatedTarget : null;
  // A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
  // keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
  // a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
  // conservative null=skip behavior.
  if (next == null) return;
  // Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
  // the editor open on an invalid value).
  if (!(gridRoot && gridRoot.contains && gridRoot.contains(next))) {
    commitEdit(undefined);
    return;
  }
  // Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
  // keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
  // (which must commit + close so the grid is not wedged with an open editor).
  const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
  const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
  // Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
  // controlled move; skip. Also skip when either cell can't be resolved (an unmounting
  // editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
  if (!nextCell || !fromCell || nextCell === fromCell) return;
  // A Tab-advance already committed the old editor and opened the next one, so the live
  // editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
  // editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
  // double commit, WR-04).
  const fromRow = fromCell.getAttribute('data-row');
  const fromCol = fromCell.getAttribute('data-col-index');
  if (fromRow !== String(editingRow) || fromCol !== String(editingCol)) return;
  // Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
  // commit does NOT bounce focus back to the just-committed editing cell (which would fight
  // the click destination). The commit's writeData re-renders the table and can DROP DOM
  // focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
  // DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
  // (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
  // late, which would steal focus back from a subsequent navigation.
  const destRow = nextCell.getAttribute('data-row');
  const destCol = nextCell.getAttribute('data-col-index');
  commitEdit(undefined, true);
  const reseatDestFocus = () => {
    if (!gridRoot || destRow == null || destCol == null || destRow === '__header') return;
    const root = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
    const act = root && root.activeElement ? root.activeElement : null;
    // Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
    if (act && gridRoot.contains && gridRoot.contains(act)) return;
    const el = resolveCellEl(destRow, parseInt(destCol, 10));
    if (el) el.focus();
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
};
// editCell(rowIndex, colIndex) — programmatic edit-entry ($expose, req-3). Coerces +
// clamps indices, moves the active cell, and opens the editor (no-op on a non-editable
// cell). Collision-clean (RESEARCH name-check): not a verb/event/prop/ROZ137 member.
export const editCell = (rowIndex: any, colIndex: any) => {
  const lastRow = bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const maxCol = visibleColCount() - 1;
  const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
  const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
  // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
  committedThisSession = false;
  activeIsHeader = false;
  activeRow = r;
  activeColIndex = c;
  beginEdit(r, c, null);
};
// commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
// nothing is editing. Collision-clean (not `commit`). Handles BOTH edit modes: a full-row
// edit (editRow()/Shift+F2) drives editingRowIndex and leaves editingRow at -1, so the
// single-cell commitEdit guard (editingRow >= 0) is false during a row edit — route to
// commitRow() first so a programmatic commit of a row editor is not a silent no-op.
export const commitEditing = () => {
  if (inRowEdit()) {
    commitRow();
    return;
  }
  if (editingRow >= 0) commitEdit(undefined);
};
// editRow(rowIndex) — programmatically enter full-row edit on a body row ($expose, req-6 /
// D-06), the API twin of the Shift+F2 shortcut. Addressed BY INDEX over the visible model
// (coerced + clamped); no-op on a row with no editable columns. Collision-clean (RESEARCH
// name-check): `editRow` is not in the 15 existing verbs, not a prop, not a *-change/commit
// event, not a Lit ROZ137-reserved host member. Moves the active cell to the row first so the
// commit/cancel focus-return lands in the right row.
export const editRow = (rowIndex: any) => {
  const lastRow = bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
  const rowList = rows || [];
  const row = rowList[r];
  if (!row) return;
  activeIsHeader = false;
  activeRow = r;
  beginRowEdit(row);
};
// ── Grid active-cell $expose verbs (phase 49 plan 03, D-01) — exactly THREE, joining the
// existing 12 (→ 15). Collision-safe names (Pitfall 1): focusCell NOT `focus` (would shadow
// HTMLElement.focus on Lit — ROZ137); clearActiveCell NOT `clear` (listbox already exposes
// `clear`); getActiveCell is a read-style getter. None collide with the 9 *-change events,
// any prop, or a React auto-setter (ROZ121/137/524 clear). ──────────────────────────────────

// focusAbsCellWhenReady — paginated page-switch focus poll (C1). After a programmatic page
// switch the in-page (localRow, col) cell is ambiguous: EVERY page renders a row at the same
// page-relative index, so a plain resolveCellEl(localRow, col) poll would grab the OLD page's
// cell on frame 1 (before the switch commits) and focus it — only for the page switch to then
// REMOVE it, dropping focus to <body>. Disambiguate by the ABSOLUTE aria-rowindex: poll until
// the cell at (localRow, col) carries the TARGET page's body aria-rowindex (i.e. the TARGET
// page has actually rendered), THEN focus. DOM-only (reads gridRoot), so React-stale-safe; works
// for both controlled (round-trips through page-change) and uncontrolled pagination. ~60 frames
// (~1s) to cover the controlled-state parent round-trip on React/Solid/Lit.
//   #13: the body aria-rowindex is now header-offset (bodyAriaRowIndex = headerRowCount + absRow
//   + 1) so header rows + body rows form one consistent aria-rowindex/aria-rowcount space — so
//   the poll target must add headerRowCount() too, else it never matches and focus drops.
const focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
  if (!gridRoot) return;
  let attempts = 0;
  const want = String(headerRowCount() + absRow + 1);
  // #9: capture the focus-intent epoch at arm time (AFTER focusCell's own bump at its top, so
  // this poll never aborts itself). A LATER focus intent — a click landing on a new cell
  // (syncActiveFromEvent) or another focusCell / keyboard nav — bumps the epoch, so this
  // paginated page-switch poll aborts instead of grabbing focus frames after the user moved on.
  const myEpoch = focusIntentEpoch;
  const tryFocus = () => {
    if (focusIntentEpoch !== myEpoch) return;
    const el = resolveCellEl(String(localRow), col);
    if (el) {
      const rowEl = el.closest ? el.closest('[role="row"]') : null;
      const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
      if (ari === want) {
        el.focus();
        return;
      }
    }
    attempts = attempts + 1;
    if (attempts >= 60) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};
// focusCell(rowIndex, colIndex) — move + focus the active cell. C1 (phase 63 wave-6): rowIndex
// is the ABSOLUTE display-order position in getPrePaginationRowModel().rows (filter+sort+expand
// applied, BEFORE pagination/windowing), in BOTH paginated and virtual modes — REVERSING the old
// page-relative-when-paginated meaning. Args are COERCED to integers and CLAMPED before the
// data-* selector is built (T-49-01/T-63-06-01: never interpolate a raw consumer string; clamp
// the abs index into getPrePaginationRowModel bounds). The activecell-change payload + getActiveCell
// speak the SAME absolute language (toAbsRow).
export const focusCell = (rowIndex: any, colIndex: any) => {
  // B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
  // is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
  // isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
  // instance does not leak a spurious activecell-change.
  if (!isGrid()) return;
  // #9: focusCell is a focus-INTENT entry point — bump the epoch BEFORE arming any poll (the
  // switched-page focusAbsCellWhenReady captures the post-bump value; the same-page / virtual
  // branches route through focusActiveCell, which bumps again — harmless). A subsequent focusCell
  // or user nav bumps again → a pending focusAbsCellWhenReady from THIS call aborts.
  focusIntentEpoch = focusIntentEpoch + 1;
  const maxCol = visibleColCount() - 1;
  const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
  // C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
  const absLast = prePaginationRowCount() - 1;
  const absRow = clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
  // B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
  // real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
  // active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
  const prevAbs = toAbsRow(activeRow);
  const prevIsHeader = activeIsHeader;
  if (virtual) {
    // Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
    // the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
    // focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
    activeIsHeader = false;
    activeInControl = false;
    activeRow = absRow;
    activeColIndex = c;
    focusActiveCell(absRow, c, false);
  } else {
    // Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
    // in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
    // body's data-row markers (and the roving tabindex) address.
    const size = pageSize();
    const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
    const localRow = absRow - targetPage * size;
    const switched = targetPage !== pageIndex();
    if (switched) setPage(targetPage);
    activeIsHeader = false;
    activeInControl = false;
    activeRow = localRow;
    activeColIndex = c;
    if (switched) {
      // The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
      // TARGET page's body aria-rowindex (headerRowCount + absRow + 1, #13) before focusing, so
      // the OLD page's same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only.
      focusAbsCellWhenReady(absRow, localRow, c);
    } else {
      // Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
      // Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
      // async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
      focusActiveCell(localRow, c, false);
    }
  }
  if (absRow !== prevAbs || prevIsHeader) {
    onactivecellchange?.({
      rowIndex: absRow,
      colIndex: c
    });
  }
};
// getActiveCell() — return the current active-cell position. Integers only — no row data,
// no DOM node (T-49-02 Information-Disclosure: return the screen position, nothing else).
// B15: reflect the HEADER-active state. When a header cell is active the roving position is
// NOT a body row — return the header sentinel (rowIndex null + isHeader true, colIndex the
// header column) so a consumer never mistakes a header focus for body 'row 0'. A body cell
// returns the integer rowIndex + isHeader false (back-compatible: the rowIndex/colIndex pair
// is unchanged for the body case).
// C1: a body cell returns the ABSOLUTE display-order rowIndex (toAbsRow) — matching focusCell's
// addressing + the activecell-change payload — in BOTH paginated and virtual modes.
export const getActiveCell = () => activeIsHeader ? {
  rowIndex: null,
  colIndex: activeColIndex,
  isHeader: true
} : {
  rowIndex: toAbsRow(activeRow),
  colIndex: activeColIndex,
  isHeader: false
};
// clearActiveCell() — reset the roving position to the D-04 entry cell (row 0, col 0) and
// exit interaction mode; the next Tab-in re-enters at the entry cell (D-01). Does NOT emit
// (no move to a new addressable cell — a reset, not a navigation). B16: isGrid()-gated — a
// table-mode instance has no roving active cell, so the verb is a no-op there.
export const clearActiveCell = () => {
  if (!isGrid()) return;
  activeIsHeader = false;
  activeInControl = false;
  activeRow = 0;
  activeColIndex = 0;
};
// ── Expand $expose verbs (phase 50 req-3, D-06) — joining the existing 19 (→ 23).
// Collision-safe names (ROZ121/137/524): toggleRowExpanded / expandAll / collapseAll are
// not inherited HTMLElement members, Lit lifecycle names, React auto-setters, prop names,
// or *-change events; getExpandedRows is a read-style getter (twin of getSelectedRows).
// Each drives @tanstack/table-core so the onExpandedChange → writeExpanded funnel fires
// one expanded-change. ──────────────────────────────────────────────────────────────────
// toggleRowExpanded(rowId) — toggle ONE row's expanded state, addressed by the consumer's
// row id (the data `id` field) OR the table-core row id. Scans the core flat-row set (all
// rows regardless of current expansion) so a collapsed parent is still resolvable.
export const toggleRowExpanded = (rowId: any) => {
  if (!table) return;
  const target = String(rowId);
  const flat = table.getCoreRowModel().flatRows;
  for (const r of flat as any) {
    if (r.id === target || r.original && String(r.original.id) === target) {
      r.toggleExpanded();
      return;
    }
  }
};
// expandAll() — open every expandable row (table-core sets ExpandedState to the `true`
// literal under the hood → Pitfall 2: writeExpanded passes it through verbatim).
export const expandAll = () => {
  if (!table) return;
  table.toggleAllRowsExpanded(true);
};
// collapseAll() — reset to a blank expanded state ({}). resetExpanded(true) forces the
// blank reset (NOT the initialState) and fires onExpandedChange → one expanded-change.
export const collapseAll = () => {
  if (!table) return;
  table.resetExpanded(true);
};
// getExpandedRows() — return the original row data for every currently-expanded row
// (read-verb twin of expanded-change). Integers/data only — scans the core flat rows and
// filters by getIsExpanded(). Empty when nothing is expanded.
export const getExpandedRows = () => {
  if (!table) return [];
  const out = [];
  const flat = table.getCoreRowModel().flatRows;
  for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
  return out;
};
// ── Grouping $expose verbs (phase 50 reqs 4-7, D-06 name-check) ────────────────────────────
// applyGrouping (RENAMED from setGrouping — ROZ524: a bare `set<ModelProp>` verb shadows
// React's auto-generated `setGrouping` useState setter for the `grouping` model slice, and an
// $expose verb is PUBLIC-CONTRACT-PROTECTED from the deconfliction rename; same precedent as
// setColumnOrder→applyColumnOrder) + clearGrouping. Both drive @tanstack/table-core's
// table.setGrouping so the onGroupingChange → writeGrouping funnel fires one group-change with
// the fresh ordered key list. Also handed to the headless #groupBar slot as apply/clear helpers.
export const applyGrouping = (cols: any) => {
  if (table) table.setGrouping(cols);
};
export const clearGrouping = () => {
  if (table) table.setGrouping([]);
};
// ── Faceted filtering read helpers (phase 50 reqs 8-9, D-03) ────────────────────────────────
// Shared by BOTH the getFaceted* $expose verbs AND the #filter slot props. They resolve a
// column via table.getColumn(colId) (a table-core lookup — NEVER a string-built querySelector,
// T-50-06 / the T-49-01 index-only discipline) and read table-core's CROSS-FILTERED faceted
// values (default impl — reflects rows passing all OTHER active column filters, D-03). They
// touch the reactive tick (`tick() < 0` guard) so the #filter slot props re-derive when an
// upstream filter changes on the fine-grained targets (Solid/Lit) — the visibleCellsFor idiom.
//
// getFacetedUniqueValues: the column's distinct values, KEYS ONLY — occurrence counts are
// deliberately NOT exposed (D-03; the column's getFacetedUniqueValues() returns Map<any,number>,
// we return Array.from(map.keys()) — no .entries()/count surface). Empty array on missing
// column/table. NAMED to match the $expose verb exactly (the ExposedMethod.name shorthand
// contract: an exposed verb lowers to `{ getFacetedUniqueValues }`, which must resolve to THIS
// helper — the table-core factory was aliased to makeFacetedUniqueValues to free this name).
export const getFacetedUniqueValues = (colId: any) => {
  if (tick() < 0 || !table) return [];
  const col = table.getColumn(colId);
  if (!col || !col.getFacetedUniqueValues) return [];
  const map = col.getFacetedUniqueValues(); // Map<any, number>
  return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
};
// getFacetedMinMaxValues: the column's [min, max] numeric range, or null when unavailable.
// Named to match the $expose verb (same shorthand contract as getFacetedUniqueValues above).
export const getFacetedMinMaxValues = (colId: any) => {
  if (tick() < 0 || !table) return null;
  const col = table.getColumn(colId);
  if (!col || !col.getFacetedMinMaxValues) return null;
  return col.getFacetedMinMaxValues() || null; // [number, number] | null
};

setContext('data-table:columns', {
  registerColumn: (id: any, spec: any) => {
    if (id == null) return;
    const key = String(id);
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
    colReg = {
      ...colReg,
      [key]: spec
    };
  },
  unregisterColumn: (id: any) => {
    if (id == null) return;
    const r = {
      ...colReg
    };
    delete r[String(id)];
    colReg = r;
  }
});

onMount(() => {
  // Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
  // edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
  // a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
  // the bound prop when controlled, this fallback otherwise.
  dataDefault = data || [];
  // Build the table instance HERE so the closures below capture the live `table`.
  table = createTable({
    // Plain value (NOT a `get data()` getter): an object-literal getter rebinds
    // `this` to the options object, and the Angular/Lit emitters resolve $props via
    // `this.data` — so `get data() { return $props.data }` lowers to `this.data`
    // re-entering the getter → infinite recursion (max call stack). `data` is re-fed
    // on every change by the watch's setOptions below, exactly like columns/state, so
    // the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
    // currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
    // (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
    data: currentData(),
    columns: tableColumns(),
    state: currentState(),
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    // Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
    // (mirrors the other models) — inert when `expanded` is empty + no getSubRows
    // (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
    // ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
    // (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
    // default `!!subRows.length` rule applies (only parents with children expand).
    getExpandedRowModel: getExpandedRowModel(),
    getSubRows: (getSubRows || undefined) as any,
    getRowCanExpand: expandable === true && getSubRows == null ? () => true : undefined,
    onExpandedChange: onExpandedChangeCb,
    // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
    // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
    // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
    // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
    // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
    // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
    // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
    // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
    // are unaffected (they fire regardless of this flag).
    autoResetExpanded: false,
    // Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
    // UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
    // (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
    // table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
    // members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
    // nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
    // (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
    getGroupedRowModel: getGroupedRowModel(),
    onGroupingChange: onGroupingChangeCb,
    // Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
    // UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
    // column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
    // The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: makeFacetedUniqueValues(),
    getFacetedMinMaxValues: makeFacetedMinMaxValues(),
    // Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
    // rows verbatim (no client-side filter/sort/paginate) and only emits the change
    // events so the consumer can fetch the next page/filtered slice.
    manualPagination: manual === true,
    manualFiltering: manual === true,
    manualSorting: manual === true,
    // Server-side page-count sources (#2): pass the consumer-supplied total row count and/or
    // explicit page count so table-core can compute getPageCount() under `manual` (where it
    // does not hold the full dataset). undefined when unset → table-core auto-derives from the
    // loaded data (client-pagination path byte-unchanged). Precedence is table-core's: explicit
    // pageCount wins, else ⌈rowCount / pageSize⌉, else auto. With a real count getCanNextPage()
    // becomes true, so a server-pagination consumer can leave page 0.
    rowCount: rowCount ?? undefined,
    pageCount: pageCount ?? undefined,
    // Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
    // (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
    // default, D-06 — NOT overridden).
    enableRowSelection: selectionMode !== 'none',
    enableMultiRowSelection: selectionMode === 'multiple',
    // PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
    // no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
    // $watch so React reads fresh currentState (the stale-closure fix, F6).
    onSortingChange: onSortingChangeCb,
    onGlobalFilterChange: onGlobalFilterChangeCb,
    onColumnFiltersChange: onColumnFiltersChangeCb,
    onPaginationChange: onPaginationChangeCb,
    onRowSelectionChange: onRowSelectionChangeCb,
    onColumnVisibilityChange: onColumnVisibilityChangeCb,
    onColumnSizingChange: onColumnSizingChangeCb,
    onColumnOrderChange: onColumnOrderChangeCb,
    onColumnPinningChange: onColumnPinningChangeCb,
    onColumnSizingInfoChange: onColumnSizingInfoChangeCb,
    // Resize mode: 'onChange' so the bound columnSizing model updates live during the
    // drag (the behavioral width-delta assertion observes the in-progress width). Column
    // resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
    columnResizeMode: 'onChange',
    enableColumnResizing: true,
    renderFallbackValue: null,
    // table-core's RESOLVED options type (TableOptionsResolved) requires a global
    // onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
    // callbacks above, so the global hook is a no-op. Present so the createTable() argument
    // satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
    onStateChange: () => {}
  });
  refreshRowModel = () => {
    if (!table) return;
    // Capture fresh locals; never write a $data key then re-read it in the same fn
    // (ROZ138 / React stale-read — setState is async on React, the closure binds the
    // PRE-write value).
    // windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
    // pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
    const nextRows = windowSource().slice();
    const nextGroups = table.getHeaderGroups().slice();
    rows = nextRows;
    headerGroups = nextGroups;
    rowModelVer = rowModelVer + 1;
    // Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
    // into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
    // NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
    // setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
    if (virtual && virtualizer) {
      virtualizer.setOptions(virtualizerOptions());
      virtualizer._willUpdate();
    }
    // D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
    // clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
    // no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
    // B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
    // async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
    // the range corners on React too — never re-reading the pre-change model.
    const nextRowCount = nextRows.length;
    const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
    clampActiveCell(nextRowCount, nextColCount);
    // #4: clamp a pageIndex that now points PAST the last page. When the consumer holds
    // pagination.pageIndex (controlled) and shrinks the data (filter / replace) so there are
    // fewer pages, the body renders blank ("Page 6 of 3" with Next disabled). Read table-core's
    // LIVE post-re-derive state: getPageCount() is the fresh count (now correct under `manual`
    // too, #2) and getState().pagination is the just-fed state. Funnel the correction through
    // writePagination (the single-emit + two-way-model funnel) so the consumer's controlled
    // pagination prop converges to the last valid page (page-change carries { pageIndex, pageSize }).
    //   • pc > 0 skips the manual-WITHOUT-count case (getPageCount() === -1) — never clamp toward
    //     an unknown total.
    //   • LOOP-GUARD: emit ONLY when the clamped index actually differs. After the consumer echoes
    //     the clamp back through the pagination prop, the re-feed re-enters here with
    //     pageIndex === pc - 1, so `pageIndex > pc - 1` is false → no re-emit; a consumer that
    //     ignores the event triggers no further re-feed, so it stays a single emit either way.
    //   • No fight with table-core's autoResetPageIndex: that reset only fires on table-core's OWN
    //     setX mutations, which this fully-controlled-state architecture never calls (filters/data
    //     flow through setOptions), so reading the live state here can only fire on a genuine
    //     overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
    //     writing paginationDefault, with no regression since table-core does not auto-clamp here).
    const pgState = table.getState().pagination;
    const pc = table.getPageCount();
    if (pc > 0 && pgState.pageIndex > pc - 1) {
      writePagination({
        pageIndex: pc - 1,
        pageSize: pgState.pageSize
      });
    }
    // B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
    // filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
    // indices), so resolve the committed row's NEW index by identity HERE (never from the React-
    // stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
    // gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
    // a no-relocation commit resolves the same index → byte-behaviorally identical to before.
    if (pendingEditFollow && isGrid()) {
      const follow = pendingEditFollow;
      pendingEditFollow = null;
      const followIdx = indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
      if (followIdx >= 0) focusCellWhenReady(followIdx, follow.col);
    }
    // keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
    // selection state (bound :indeterminate is inert on 5/6 targets). The box persists
    // across selection changes; a microtask defer covers React's post-render DOM patch.
    syncIndeterminate();
    if (typeof queueMicrotask !== 'undefined') queueMicrotask(syncIndeterminate);else Promise.resolve().then(syncIndeterminate);
  };

  // initial pull
  refreshRowModel();

  // ── Grid mode: capture the table root ──────────────────────────────────────────────
  // $el is the component root; the <table class="rozie-data-table"> is the grid root the
  // cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
  // (post-mount) so it is non-null and ROZ123-clean.
  gridRoot = __rozieRoot ? __rozieRoot!.querySelector('.rozie-data-table') : null;
  // WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
  // page load AND was non-deterministic on React/Solid (the entry cell may not be
  // committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
  // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
  // mount"); the consumer drives focus by Tabbing/clicking in, never the component.

  // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
  // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
  // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
  // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
  // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
  if (virtual) {
    gridScrollEl = __rozieRoot ? __rozieRoot!.querySelector('.rdt-scroll') : null;
    virtualizer = new Virtualizer(virtualizerOptions());
    virtualizerCleanup = virtualizer._didMount();
    // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
    // evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
    // reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
    // non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
    // SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
    // only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
    // never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
    // re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
    // pulls getVirtualItems().
    windowVer = windowVer + 1;
    // After the first window commits (next frame), refine heights + fire the dev-mode warns
    // ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
    // code and these warns can never fire there (req-1 byte-identical-off preserved).
    const afterFirstFrame = () => {
      // D-10: measure the rendered rows.
      remeasureWindow();
      // D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
      // bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
      // process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
      const h = gridScrollEl ? gridScrollEl.clientHeight : 0;
      if (!h) {
        console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
      }
      // D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
      // CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
      // virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
      // ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
      // detected as a pagination that DIFFERS from that default (a consumer who set a real page
      // size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
      // virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
      const pg = pagination;
      const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
      if (manual !== true && pgConfigured) {
        console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
      }
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
  }

  // #14: seed the sort/filter announce baseline from the initial (post-mount) state so the LAZY
  // watch's first fire — a real user sort/filter — compares against the true starting values and
  // is classified correctly (a null sentinel would misread the first filter change as a sort change).
  announceState.sorting = effectiveSorting();
  announceState.columnFilters = effectiveColumnFilters();
  announceState.globalFilter = effectiveGlobalFilter();
});
onDestroy(() => (() => {
  if (virtualizerCleanup) virtualizerCleanup();
  // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
  teardownFillDrag();
  // §6 (260709-3qt): remove any live drag-select document listeners on a mid-drag unmount.
  teardownRangeDrag();
})());
$effect(() => (() => {
  maybeClearHistoryOnExternalSwap();
  if (!table) return;
  // Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
  // $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
  // bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
  // identical length) still re-feeds.
  const d = currentData() || [];
  if (d === lastData && d.length === lastDataLen) return;
  lastData = d;
  lastDataLen = d.length;
  reFeed();
})());

let __rozieWatchInitial_0 = true;
$effect(() => { (() => [sorting, globalFilter, columnFilters, pagination,
// Server-side page-count sources (#2): re-feed when the consumer's rowCount/pageCount
// changes at runtime (e.g. a server response updates the total) so getPageCount() and the
// Next button availability track the new total.
rowCount, pageCount, rowSelection, expanded, expandable, grouping, groupable, columnVisibility, columnSizing, columnOrder, columnPinning, selectionMode, (data || []).length,
// Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
// even when the fresh array is the SAME length (a single-cell edit replaces one row
// object → new array ref, identical length → the .length key alone would miss it). The
// controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
// writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
data, dataDefault,
// Column CONFIG prop (the `:columns` array form) — the sibling column source to
// $data.colReg (the `<Column>` children). Watch it so a runtime `:columns` swap re-feeds:
// columnDefs()/tableColumns() build the UNION of both, and reFeed re-passes columns.
// (Consumers memoize the array as with $props.data/$props.sorting; the uncontrolled
// <Column>-children path leaves $props.columns undefined — a stable no-op getter.)
columns, colReg])(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } (() => {
  reFeed();
  maybeClearHistoryOnExternalSwap();
})(); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { (() => [sorting, columnFilters, globalFilter, sortingDefault, columnFiltersDefault, globalFilterDefault])(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } (() => {
  const msg = buildSortFilterAnnounce();
  if (msg) liveAnnounce = msg;
})(); }); });
</script>

<div class="rozie-data-table-wrap" bind:this={__rozieRoot} data-rozie-s-d5dcab4c><div class="rdt-column-defs" style="display:none" aria-hidden="true" data-rozie-s-d5dcab4c>{@render children?.()}</div>{#if !!invalidMsg}<div class="rdt-sr-live" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c>{invalidMsg}</div>{/if}{#if !!pasteAnnounce}<div class="rdt-sr-live rdt-sr-paste" data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c>{pasteAnnounce}</div>{/if}{#if !!liveAnnounce}<div class="rdt-sr-live rdt-sr-sortfilter" data-testid="sortfilter-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c>{liveAnnounce}</div>{/if}<div class="rdt-toolbar" data-rozie-s-d5dcab4c><input class="rdt-global-filter" type="text" role="searchbox" aria-label="Search table" value={globalFilterValue()} oninput={($event) => { onGlobalFilterInput($event); }} data-rozie-s-d5dcab4c />{#if allLeafColumns().length}<details class="rdt-colvis" data-rozie-s-d5dcab4c><summary class="rdt-colvis-summary" data-rozie-s-d5dcab4c>Columns</summary><div class="rdt-colvis-menu" role="group" aria-label="Toggle columns" data-rozie-s-d5dcab4c>{#each allLeafColumns() as lc (lc.id)}<label class="rdt-colvis-item" data-rozie-s-d5dcab4c><input type="checkbox" class="rdt-colvis-checkbox" checked={lc.visible} onchange={($event) => { onToggleVisibility(lc.id); }} data-rozie-s-d5dcab4c /><span class="rdt-colvis-label" data-rozie-s-d5dcab4c>{rozieDisplay(lc.label)}</span></label>{/each}</div></details>{/if}</div>{#if groupable}<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>{#if groupBar}{@render groupBar({ grouping: groupingKeys(), groupableColumns: groupableColumns(), applyGrouping, clearGrouping })}{:else}{#each groupingKeys() as gk (gk)}<span class="rdt-group-token" data-group-token="" data-rozie-s-d5dcab4c>{rozieDisplay(gk)}</span>{/each}{/if}</div>{/if}{#if virtual}<div class="rdt-scroll" style={rozieStyle(maxHeight ? 'max-height:' + maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c><table class={["rozie-data-table", { 'rdt-sticky': stickyHeader }]} role={rozieAttr(tableRole())} aria-rowcount={rozieAttr(gridAriaRowCount())} onkeydown={($event) => { onGridKeyDown($event); }} onfocusin={($event) => { syncActiveFromEvent($event); }} onfocusout={($event) => { onGridFocusOut($event); }} onmousedown={($event) => { onGridMouseDown($event); }} ondblclick={($event) => { onGridDblClick($event); }} onclick={($event) => { onGridClick($event); }} data-rozie-s-d5dcab4c><thead class="rdt-thead" role="rowgroup" data-rozie-s-d5dcab4c>{#each headerGroups as hg, hgLevel (hg.id)}<tr class="rdt-tr" role="row" aria-rowindex={rozieAttr(hgLevel + 1)} data-rozie-s-d5dcab4c>{#each hg.headers as header (header.id)}<th class={["rdt-th", { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-expander-th': isExpanderColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg, header), hgLevel) }]} role="columnheader" data-col={rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel)} colspan={rozieAttr(header.colSpan > 1 ? header.colSpan : null)} data-col-index={rozieAttr(headerColIndexOf(hg, header))} tabindex={rozieAttr(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel))} aria-sort={rozieAttr(ariaSortFor(header.column.id))} style={rozieStyle(thStyle(header.column.id))} data-rozie-s-d5dcab4c>{#if isSelectColumn(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if selectAll}{@render selectAll({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows })}{:else}{#if selectionMode === 'multiple'}<input class="rdt-select-all" type="checkbox" aria-label="Select all rows" checked={isAllRowsSelected()} onchange={($event) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c />{/if}{/if}</span>{:else if isExpanderColumn(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c></span>{:else}<span style="display:contents" data-rozie-s-d5dcab4c>{#if header.column.getCanSort && header.column.getCanSort()}<button type="button" class="rdt-sort-btn" onclick={($event) => { onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-header-label" data-rozie-s-d5dcab4c>{#if colHeader}{@render colHeader({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) })}{:else}{rozieDisplay(headerLabel(header.column.id))}{/if}</span><span class="rdt-sort-ind" aria-hidden="true" data-rozie-s-d5dcab4c>{rozieDisplay(sortIndicator(header.column.id))}</span></button>{:else}<span style="display:contents" data-rozie-s-d5dcab4c><span class="rdt-header-label" data-rozie-s-d5dcab4c>{#if colHeader}{@render colHeader({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) })}{:else}{rozieDisplay(headerLabel(header.column.id))}{/if}</span></span>{/if}<Popover trigger="click" placement="bottom-end" strategy="fixed" offset={4} data-rozie-s-d5dcab4c>{#snippet anchor()}<button type="button" class="rdt-col-menu-trigger" aria-label={rozieAttr('Column options for ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c>⋯</button>{/snippet}<div class="rdt-col-menu" role="menu" data-rozie-s-d5dcab4c><button type="button" role="menuitem" class="rdt-col-menu-item" aria-pressed={columnPinSide(header.column.id) === 'left'} onclick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>Pin left</button><button type="button" role="menuitem" class="rdt-col-menu-item" aria-pressed={columnPinSide(header.column.id) === 'right'} onclick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>Pin right</button><button type="button" role="menuitem" class="rdt-col-menu-item" aria-pressed={!columnPinSide(header.column.id)} onclick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>Unpin</button><hr class="rdt-col-menu-sep" data-rozie-s-d5dcab4c /><button type="button" role="menuitem" class="rdt-col-menu-item" onclick={($event) => { onHideColumn(header.column.id, $event); }} data-rozie-s-d5dcab4c>Hide column</button></div></Popover><button type="button" class="rdt-resize-handle" aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} onpointerdown={($event) => { onResizeStart(header.column.id, $event); }} ontouchstart={($event) => { onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-resize-grip" aria-hidden="true" data-rozie-s-d5dcab4c></span></button></span>{/if}</th>{/each}</tr>{/each}{#if hasAnyFilterableColumn()}<tr class="rdt-filter-row" data-rozie-s-d5dcab4c>{#each headerGroups[headerGroups.length - 1].headers as header (header.id)}<th class="rdt-filter-cell" role="presentation" style={rozieStyle(pinStyle(header.column.id))} data-rozie-s-d5dcab4c>{#if isSelectColumn(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c></span>{:else if isExpanderColumn(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c></span>{:else}<span style="display:contents" data-rozie-s-d5dcab4c>{#if columnIsFilterable(header.column.id) && !hasFilterSlot()}<input class="rdt-col-filter" type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} value={columnFilterValue(header.column.id)} oninput={($event) => { onColumnFilterInput(header.column.id, $event); }} onclick={($event) => { stopEvent($event); }} data-rozie-s-d5dcab4c />{/if}{#if columnIsFilterable(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{@render filter?.({ columnId: header.column.id, value: columnFilterValue(header.column.id), uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}</span>{/if}</span>{/if}</th>{/each}</tr>{/if}</thead><tbody class="rdt-tbody" role="rowgroup" data-rozie-s-d5dcab4c><tr class="rdt-spacer" aria-hidden="true" data-rozie-s-d5dcab4c><td colspan={rozieAttr(visibleColCount())} style={rozieStyle('height:' + padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td></tr>{#each windowedRows() as wr (wr.row.id)}<tr class={["rdt-tr", { 'rdt-group-header': rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned }]} role="row" data-row={rozieAttr(wr.vi.index)} aria-rowindex={rozieAttr(headerRowCount() + wr.vi.index + 1)} data-index={rozieAttr(wr.vi.index)} data-pinned={rozieAttr(wr.pinned ? 'true' : null)} data-depth={rozieAttr(wr.row.depth)} data-group-header={rozieAttr(rowIsGrouped(wr.row) ? wr.row.id : null)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(wr.row) ? wr.row.id : null)} aria-expanded={rozieAttr(rowIsGrouped(wr.row) ? !!rowIsExpanded(wr.row) : null)} aria-selected={rozieAttr(selectionMode !== 'none' ? !!rowIsSelected(wr.row) : null)} aria-level={rozieAttr(groupingActive() ? wr.row.depth + 1 : null)} data-rozie-s-d5dcab4c>{#each visibleCellsFor(wr.row) as cell (cell.id)}<td class={["rdt-td", { 'rdt-select-td': isSelectColumn(cell.column.id), 'rdt-expander-td': isExpanderColumn(cell.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cell)), 'rdt-cell-active': isActiveCell(String(wr.vi.index), colIndexOf(wr.row, cell)) }]} role={rozieAttr(cellRole())} data-col={rozieAttr(cell.column.id)} data-grid-cell="" data-row={rozieAttr(wr.vi.index)} data-col-index={rozieAttr(colIndexOf(wr.row, cell))} tabindex={rozieAttr(cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cell)))} style={rozieStyle(bodyCellStyle(wr.row, cell.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cell)))} data-in-range={rozieAttr(inRange(wr.vi.index, colIndexOf(wr.row, cell)) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cell) ? cell.column.id : null)} data-rozie-s-d5dcab4c>{#if isExpanderColumn(cell.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if rowCanExpand(wr.row)}<button type="button" class="rdt-expander" data-expander="" aria-expanded={!!rowIsExpanded(wr.row)} aria-label={rozieAttr(rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row')} onclick={($event) => { onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c>{rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸')}</button>{/if}</span>{:else if isSelectColumn(cell.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if selectCell}{@render selectCell({ row: wr.row.original, checked: rowIsSelected(wr.row), toggle: e => onToggleRow(wr.row, e) })}{:else}<input class="rdt-select-row" type="checkbox" aria-label="Select row" checked={rowIsSelected(wr.row)} onchange={($event) => { onToggleRow(wr.row, $event); }} data-rozie-s-d5dcab4c />{/if}</span>{:else if cellIsGrouped(cell)}<span style="display:contents" data-rozie-s-d5dcab4c><button type="button" class="rdt-expander rdt-group-toggle" data-expander="" aria-expanded={!!rowIsExpanded(wr.row)} aria-label={rozieAttr(rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group')} onclick={($event) => { onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c>{rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸')}</button><span class="rdt-group-value" data-rozie-s-d5dcab4c>{#if cell$$slot}{@render cell$$slot({ columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue() })}{:else}{rozieDisplay(cell.getValue())}{/if}</span><span class="rdt-group-count" data-rozie-s-d5dcab4c>{rozieDisplay('(' + groupSubRowCount(wr.row) + ')')}</span></span>{:else if isEditing(wr.vi.index, colIndexOf(wr.row, cell))}<span style="display:contents" data-rozie-s-d5dcab4c>{#if hasEditorSlot(cell.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{@render editor?.({ columnId: cell.column.id, column: cell.column, row: wr.row.original, value: editorValueFor(cell.column.id), commit: editorCommitFor(cell.column.id), cancel: editorCancelFor(), autofocus: editorAutofocusFor(cell.column.id, wr.vi.index) })}</span>{:else if editorTypeOf(cell.column.id) === 'number'}<input class="rdt-cell-editor" type="number" data-editing-cell="" value={editorValueFor(cell.column.id)} oninput={($event) => { onCellEditorInput(cell.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{:else if editorTypeOf(cell.column.id) === 'select'}<select class="rdt-cell-editor" data-editing-cell="" value={rozieAttr(editorValueFor(cell.column.id))} onchange={($event) => { onCellEditorInput(cell.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c>{#each editorOptionsOf(cell.column.id) as opt (opt.value)}<option value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c>{rozieDisplay(opt.label)}</option>{/each}</select>{:else if editorTypeOf(cell.column.id) === 'checkbox'}<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" checked={editorCheckedFor(cell.column.id)} onchange={($event) => { onCellEditorCheckbox(cell.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{:else}<input class="rdt-cell-editor" type="text" data-editing-cell="" value={editorValueFor(cell.column.id)} oninput={($event) => { onCellEditorInput(cell.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{/if}</span>{:else if cellIsPlaceholder(cell)}<span style="display:contents" data-rozie-s-d5dcab4c></span>{:else}<span class="rdt-cell-value" data-rozie-s-d5dcab4c>{#if cell$$slot}{@render cell$$slot({ columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue() })}{:else}{rozieDisplay(cell.getValue())}{/if}</span>{/if}{#if isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cell))}<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" onpointerdown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c></span>{/if}</td>{/each}</tr>{#if rowShowsDetail(wr.row)}<tr class="rdt-detail-row" role="row" data-detail-row={rozieAttr(wr.row.id)} data-rozie-s-d5dcab4c><td class="rdt-detail-cell" colspan={rozieAttr(visibleColCount())} data-rozie-s-d5dcab4c>{@render detail?.({ row: wr.row.original })}</td></tr>{/if}{/each}<tr class="rdt-spacer" aria-hidden="true" data-rozie-s-d5dcab4c><td colspan={rozieAttr(visibleColCount())} style={rozieStyle('height:' + padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td></tr></tbody></table></div>{:else}<table class={["rozie-data-table", { 'rdt-sticky': stickyHeader }]} role={rozieAttr(tableRole())} aria-rowcount={rozieAttr(gridAriaRowCount())} onkeydown={($event) => { onGridKeyDown($event); }} onfocusin={($event) => { syncActiveFromEvent($event); }} onfocusout={($event) => { onGridFocusOut($event); }} onmousedown={($event) => { onGridMouseDown($event); }} ondblclick={($event) => { onGridDblClick($event); }} onclick={($event) => { onGridClick($event); }} data-rozie-s-d5dcab4c><thead class="rdt-thead" role="rowgroup" data-rozie-s-d5dcab4c>{#each headerGroups as hg, hgLevel (hg.id)}<tr class="rdt-tr" role="row" aria-rowindex={rozieAttr(hgLevel + 1)} data-rozie-s-d5dcab4c>{#each hg.headers as header (header.id)}<th class={["rdt-th", { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-expander-th': isExpanderColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg, header), hgLevel) }]} role="columnheader" data-col={rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel)} colspan={rozieAttr(header.colSpan > 1 ? header.colSpan : null)} data-col-index={rozieAttr(headerColIndexOf(hg, header))} tabindex={rozieAttr(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel))} aria-sort={rozieAttr(ariaSortFor(header.column.id))} style={rozieStyle(thStyle(header.column.id))} data-rozie-s-d5dcab4c>{#if isSelectColumn(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if selectAll}{@render selectAll({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows })}{:else}{#if selectionMode === 'multiple'}<input class="rdt-select-all" type="checkbox" aria-label="Select all rows" checked={isAllRowsSelected()} onchange={($event) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c />{/if}{/if}</span>{:else if isExpanderColumn(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c></span>{:else}<span style="display:contents" data-rozie-s-d5dcab4c>{#if header.column.getCanSort && header.column.getCanSort()}<button type="button" class="rdt-sort-btn" onclick={($event) => { onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-header-label" data-rozie-s-d5dcab4c>{#if colHeader}{@render colHeader({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) })}{:else}{rozieDisplay(headerLabel(header.column.id))}{/if}</span><span class="rdt-sort-ind" aria-hidden="true" data-rozie-s-d5dcab4c>{rozieDisplay(sortIndicator(header.column.id))}</span></button>{:else}<span style="display:contents" data-rozie-s-d5dcab4c><span class="rdt-header-label" data-rozie-s-d5dcab4c>{#if colHeader}{@render colHeader({ columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) })}{:else}{rozieDisplay(headerLabel(header.column.id))}{/if}</span></span>{/if}<Popover trigger="click" placement="bottom-end" strategy="fixed" offset={4} data-rozie-s-d5dcab4c>{#snippet anchor()}<button type="button" class="rdt-col-menu-trigger" aria-label={rozieAttr('Column options for ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c>⋯</button>{/snippet}<div class="rdt-col-menu" role="menu" data-rozie-s-d5dcab4c><button type="button" role="menuitem" class="rdt-col-menu-item" aria-pressed={columnPinSide(header.column.id) === 'left'} onclick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>Pin left</button><button type="button" role="menuitem" class="rdt-col-menu-item" aria-pressed={columnPinSide(header.column.id) === 'right'} onclick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>Pin right</button><button type="button" role="menuitem" class="rdt-col-menu-item" aria-pressed={!columnPinSide(header.column.id)} onclick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>Unpin</button><hr class="rdt-col-menu-sep" data-rozie-s-d5dcab4c /><button type="button" role="menuitem" class="rdt-col-menu-item" onclick={($event) => { onHideColumn(header.column.id, $event); }} data-rozie-s-d5dcab4c>Hide column</button></div></Popover><button type="button" class="rdt-resize-handle" aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} onpointerdown={($event) => { onResizeStart(header.column.id, $event); }} ontouchstart={($event) => { onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-resize-grip" aria-hidden="true" data-rozie-s-d5dcab4c></span></button></span>{/if}</th>{/each}</tr>{/each}{#if hasAnyFilterableColumn()}<tr class="rdt-filter-row" data-rozie-s-d5dcab4c>{#each headerGroups[headerGroups.length - 1].headers as header (header.id)}<th class="rdt-filter-cell" role="presentation" style={rozieStyle(pinStyle(header.column.id))} data-rozie-s-d5dcab4c>{#if isSelectColumn(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c></span>{:else if isExpanderColumn(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c></span>{:else}<span style="display:contents" data-rozie-s-d5dcab4c>{#if columnIsFilterable(header.column.id) && !hasFilterSlot()}<input class="rdt-col-filter" type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} value={columnFilterValue(header.column.id)} oninput={($event) => { onColumnFilterInput(header.column.id, $event); }} onclick={($event) => { stopEvent($event); }} data-rozie-s-d5dcab4c />{/if}{#if columnIsFilterable(header.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{@render filter?.({ columnId: header.column.id, value: columnFilterValue(header.column.id), uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}</span>{/if}</span>{/if}</th>{/each}</tr>{/if}</thead><tbody class="rdt-tbody" role="rowgroup" data-rozie-s-d5dcab4c>{#each rows as row (row.id)}<tr class={["rdt-tr", { 'rdt-group-header': rowIsGrouped(row) }]} role="row" data-depth={rozieAttr(row.depth)} aria-rowindex={rozieAttr(bodyAriaRowIndex(row))} data-group-header={rozieAttr(rowIsGrouped(row) ? row.id : null)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(row) ? row.id : null)} aria-expanded={rozieAttr(rowIsGrouped(row) ? !!rowIsExpanded(row) : null)} aria-selected={rozieAttr(selectionMode !== 'none' ? !!rowIsSelected(row) : null)} aria-level={rozieAttr(groupingActive() ? row.depth + 1 : null)} data-rozie-s-d5dcab4c>{#each visibleCellsFor(row) as cell (cell.id)}<td class={["rdt-td", { 'rdt-select-td': isSelectColumn(cell.column.id), 'rdt-expander-td': isExpanderColumn(cell.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cell)), 'rdt-cell-active': isActiveCell(String(rowIndexOf(row)), colIndexOf(row, cell)) }]} role={rozieAttr(cellRole())} data-col={rozieAttr(cell.column.id)} data-grid-cell="" data-row={rozieAttr(rowIndexOf(row))} data-col-index={rozieAttr(colIndexOf(row, cell))} tabindex={rozieAttr(cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cell)))} style={rozieStyle(bodyCellStyle(row, cell.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cell)))} data-in-range={rozieAttr(inRange(rowIndexOf(row), colIndexOf(row, cell)) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cell) ? cell.column.id : null)} data-rozie-s-d5dcab4c>{#if isExpanderColumn(cell.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if rowCanExpand(row)}<button type="button" class="rdt-expander" data-expander="" aria-expanded={!!rowIsExpanded(row)} aria-label={rozieAttr(rowIsExpanded(row) ? 'Collapse row' : 'Expand row')} onclick={($event) => { onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c>{rozieDisplay(rowIsExpanded(row) ? '▾' : '▸')}</button>{/if}</span>{:else if isSelectColumn(cell.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{#if selectCell}{@render selectCell({ row: row.original, checked: rowIsSelected(row), toggle: e => onToggleRow(row, e) })}{:else}<input class="rdt-select-row" type="checkbox" aria-label="Select row" checked={rowIsSelected(row)} onchange={($event) => { onToggleRow(row, $event); }} data-rozie-s-d5dcab4c />{/if}</span>{:else if cellIsGrouped(cell)}<span style="display:contents" data-rozie-s-d5dcab4c><button type="button" class="rdt-expander rdt-group-toggle" data-expander="" aria-expanded={!!rowIsExpanded(row)} aria-label={rozieAttr(rowIsExpanded(row) ? 'Collapse group' : 'Expand group')} onclick={($event) => { onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c>{rozieDisplay(rowIsExpanded(row) ? '▾' : '▸')}</button><span class="rdt-group-value" data-rozie-s-d5dcab4c>{#if cell$$slot}{@render cell$$slot({ columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue() })}{:else}{rozieDisplay(cell.getValue())}{/if}</span><span class="rdt-group-count" data-rozie-s-d5dcab4c>{rozieDisplay('(' + groupSubRowCount(row) + ')')}</span></span>{:else if isEditing(rowIndexOf(row), colIndexOf(row, cell))}<span style="display:contents" data-rozie-s-d5dcab4c>{#if hasEditorSlot(cell.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{@render editor?.({ columnId: cell.column.id, column: cell.column, row: row.original, value: editorValueFor(cell.column.id), commit: editorCommitFor(cell.column.id), cancel: editorCancelFor(), autofocus: editorAutofocusFor(cell.column.id, rowIndexOf(row)) })}</span>{:else if editorTypeOf(cell.column.id) === 'number'}<input class="rdt-cell-editor" type="number" data-editing-cell="" value={editorValueFor(cell.column.id)} oninput={($event) => { onCellEditorInput(cell.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{:else if editorTypeOf(cell.column.id) === 'select'}<select class="rdt-cell-editor" data-editing-cell="" value={rozieAttr(editorValueFor(cell.column.id))} onchange={($event) => { onCellEditorInput(cell.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c>{#each editorOptionsOf(cell.column.id) as opt (opt.value)}<option value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c>{rozieDisplay(opt.label)}</option>{/each}</select>{:else if editorTypeOf(cell.column.id) === 'checkbox'}<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" checked={editorCheckedFor(cell.column.id)} onchange={($event) => { onCellEditorCheckbox(cell.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{:else}<input class="rdt-cell-editor" type="text" data-editing-cell="" value={editorValueFor(cell.column.id)} oninput={($event) => { onCellEditorInput(cell.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{/if}</span>{:else if cellIsPlaceholder(cell)}<span style="display:contents" data-rozie-s-d5dcab4c></span>{:else}<span class="rdt-cell-value" data-rozie-s-d5dcab4c>{#if cell$$slot}{@render cell$$slot({ columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue() })}{:else}{rozieDisplay(cell.getValue())}{/if}</span>{/if}{#if isFillHandleCell(rowIndexOf(row), colIndexOf(row, cell))}<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" onpointerdown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c></span>{/if}</td>{/each}</tr>{#if rowShowsDetail(row)}<tr class="rdt-detail-row" role="row" data-detail-row={rozieAttr(row.id)} data-rozie-s-d5dcab4c><td class="rdt-detail-cell" colspan={rozieAttr(visibleColCount())} data-rozie-s-d5dcab4c>{@render detail?.({ row: row.original })}</td></tr>{/if}{/each}</tbody></table>{/if}{#if !virtual}<div class="rdt-pagination" role="group" aria-label="Pagination" data-rozie-s-d5dcab4c><button type="button" class="rdt-page-btn rdt-page-prev" disabled={!canPrevPage()} onclick={($event) => { onPrevPage(); }} data-rozie-s-d5dcab4c>Prev</button><span class="rdt-page-status" aria-live="polite" data-rozie-s-d5dcab4c>{rozieDisplay('Page ' + (pageIndex() + 1) + ' of ' + displayPageCount())}</span><button type="button" class="rdt-page-btn rdt-page-next" disabled={!canNextPage()} onclick={($event) => { onNextPage(); }} data-rozie-s-d5dcab4c>Next</button><select class="rdt-page-size" aria-label="Rows per page" value={rozieAttr(pageSize())} onchange={($event) => { onPageSizeChange($event); }} data-rozie-s-d5dcab4c><option value={10} data-rozie-s-d5dcab4c>10</option><option value={25} data-rozie-s-d5dcab4c>25</option><option value={50} data-rozie-s-d5dcab4c>50</option><option value={100} data-rozie-s-d5dcab4c>100</option></select></div>{/if}</div>

<style>
:global {
  .rozie-data-table[data-rozie-s-d5dcab4c] {
    border-collapse: collapse;
    width: 100%;
    font: var(--rdt-font, 14px system-ui, sans-serif);
    color: var(--rdt-color, inherit);
  }
  .rdt-sr-live[data-rozie-s-d5dcab4c] {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-cell-editor[data-rozie-s-d5dcab4c] {
    font: inherit;
    width: 100%;
    box-sizing: border-box;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[aria-invalid="true"][data-rozie-s-d5dcab4c] {
    outline: var(--rdt-invalid-outline, 2px solid #d33);
    outline-offset: -2px;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td.rdt-in-range[data-rozie-s-d5dcab4c] {
    background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td.rdt-cell-active[data-rozie-s-d5dcab4c],
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th.rdt-cell-active[data-rozie-s-d5dcab4c] {
    outline: var(--rdt-active-cell-outline, 2px solid #2563eb);
    outline-offset: -2px;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
    position: relative;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-fill-handle[data-rozie-s-d5dcab4c] {
    position: absolute;
    right: -3px;
    bottom: -3px;
    width: 8px;
    height: 8px;
    background: var(--rdt-fill-handle-bg, #2563eb);
    border: 1px solid #fff;
    cursor: crosshair;
    z-index: 1;
    touch-action: none;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c],
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
    padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
    text-align: left;
    border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
    font-weight: var(--rdt-header-weight, 600);
    /* OPAQUE default (was rgba(0,0,0,0.03)): a translucent header lets the scrolling body
       bleed through in sticky mode. #f7f7f7 is the visual equivalent of the old 3%-black
       tint over white, but solid. The three design-system themes already ship opaque
       header backgrounds; this makes the zero-config default consistent with them. */
    background: var(--rdt-header-bg, #f7f7f7);
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-btn[data-rozie-s-d5dcab4c] {
    display: inline-flex;
    align-items: center;
    gap: var(--rdt-sort-gap, 0.35em);
    background: none;
    border: none;
    font: inherit;
    font-weight: inherit;
    color: inherit;
    cursor: pointer;
    padding: 0;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-ind[data-rozie-s-d5dcab4c] {
    font-size: 0.8em;
    opacity: var(--rdt-sort-ind-opacity, 0.7);
  }
  .rozie-data-table.rdt-sticky[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
    position: sticky;
    top: var(--rdt-sticky-top, 0);
    z-index: var(--rdt-sticky-z, 2);
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-scroll[data-rozie-s-d5dcab4c] {
    max-height: var(--rozie-data-table-max-height);
    overflow: auto;
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-bar-host[data-rozie-s-d5dcab4c] {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--rdt-group-bar-gap, 0.375rem);
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-token {
    display: inline-flex;
    align-items: center;
    padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
    border-radius: var(--rdt-group-token-radius, 999px);
    background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
    font-size: var(--rdt-group-token-size, 0.8125em);
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-header[data-rozie-s-d5dcab4c] {
    background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
    font-weight: var(--rdt-group-header-weight, 600);
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-toggle[data-rozie-s-d5dcab4c] {
    margin-right: var(--rdt-group-toggle-gap, 0.375rem);
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-count[data-rozie-s-d5dcab4c] {
    margin-left: var(--rdt-group-count-gap, 0.375rem);
    opacity: var(--rdt-group-count-opacity, 0.65);
    font-weight: 400;
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] {
    display: flex;
    flex-direction: column;
    gap: var(--rdt-chrome-gap, 0.5rem);
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-toolbar[data-rozie-s-d5dcab4c] {
    display: flex;
    gap: var(--rdt-toolbar-gap, 0.5rem);
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-global-filter[data-rozie-s-d5dcab4c],
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter {
    font: inherit;
    /* border-box so the padding + border count INSIDE the declared width — without it
       the col-filter's `width: 100%` + padding overflows its (constrained) header cell. */
    box-sizing: border-box;
    padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
    border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
    border-radius: var(--rdt-filter-radius, 4px);
    background: var(--rdt-filter-bg, transparent);
    color: inherit;
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter {
    display: block;
    margin-top: var(--rdt-col-filter-gap, 0.25rem);
    width: 100%;
    font-weight: normal;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-filter-row[data-rozie-s-d5dcab4c] {
    background: var(--rdt-filter-row-bg, rgba(0, 0, 0, 0.015));
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-filter-cell[data-rozie-s-d5dcab4c] {
    padding: var(--rdt-filter-cell-padding, 0.35rem 0.75rem);
    border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-filter-row[data-rozie-s-d5dcab4c] .rdt-col-filter {
    font-size: var(--rdt-filter-row-input-size, 0.9em);
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-pagination[data-rozie-s-d5dcab4c] {
    display: flex;
    align-items: center;
    gap: var(--rdt-pagination-gap, 0.5rem);
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c] {
    font: inherit;
    cursor: pointer;
    padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
    border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
    border-radius: var(--rdt-page-btn-radius, 4px);
    background: var(--rdt-page-btn-bg, transparent);
    color: inherit;
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c]:disabled {
    opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
    cursor: default;
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-status[data-rozie-s-d5dcab4c] {
    font-size: var(--rdt-page-status-size, 0.9em);
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-size[data-rozie-s-d5dcab4c] {
    font: inherit;
    padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
    border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
    border-radius: var(--rdt-page-size-radius, 4px);
    background: var(--rdt-page-size-bg, transparent);
    color: inherit;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
    position: relative;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c] {
    position: absolute;
    top: 0;
    right: 0;
    height: 100%;
    width: var(--rdt-resize-handle-width, 6px);
    padding: 0;
    border: none;
    background: none;
    cursor: col-resize;
    touch-action: none;
    user-select: none;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
    display: block;
    width: var(--rdt-resize-grip-width, 2px);
    height: 100%;
    margin: 0 auto;
    background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c]:hover .rdt-resize-grip[data-rozie-s-d5dcab4c],
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th-resizing[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
    background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-trigger[data-rozie-s-d5dcab4c] {
    font: inherit;
    font-size: var(--rdt-col-menu-trigger-size, 0.9em);
    line-height: 1;
    cursor: pointer;
    margin-left: var(--rdt-col-menu-trigger-margin, 0.35em);
    padding: var(--rdt-col-menu-trigger-padding, 0.15em 0.4em);
    border: var(--rdt-col-menu-trigger-border, 1px solid rgba(0, 0, 0, 0.15));
    border-radius: var(--rdt-col-menu-trigger-radius, 3px);
    background: var(--rdt-col-menu-trigger-bg, transparent);
    color: inherit;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-trigger[data-rozie-s-d5dcab4c]:hover {
    background: var(--rdt-col-menu-trigger-hover-bg, rgba(0, 0, 0, 0.06));
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-trigger[data-rozie-s-d5dcab4c]:focus-visible {
    outline: var(--rdt-col-menu-trigger-focus-outline, 2px solid #2563eb);
    outline-offset: 1px;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu[data-rozie-s-d5dcab4c] {
    display: flex;
    flex-direction: column;
    gap: var(--rdt-col-menu-item-gap, 0.15rem);
    min-width: var(--rdt-col-menu-min-width, 9rem);
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-item[data-rozie-s-d5dcab4c] {
    display: block;
    width: 100%;
    text-align: left;
    font: inherit;
    cursor: pointer;
    padding: var(--rdt-col-menu-item-padding, 0.35em 0.6em);
    border: none;
    border-radius: var(--rdt-col-menu-item-radius, 3px);
    background: none;
    color: inherit;
    white-space: nowrap;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-item[data-rozie-s-d5dcab4c]:hover {
    background: var(--rdt-col-menu-item-hover-bg, rgba(0, 0, 0, 0.06));
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-item[aria-pressed='true'][data-rozie-s-d5dcab4c] {
    background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
    font-weight: 700;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-sep[data-rozie-s-d5dcab4c] {
    margin: var(--rdt-col-menu-sep-margin, 0.25rem 0);
    border: none;
    border-top: var(--rdt-col-menu-sep-border, 1px solid rgba(0, 0, 0, 0.1));
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis[data-rozie-s-d5dcab4c] {
    position: relative;
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-summary[data-rozie-s-d5dcab4c] {
    cursor: pointer;
    font: inherit;
    padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
    border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
    border-radius: var(--rdt-colvis-summary-radius, 4px);
    list-style: none;
    user-select: none;
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-menu[data-rozie-s-d5dcab4c] {
    position: absolute;
    z-index: var(--rdt-colvis-menu-z, 5);
    margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
    padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
    display: flex;
    flex-direction: column;
    gap: var(--rdt-colvis-item-gap, 0.25rem);
    border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
    border-radius: var(--rdt-colvis-menu-radius, 4px);
    background: var(--rdt-colvis-menu-bg, #fff);
    box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
  }
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-item[data-rozie-s-d5dcab4c] {
    display: flex;
    align-items: center;
    gap: var(--rdt-colvis-label-gap, 0.4em);
    cursor: pointer;
    white-space: nowrap;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-th[data-rozie-s-d5dcab4c],
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-td[data-rozie-s-d5dcab4c] {
    width: var(--rdt-select-col-width, 44px);
    text-align: var(--rdt-select-col-align, center);
    white-space: nowrap;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-expander-th[data-rozie-s-d5dcab4c],
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-expander-td[data-rozie-s-d5dcab4c] {
    width: var(--rdt-expander-col-width, 40px);
    text-align: var(--rdt-expander-col-align, center);
    white-space: nowrap;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-all[data-rozie-s-d5dcab4c],
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-row[data-rozie-s-d5dcab4c] {
    cursor: pointer;
    accent-color: var(--rdt-select-accent, currentColor);
  }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, TemplateRef, ViewEncapsulation, computed, contentChildren, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { RozieSlot, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay, rozieToken } from '@rozie/runtime-angular';

import { Popover } from '@rozie-ui/popover-angular';

import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';

// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).

interface DefaultCtx {}

interface GroupBarCtx {
  $implicit: { grouping: any; groupableColumns: any; applyGrouping: any; clearGrouping: any };
  grouping: any;
  groupableColumns: any;
  applyGrouping: any;
  clearGrouping: any;
}

interface SelectAllCtx {
  $implicit: { checked: any; indeterminate: any; toggle: any };
  checked: any;
  indeterminate: any;
  toggle: any;
}

interface ColHeaderCtx {
  $implicit: { columnId: any; column: any; label: any };
  columnId: any;
  column: any;
  label: any;
}

interface FilterCtx {
  $implicit: { columnId: any; value: any; uniqueValues: any; minMax: any; setFilter: any };
  columnId: any;
  value: any;
  uniqueValues: any;
  minMax: any;
  setFilter: any;
}

interface SelectCellCtx {
  $implicit: { row: any; checked: any; toggle: any };
  row: any;
  checked: any;
  toggle: any;
}

interface CellCtx {
  $implicit: { columnId: any; column: any; row: any; value: any };
  columnId: any;
  column: any;
  row: any;
  value: any;
}

interface EditorCtx {
  $implicit: { columnId: any; column: any; row: any; value: any; commit: any; cancel: any; autofocus: any };
  columnId: any;
  column: any;
  row: any;
  value: any;
  commit: any;
  cancel: any;
  autofocus: any;
}

interface DetailCtx {
  $implicit: { row: any };
  row: any;
}

@Component({
  selector: 'rozie-data-table',
  standalone: true,
  imports: [NgTemplateOutlet, NgClass, Popover],
  template: `


    <div class="rozie-data-table-wrap" #__rozieRoot>

    <div class="rdt-column-defs" style="display:none" aria-hidden="true"><ng-container *ngTemplateOutlet="(defaultTpl ?? __rozieFillMap()['defaultSlot'] ?? templates()?.['defaultSlot'])" /></div>

    @if (!!invalidMsg()) {
    <div class="rdt-sr-live" role="status" aria-live="polite" aria-atomic="true">{{ invalidMsg() }}</div>
    }@if (!!pasteAnnounce()) {
    <div class="rdt-sr-live rdt-sr-paste" data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true">{{ pasteAnnounce() }}</div>
    }@if (!!liveAnnounce()) {
    <div class="rdt-sr-live rdt-sr-sortfilter" data-testid="sortfilter-announce" role="status" aria-live="polite" aria-atomic="true">{{ liveAnnounce() }}</div>
    }<div class="rdt-toolbar">
      <input class="rdt-global-filter" type="text" role="searchbox" aria-label="Search table" [value]="globalFilterValue()" (input)="onGlobalFilterInput($event)" />
      
      @if (allLeafColumns().length) {
    <details class="rdt-colvis">
        <summary class="rdt-colvis-summary">Columns</summary>
        <div class="rdt-colvis-menu" role="group" aria-label="Toggle columns">
          @for (lc of allLeafColumns(); track lc.id) {
    <label class="rdt-colvis-item">
            <input type="checkbox" class="rdt-colvis-checkbox" [checked]="lc.visible" (change)="onToggleVisibility(lc.id)" />
            <span class="rdt-colvis-label">{{ rozieDisplay(lc.label) }}</span>
          </label>
    }
        </div>
      </details>
    }</div>


    @if (groupable()) {
    <div class="rdt-group-bar-host">
      @if ((groupBarTpl ?? __rozieFillMap()['groupBar'] ?? templates()?.['groupBar'])) {
    <ng-container *ngTemplateOutlet="(groupBarTpl ?? __rozieFillMap()['groupBar'] ?? templates()?.['groupBar']); context: { $implicit: { grouping: groupingKeys(), groupableColumns: groupableColumns(), applyGrouping: applyGrouping, clearGrouping: clearGrouping }, grouping: groupingKeys(), groupableColumns: groupableColumns(), applyGrouping: applyGrouping, clearGrouping: clearGrouping }" />
    } @else {

        @for (gk of groupingKeys(); track gk) {
    <span class="rdt-group-token" data-group-token="">{{ rozieDisplay(gk) }}</span>
    }
      
    }
    </div>
    }@if (virtual()) {
    <div class="rdt-scroll" [attr.style]="__style">
    <table class="rozie-data-table" [ngClass]="{ 'rdt-sticky': stickyHeader() }" [attr.role]="rozieAttr(tableRole())" [attr.aria-rowcount]="rozieAttr(gridAriaRowCount())" (keydown)="onGridKeyDown($event)" (focusin)="syncActiveFromEvent($event)" (focusout)="onGridFocusOut($event)" (mousedown)="onGridMouseDown($event)" (dblclick)="onGridDblClick($event)" (click)="onGridClick($event)">
      <thead class="rdt-thead" role="rowgroup">
        @for (hg of headerGroups(); track hg.id; let hgLevel = $index) {
    <tr class="rdt-tr" role="row" [attr.aria-rowindex]="rozieAttr(hgLevel + 1)">
          @for (header of hg.headers; track header.id) {
    <th class="rdt-th" [ngClass]="{ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-expander-th': isExpanderColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg, header), hgLevel) }" role="columnheader" [attr.data-col]="rozieAttr(header.column.id)" data-grid-cell="" data-row="__header" [attr.data-header-level]="rozieAttr(hgLevel)" [attr.colspan]="rozieAttr(header.colSpan > 1 ? header.colSpan : null)" [attr.data-col-index]="rozieAttr(headerColIndexOf(hg, header))" [attr.tabindex]="rozieAttr(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel))" [attr.aria-sort]="rozieAttr(ariaSortFor(header.column.id))" [style]="thStyle(header.column.id)">
            @if (isSelectColumn(header.column.id)) {
    <span style="display:contents">
              @if ((selectAllTpl ?? __rozieFillMap()['selectAll'] ?? templates()?.['selectAll'])) {
    <ng-container *ngTemplateOutlet="(selectAllTpl ?? __rozieFillMap()['selectAll'] ?? templates()?.['selectAll']); context: { $implicit: { checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }, checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }" />
    } @else {

                @if (selectionMode() === 'multiple') {
    <input class="rdt-select-all" type="checkbox" aria-label="Select all rows" [checked]="isAllRowsSelected()" (change)="onToggleAllRows($event)" />
    }
    }
            </span>
    } @else if (isExpanderColumn(header.column.id)) {
    <span style="display:contents"></span>
    } @else {
    <span style="display:contents">
              @if (header.column.getCanSort && header.column.getCanSort()) {
    <button type="button" class="rdt-sort-btn" (click)="onHeaderSort(header.column.id, $event)">
                <span class="rdt-header-label">
                  @if ((colHeaderTpl ?? __rozieFillMap()['colHeader'] ?? templates()?.['colHeader'])) {
    <ng-container *ngTemplateOutlet="(colHeaderTpl ?? __rozieFillMap()['colHeader'] ?? templates()?.['colHeader']); context: { $implicit: { columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }, columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }" />
    } @else {
    {{ rozieDisplay(headerLabel(header.column.id)) }}
    }
                </span>
                <span class="rdt-sort-ind" aria-hidden="true">{{ rozieDisplay(sortIndicator(header.column.id)) }}</span>
              </button>
    } @else {
    <span style="display:contents">
                <span class="rdt-header-label">
                  @if ((colHeaderTpl ?? __rozieFillMap()['colHeader'] ?? templates()?.['colHeader'])) {
    <ng-container *ngTemplateOutlet="(colHeaderTpl ?? __rozieFillMap()['colHeader'] ?? templates()?.['colHeader']); context: { $implicit: { columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }, columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }" />
    } @else {
    {{ rozieDisplay(headerLabel(header.column.id)) }}
    }
                </span>
              </span>
    }<rozie-popover trigger="click" placement="bottom-end" strategy="fixed" [offset]="4"><ng-template #anchor>
                  <button type="button" class="rdt-col-menu-trigger" [attr.aria-label]="rozieAttr('Column options for ' + headerLabel(header.column.id))">⋯</button>
                </ng-template><ng-template #defaultSlot><div class="rdt-col-menu" role="menu">
                  <button type="button" role="menuitem" class="rdt-col-menu-item" [attr.aria-pressed]="columnPinSide(header.column.id) === 'left'" (click)="onPinColumn(header.column.id, 'left', $event)">Pin left</button>
                  <button type="button" role="menuitem" class="rdt-col-menu-item" [attr.aria-pressed]="columnPinSide(header.column.id) === 'right'" (click)="onPinColumn(header.column.id, 'right', $event)">Pin right</button>
                  <button type="button" role="menuitem" class="rdt-col-menu-item" [attr.aria-pressed]="!columnPinSide(header.column.id)" (click)="onPinColumn(header.column.id, false, $event)">Unpin</button>
                  <hr class="rdt-col-menu-sep" />
                  <button type="button" role="menuitem" class="rdt-col-menu-item" (click)="onHideColumn(header.column.id, $event)">Hide column</button>
                </div></ng-template></rozie-popover>
              <button type="button" class="rdt-resize-handle" [attr.aria-label]="rozieAttr('Resize ' + headerLabel(header.column.id))" (pointerdown)="onResizeStart(header.column.id, $event)" (touchstart)="onResizeStart(header.column.id, $event)"><span class="rdt-resize-grip" aria-hidden="true"></span></button>
            </span>
    }</th>
    }
        </tr>
    }
        
        @if (hasAnyFilterableColumn()) {
    <tr class="rdt-filter-row">
          @for (header of headerGroups()[headerGroups().length - 1].headers; track header.id) {
    <th class="rdt-filter-cell" role="presentation" [style]="pinStyle(header.column.id)">
            @if (isSelectColumn(header.column.id)) {
    <span style="display:contents"></span>
    } @else if (isExpanderColumn(header.column.id)) {
    <span style="display:contents"></span>
    } @else {
    <span style="display:contents">
              @if (columnIsFilterable(header.column.id) && !hasFilterSlot()) {
    <input class="rdt-col-filter" type="text" [attr.aria-label]="rozieAttr('Filter ' + headerLabel(header.column.id))" [value]="columnFilterValue(header.column.id)" (input)="onColumnFilterInput(header.column.id, $event)" (click)="stopEvent($event)" />
    }@if (columnIsFilterable(header.column.id)) {
    <span style="display:contents">
                <ng-container *ngTemplateOutlet="(filterTpl ?? __rozieFillMap()['filter'] ?? templates()?.['filter']); context: { $implicit: { columnId: header.column.id, value: columnFilterValue(header.column.id), uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }, columnId: header.column.id, value: columnFilterValue(header.column.id), uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }" />
              </span>
    }</span>
    }</th>
    }
        </tr>
    }</thead>

      <tbody class="rdt-tbody" role="rowgroup">
        
        <tr class="rdt-spacer" aria-hidden="true">
          <td [attr.colspan]="rozieAttr(visibleColCount())" [attr.style]="'height:' + padTop() + 'px;padding:0;border:0'"></td>
        </tr>
        
        @for (wr of windowedRows(); track wr.row.id) {

        <tr class="rdt-tr" [ngClass]="{ 'rdt-group-header': rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned }" role="row" [attr.data-row]="rozieAttr(wr.vi.index)" [attr.aria-rowindex]="rozieAttr(headerRowCount() + wr.vi.index + 1)" [attr.data-index]="rozieAttr(wr.vi.index)" [attr.data-pinned]="rozieAttr(wr.pinned ? 'true' : null)" [attr.data-depth]="rozieAttr(wr.row.depth)" [attr.data-group-header]="rozieAttr(rowIsGrouped(wr.row) ? wr.row.id : null)" [attr.data-group-leaf]="rozieAttr(groupingActive() && !rowIsGrouped(wr.row) ? wr.row.id : null)" [attr.aria-expanded]="rozieAttr(rowIsGrouped(wr.row) ? !!rowIsExpanded(wr.row) : null)" [attr.aria-selected]="rozieAttr(selectionMode() !== 'none' ? !!rowIsSelected(wr.row) : null)" [attr.aria-level]="rozieAttr(groupingActive() ? wr.row.depth + 1 : null)">
          @for (cell of visibleCellsFor(wr.row); track cell.id) {
    <td class="rdt-td" [ngClass]="{ 'rdt-select-td': isSelectColumn(cell.column.id), 'rdt-expander-td': isExpanderColumn(cell.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cell)), 'rdt-cell-active': isActiveCell(String(wr.vi.index), colIndexOf(wr.row, cell)) }" [attr.role]="rozieAttr(cellRole())" [attr.data-col]="rozieAttr(cell.column.id)" data-grid-cell="" [attr.data-row]="rozieAttr(wr.vi.index)" [attr.data-col-index]="rozieAttr(colIndexOf(wr.row, cell))" [attr.tabindex]="rozieAttr(cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cell)))" [style]="bodyCellStyle(wr.row, cell.column.id)" [attr.aria-invalid]="rozieAttr(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cell)))" [attr.data-in-range]="rozieAttr(inRange(wr.vi.index, colIndexOf(wr.row, cell)) ? 'true' : null)" [attr.data-agg-cell]="rozieAttr(cellIsAggregated(cell) ? cell.column.id : null)">
            
            @if (isExpanderColumn(cell.column.id)) {
    <span style="display:contents">
              @if (rowCanExpand(wr.row)) {
    <button type="button" class="rdt-expander" data-expander="" [attr.aria-expanded]="!!rowIsExpanded(wr.row)" [attr.aria-label]="rozieAttr(rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row')" (click)="onToggleExpand(wr.row, $event)">{{ rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸') }}</button>
    }</span>
    } @else if (isSelectColumn(cell.column.id)) {
    <span style="display:contents">
              @if ((selectCellTpl ?? __rozieFillMap()['selectCell'] ?? templates()?.['selectCell'])) {
    <ng-container *ngTemplateOutlet="(selectCellTpl ?? __rozieFillMap()['selectCell'] ?? templates()?.['selectCell']); context: _selectCell_ctx(wr, cell)" />
    } @else {

                <input class="rdt-select-row" type="checkbox" aria-label="Select row" [checked]="rowIsSelected(wr.row)" (change)="onToggleRow(wr.row, $event)" />
              
    }
            </span>
    } @else if (cellIsGrouped(cell)) {
    <span style="display:contents">
              <button type="button" class="rdt-expander rdt-group-toggle" data-expander="" [attr.aria-expanded]="!!rowIsExpanded(wr.row)" [attr.aria-label]="rozieAttr(rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group')" (click)="onToggleExpand(wr.row, $event)">{{ rozieDisplay(rowIsExpanded(wr.row) ? '▾' : '▸') }}</button>
              <span class="rdt-group-value">
                @if ((cellTpl ?? __rozieFillMap()['cell'] ?? templates()?.['cell'])) {
    <ng-container *ngTemplateOutlet="(cellTpl ?? __rozieFillMap()['cell'] ?? templates()?.['cell']); context: { $implicit: { columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue() }, columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue() }" />
    } @else {
    {{ rozieDisplay(cell.getValue()) }}
    }
              </span>
              <span class="rdt-group-count">{{ rozieDisplay('(' + groupSubRowCount(wr.row) + ')') }}</span>
            </span>
    } @else if (isEditing(wr.vi.index, colIndexOf(wr.row, cell))) {
    <span style="display:contents">
              @if (hasEditorSlot(cell.column.id)) {
    <span style="display:contents">
                <ng-container *ngTemplateOutlet="(editorTpl ?? __rozieFillMap()['editor'] ?? templates()?.['editor']); context: { $implicit: { columnId: cell.column.id, column: cell.column, row: wr.row.original, value: editorValueFor(cell.column.id), commit: editorCommitFor(cell.column.id), cancel: editorCancelFor(), autofocus: editorAutofocusFor(cell.column.id, wr.vi.index) }, columnId: cell.column.id, column: cell.column, row: wr.row.original, value: editorValueFor(cell.column.id), commit: editorCommitFor(cell.column.id), cancel: editorCancelFor(), autofocus: editorAutofocusFor(cell.column.id, wr.vi.index) }" />
              </span>
    } @else if (editorTypeOf(cell.column.id) === 'number') {
    <input class="rdt-cell-editor" type="number" data-editing-cell="" [value]="editorValueFor(cell.column.id)" (input)="onCellEditorInput(cell.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    } @else if (editorTypeOf(cell.column.id) === 'select') {
    <select class="rdt-cell-editor" data-editing-cell="" [value]="editorValueFor(cell.column.id)" (change)="onCellEditorInput(cell.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)">
                @for (opt of editorOptionsOf(cell.column.id); track opt.value) {
    <option [attr.value]="rozieAttr(opt.value)">{{ rozieDisplay(opt.label) }}</option>
    }
              </select>
    } @else if (editorTypeOf(cell.column.id) === 'checkbox') {
    <input class="rdt-cell-editor" type="checkbox" data-editing-cell="" [checked]="editorCheckedFor(cell.column.id)" (change)="onCellEditorCheckbox(cell.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    } @else {
    <input class="rdt-cell-editor" type="text" data-editing-cell="" [value]="editorValueFor(cell.column.id)" (input)="onCellEditorInput(cell.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    }</span>
    } @else if (cellIsPlaceholder(cell)) {
    <span style="display:contents"></span>
    } @else {
    <span class="rdt-cell-value">
              @if ((cellTpl ?? __rozieFillMap()['cell'] ?? templates()?.['cell'])) {
    <ng-container *ngTemplateOutlet="(cellTpl ?? __rozieFillMap()['cell'] ?? templates()?.['cell']); context: { $implicit: { columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue() }, columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue() }" />
    } @else {
    {{ rozieDisplay(cell.getValue()) }}
    }
            </span>
    }@if (isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cell))) {
    <span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" (pointerdown)="onFillHandlePointerDown($event)"></span>
    }</td>
    }
        </tr>
        
        @if (rowShowsDetail(wr.row)) {
    <tr class="rdt-detail-row" role="row" [attr.data-detail-row]="rozieAttr(wr.row.id)">
          <td class="rdt-detail-cell" [attr.colspan]="rozieAttr(visibleColCount())">
            <ng-container *ngTemplateOutlet="(detailTpl ?? __rozieFillMap()['detail'] ?? templates()?.['detail']); context: { $implicit: { row: wr.row.original }, row: wr.row.original }" />
          </td>
        </tr>
    }
    }
        
        <tr class="rdt-spacer" aria-hidden="true">
          <td [attr.colspan]="rozieAttr(visibleColCount())" [attr.style]="'height:' + padBottom() + 'px;padding:0;border:0'"></td>
        </tr>
      </tbody>
    </table>
    </div>
    } @else {
    <table class="rozie-data-table" [ngClass]="{ 'rdt-sticky': stickyHeader() }" [attr.role]="rozieAttr(tableRole())" [attr.aria-rowcount]="rozieAttr(gridAriaRowCount())" (keydown)="onGridKeyDown($event)" (focusin)="syncActiveFromEvent($event)" (focusout)="onGridFocusOut($event)" (mousedown)="onGridMouseDown($event)" (dblclick)="onGridDblClick($event)" (click)="onGridClick($event)">
      <thead class="rdt-thead" role="rowgroup">
        @for (hg of headerGroups(); track hg.id; let hgLevel = $index) {
    <tr class="rdt-tr" role="row" [attr.aria-rowindex]="rozieAttr(hgLevel + 1)">
          @for (header of hg.headers; track header.id) {
    <th class="rdt-th" [ngClass]="{ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-expander-th': isExpanderColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg, header), hgLevel) }" role="columnheader" [attr.data-col]="rozieAttr(header.column.id)" data-grid-cell="" data-row="__header" [attr.data-header-level]="rozieAttr(hgLevel)" [attr.colspan]="rozieAttr(header.colSpan > 1 ? header.colSpan : null)" [attr.data-col-index]="rozieAttr(headerColIndexOf(hg, header))" [attr.tabindex]="rozieAttr(cellTabindex('__header', headerColIndexOf(hg, header), hgLevel))" [attr.aria-sort]="rozieAttr(ariaSortFor(header.column.id))" [style]="thStyle(header.column.id)">
            
            
            @if (isSelectColumn(header.column.id)) {
    <span style="display:contents">
              @if ((selectAllTpl ?? __rozieFillMap()['selectAll'] ?? templates()?.['selectAll'])) {
    <ng-container *ngTemplateOutlet="(selectAllTpl ?? __rozieFillMap()['selectAll'] ?? templates()?.['selectAll']); context: { $implicit: { checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }, checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }" />
    } @else {

                
                @if (selectionMode() === 'multiple') {
    <input class="rdt-select-all" type="checkbox" aria-label="Select all rows" [checked]="isAllRowsSelected()" (change)="onToggleAllRows($event)" />
    }
    }
            </span>
    } @else if (isExpanderColumn(header.column.id)) {
    <span style="display:contents"></span>
    } @else {
    <span style="display:contents">
              
              @if (header.column.getCanSort && header.column.getCanSort()) {
    <button type="button" class="rdt-sort-btn" (click)="onHeaderSort(header.column.id, $event)">
                
                <span class="rdt-header-label">
                  @if ((colHeaderTpl ?? __rozieFillMap()['colHeader'] ?? templates()?.['colHeader'])) {
    <ng-container *ngTemplateOutlet="(colHeaderTpl ?? __rozieFillMap()['colHeader'] ?? templates()?.['colHeader']); context: { $implicit: { columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }, columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }" />
    } @else {
    {{ rozieDisplay(headerLabel(header.column.id)) }}
    }
                </span>
                <span class="rdt-sort-ind" aria-hidden="true">{{ rozieDisplay(sortIndicator(header.column.id)) }}</span>
              </button>
    } @else {
    <span style="display:contents">
                <span class="rdt-header-label">
                  @if ((colHeaderTpl ?? __rozieFillMap()['colHeader'] ?? templates()?.['colHeader'])) {
    <ng-container *ngTemplateOutlet="(colHeaderTpl ?? __rozieFillMap()['colHeader'] ?? templates()?.['colHeader']); context: { $implicit: { columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }, columnId: header.column.id, column: header.column, label: headerLabel(header.column.id) }" />
    } @else {
    {{ rozieDisplay(headerLabel(header.column.id)) }}
    }
                </span>
              </span>
    }<rozie-popover trigger="click" placement="bottom-end" strategy="fixed" [offset]="4"><ng-template #anchor>
                  <button type="button" class="rdt-col-menu-trigger" [attr.aria-label]="rozieAttr('Column options for ' + headerLabel(header.column.id))">⋯</button>
                </ng-template><ng-template #defaultSlot><div class="rdt-col-menu" role="menu">
                  <button type="button" role="menuitem" class="rdt-col-menu-item" [attr.aria-pressed]="columnPinSide(header.column.id) === 'left'" (click)="onPinColumn(header.column.id, 'left', $event)">Pin left</button>
                  <button type="button" role="menuitem" class="rdt-col-menu-item" [attr.aria-pressed]="columnPinSide(header.column.id) === 'right'" (click)="onPinColumn(header.column.id, 'right', $event)">Pin right</button>
                  <button type="button" role="menuitem" class="rdt-col-menu-item" [attr.aria-pressed]="!columnPinSide(header.column.id)" (click)="onPinColumn(header.column.id, false, $event)">Unpin</button>
                  <hr class="rdt-col-menu-sep" />
                  <button type="button" role="menuitem" class="rdt-col-menu-item" (click)="onHideColumn(header.column.id, $event)">Hide column</button>
                </div></ng-template></rozie-popover>
              
              <button type="button" class="rdt-resize-handle" [attr.aria-label]="rozieAttr('Resize ' + headerLabel(header.column.id))" (pointerdown)="onResizeStart(header.column.id, $event)" (touchstart)="onResizeStart(header.column.id, $event)"><span class="rdt-resize-grip" aria-hidden="true"></span></button>
            </span>
    }</th>
    }
        </tr>
    }
        
        @if (hasAnyFilterableColumn()) {
    <tr class="rdt-filter-row">
          @for (header of headerGroups()[headerGroups().length - 1].headers; track header.id) {
    <th class="rdt-filter-cell" role="presentation" [style]="pinStyle(header.column.id)">
            @if (isSelectColumn(header.column.id)) {
    <span style="display:contents"></span>
    } @else if (isExpanderColumn(header.column.id)) {
    <span style="display:contents"></span>
    } @else {
    <span style="display:contents">
              @if (columnIsFilterable(header.column.id) && !hasFilterSlot()) {
    <input class="rdt-col-filter" type="text" [attr.aria-label]="rozieAttr('Filter ' + headerLabel(header.column.id))" [value]="columnFilterValue(header.column.id)" (input)="onColumnFilterInput(header.column.id, $event)" (click)="stopEvent($event)" />
    }@if (columnIsFilterable(header.column.id)) {
    <span style="display:contents">
                <ng-container *ngTemplateOutlet="(filterTpl ?? __rozieFillMap()['filter'] ?? templates()?.['filter']); context: { $implicit: { columnId: header.column.id, value: columnFilterValue(header.column.id), uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }, columnId: header.column.id, value: columnFilterValue(header.column.id), uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }" />
              </span>
    }</span>
    }</th>
    }
        </tr>
    }</thead>

      <tbody class="rdt-tbody" role="rowgroup">
        
        @for (row of rows(); track row.id) {

        <tr class="rdt-tr" [ngClass]="{ 'rdt-group-header': rowIsGrouped(row) }" role="row" [attr.data-depth]="rozieAttr(row.depth)" [attr.aria-rowindex]="rozieAttr(bodyAriaRowIndex(row))" [attr.data-group-header]="rozieAttr(rowIsGrouped(row) ? row.id : null)" [attr.data-group-leaf]="rozieAttr(groupingActive() && !rowIsGrouped(row) ? row.id : null)" [attr.aria-expanded]="rozieAttr(rowIsGrouped(row) ? !!rowIsExpanded(row) : null)" [attr.aria-selected]="rozieAttr(selectionMode() !== 'none' ? !!rowIsSelected(row) : null)" [attr.aria-level]="rozieAttr(groupingActive() ? row.depth + 1 : null)">
          @for (cell of visibleCellsFor(row); track cell.id) {
    <td class="rdt-td" [ngClass]="{ 'rdt-select-td': isSelectColumn(cell.column.id), 'rdt-expander-td': isExpanderColumn(cell.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cell)), 'rdt-cell-active': isActiveCell(String(rowIndexOf(row)), colIndexOf(row, cell)) }" [attr.role]="rozieAttr(cellRole())" [attr.data-col]="rozieAttr(cell.column.id)" data-grid-cell="" [attr.data-row]="rozieAttr(rowIndexOf(row))" [attr.data-col-index]="rozieAttr(colIndexOf(row, cell))" [attr.tabindex]="rozieAttr(cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cell)))" [style]="bodyCellStyle(row, cell.column.id)" [attr.aria-invalid]="rozieAttr(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cell)))" [attr.data-in-range]="rozieAttr(inRange(rowIndexOf(row), colIndexOf(row, cell)) ? 'true' : null)" [attr.data-agg-cell]="rozieAttr(cellIsAggregated(cell) ? cell.column.id : null)">
            
            @if (isExpanderColumn(cell.column.id)) {
    <span style="display:contents">
              @if (rowCanExpand(row)) {
    <button type="button" class="rdt-expander" data-expander="" [attr.aria-expanded]="!!rowIsExpanded(row)" [attr.aria-label]="rozieAttr(rowIsExpanded(row) ? 'Collapse row' : 'Expand row')" (click)="onToggleExpand(row, $event)">{{ rozieDisplay(rowIsExpanded(row) ? '▾' : '▸') }}</button>
    }</span>
    } @else if (isSelectColumn(cell.column.id)) {
    <span style="display:contents">
              @if ((selectCellTpl ?? __rozieFillMap()['selectCell'] ?? templates()?.['selectCell'])) {
    <ng-container *ngTemplateOutlet="(selectCellTpl ?? __rozieFillMap()['selectCell'] ?? templates()?.['selectCell']); context: _selectCell_ctx_1(row, cell)" />
    } @else {

                <input class="rdt-select-row" type="checkbox" aria-label="Select row" [checked]="rowIsSelected(row)" (change)="onToggleRow(row, $event)" />
              
    }
            </span>
    } @else if (cellIsGrouped(cell)) {
    <span style="display:contents">
              <button type="button" class="rdt-expander rdt-group-toggle" data-expander="" [attr.aria-expanded]="!!rowIsExpanded(row)" [attr.aria-label]="rozieAttr(rowIsExpanded(row) ? 'Collapse group' : 'Expand group')" (click)="onToggleExpand(row, $event)">{{ rozieDisplay(rowIsExpanded(row) ? '▾' : '▸') }}</button>
              <span class="rdt-group-value">
                @if ((cellTpl ?? __rozieFillMap()['cell'] ?? templates()?.['cell'])) {
    <ng-container *ngTemplateOutlet="(cellTpl ?? __rozieFillMap()['cell'] ?? templates()?.['cell']); context: { $implicit: { columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue() }, columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue() }" />
    } @else {
    {{ rozieDisplay(cell.getValue()) }}
    }
              </span>
              <span class="rdt-group-count">{{ rozieDisplay('(' + groupSubRowCount(row) + ')') }}</span>
            </span>
    } @else if (isEditing(rowIndexOf(row), colIndexOf(row, cell))) {
    <span style="display:contents">
              @if (hasEditorSlot(cell.column.id)) {
    <span style="display:contents">
                <ng-container *ngTemplateOutlet="(editorTpl ?? __rozieFillMap()['editor'] ?? templates()?.['editor']); context: { $implicit: { columnId: cell.column.id, column: cell.column, row: row.original, value: editorValueFor(cell.column.id), commit: editorCommitFor(cell.column.id), cancel: editorCancelFor(), autofocus: editorAutofocusFor(cell.column.id, rowIndexOf(row)) }, columnId: cell.column.id, column: cell.column, row: row.original, value: editorValueFor(cell.column.id), commit: editorCommitFor(cell.column.id), cancel: editorCancelFor(), autofocus: editorAutofocusFor(cell.column.id, rowIndexOf(row)) }" />
              </span>
    } @else if (editorTypeOf(cell.column.id) === 'number') {
    <input class="rdt-cell-editor" type="number" data-editing-cell="" [value]="editorValueFor(cell.column.id)" (input)="onCellEditorInput(cell.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    } @else if (editorTypeOf(cell.column.id) === 'select') {
    <select class="rdt-cell-editor" data-editing-cell="" [value]="editorValueFor(cell.column.id)" (change)="onCellEditorInput(cell.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)">
                @for (opt of editorOptionsOf(cell.column.id); track opt.value) {
    <option [attr.value]="rozieAttr(opt.value)">{{ rozieDisplay(opt.label) }}</option>
    }
              </select>
    } @else if (editorTypeOf(cell.column.id) === 'checkbox') {
    <input class="rdt-cell-editor" type="checkbox" data-editing-cell="" [checked]="editorCheckedFor(cell.column.id)" (change)="onCellEditorCheckbox(cell.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    } @else {
    <input class="rdt-cell-editor" type="text" data-editing-cell="" [value]="editorValueFor(cell.column.id)" (input)="onCellEditorInput(cell.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    }</span>
    } @else if (cellIsPlaceholder(cell)) {
    <span style="display:contents"></span>
    } @else {
    <span class="rdt-cell-value">
              @if ((cellTpl ?? __rozieFillMap()['cell'] ?? templates()?.['cell'])) {
    <ng-container *ngTemplateOutlet="(cellTpl ?? __rozieFillMap()['cell'] ?? templates()?.['cell']); context: { $implicit: { columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue() }, columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue() }" />
    } @else {
    {{ rozieDisplay(cell.getValue()) }}
    }
            </span>
    }@if (isFillHandleCell(rowIndexOf(row), colIndexOf(row, cell))) {
    <span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" (pointerdown)="onFillHandlePointerDown($event)"></span>
    }</td>
    }
        </tr>
        
        @if (rowShowsDetail(row)) {
    <tr class="rdt-detail-row" role="row" [attr.data-detail-row]="rozieAttr(row.id)">
          <td class="rdt-detail-cell" [attr.colspan]="rozieAttr(visibleColCount())">
            <ng-container *ngTemplateOutlet="(detailTpl ?? __rozieFillMap()['detail'] ?? templates()?.['detail']); context: { $implicit: { row: row.original }, row: row.original }" />
          </td>
        </tr>
    }
    }
      </tbody>
    </table>
    }@if (!virtual()) {
    <div class="rdt-pagination" role="group" aria-label="Pagination">
      <button type="button" class="rdt-page-btn rdt-page-prev" [disabled]="!canPrevPage()" (click)="onPrevPage()">Prev</button>
      <span class="rdt-page-status" aria-live="polite">
        {{ rozieDisplay('Page ' + (pageIndex() + 1) + ' of ' + displayPageCount()) }}
      </span>
      <button type="button" class="rdt-page-btn rdt-page-next" [disabled]="!canNextPage()" (click)="onNextPage()">Next</button>
      <select class="rdt-page-size" aria-label="Rows per page" [value]="pageSize()" (change)="onPageSizeChange($event)">
        <option [value]="10">10</option>
        <option [value]="25">25</option>
        <option [value]="50">50</option>
        <option [value]="100">100</option>
      </select>
    </div>
    }</div>

  `,
  styles: [`
    :host(rozie-data-table) { display: contents; }
    .rozie-data-table {
      border-collapse: collapse;
      width: 100%;
      font: var(--rdt-font, 14px system-ui, sans-serif);
      color: var(--rdt-color, inherit);
    }
    .rdt-sr-live {
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      white-space: nowrap;
      border: 0;
    }
    .rozie-data-table .rdt-cell-editor {
      font: inherit;
      width: 100%;
      box-sizing: border-box;
    }
    .rozie-data-table .rdt-td[aria-invalid="true"] {
      outline: var(--rdt-invalid-outline, 2px solid #d33);
      outline-offset: -2px;
    }
    .rozie-data-table .rdt-td.rdt-in-range {
      background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
    }
    .rozie-data-table .rdt-td.rdt-cell-active,
    .rozie-data-table .rdt-th.rdt-cell-active {
      outline: var(--rdt-active-cell-outline, 2px solid #2563eb);
      outline-offset: -2px;
    }
    .rozie-data-table .rdt-td {
      position: relative;
    }
    .rozie-data-table .rdt-fill-handle {
      position: absolute;
      right: -3px;
      bottom: -3px;
      width: 8px;
      height: 8px;
      background: var(--rdt-fill-handle-bg, #2563eb);
      border: 1px solid #fff;
      cursor: crosshair;
      z-index: 1;
      touch-action: none;
    }
    .rozie-data-table .rdt-th,
    .rozie-data-table .rdt-td {
      padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
      text-align: left;
      border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
    }
    .rozie-data-table .rdt-thead .rdt-th {
      font-weight: var(--rdt-header-weight, 600);
      /* OPAQUE default (was rgba(0,0,0,0.03)): a translucent header lets the scrolling body
         bleed through in sticky mode. #f7f7f7 is the visual equivalent of the old 3%-black
         tint over white, but solid. The three design-system themes already ship opaque
         header backgrounds; this makes the zero-config default consistent with them. */
      background: var(--rdt-header-bg, #f7f7f7);
    }
    .rozie-data-table .rdt-sort-btn {
      display: inline-flex;
      align-items: center;
      gap: var(--rdt-sort-gap, 0.35em);
      background: none;
      border: none;
      font: inherit;
      font-weight: inherit;
      color: inherit;
      cursor: pointer;
      padding: 0;
    }
    .rozie-data-table .rdt-sort-ind {
      font-size: 0.8em;
      opacity: var(--rdt-sort-ind-opacity, 0.7);
    }
    .rozie-data-table.rdt-sticky .rdt-thead .rdt-th {
      position: sticky;
      top: var(--rdt-sticky-top, 0);
      z-index: var(--rdt-sticky-z, 2);
    }
    .rozie-data-table-wrap .rdt-scroll {
      max-height: var(--rozie-data-table-max-height);
      overflow: auto;
    }
    .rozie-data-table-wrap .rdt-group-bar-host {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: var(--rdt-group-bar-gap, 0.375rem);
    }
    .rozie-data-table-wrap ::ng-deep .rdt-group-token {
      display: inline-flex;
      align-items: center;
      padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
      border-radius: var(--rdt-group-token-radius, 999px);
      background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
      font-size: var(--rdt-group-token-size, 0.8125em);
    }
    .rozie-data-table .rdt-group-header {
      background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
      font-weight: var(--rdt-group-header-weight, 600);
    }
    .rozie-data-table .rdt-group-toggle {
      margin-right: var(--rdt-group-toggle-gap, 0.375rem);
    }
    .rozie-data-table .rdt-group-count {
      margin-left: var(--rdt-group-count-gap, 0.375rem);
      opacity: var(--rdt-group-count-opacity, 0.65);
      font-weight: 400;
    }
    .rozie-data-table-wrap {
      display: flex;
      flex-direction: column;
      gap: var(--rdt-chrome-gap, 0.5rem);
    }
    .rozie-data-table-wrap .rdt-toolbar {
      display: flex;
      gap: var(--rdt-toolbar-gap, 0.5rem);
    }
    .rozie-data-table-wrap .rdt-global-filter,
    .rozie-data-table-wrap ::ng-deep .rdt-col-filter {
      font: inherit;
      /* border-box so the padding + border count INSIDE the declared width — without it
         the col-filter's \`width: 100%\` + padding overflows its (constrained) header cell. */
      box-sizing: border-box;
      padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
      border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
      border-radius: var(--rdt-filter-radius, 4px);
      background: var(--rdt-filter-bg, transparent);
      color: inherit;
    }
    .rozie-data-table-wrap ::ng-deep .rdt-col-filter {
      display: block;
      margin-top: var(--rdt-col-filter-gap, 0.25rem);
      width: 100%;
      font-weight: normal;
    }
    .rozie-data-table .rdt-filter-row {
      background: var(--rdt-filter-row-bg, rgba(0, 0, 0, 0.015));
    }
    .rozie-data-table .rdt-filter-cell {
      padding: var(--rdt-filter-cell-padding, 0.35rem 0.75rem);
      border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
    }
    .rozie-data-table-wrap .rdt-filter-row ::ng-deep .rdt-col-filter {
      font-size: var(--rdt-filter-row-input-size, 0.9em);
    }
    .rozie-data-table-wrap .rdt-pagination {
      display: flex;
      align-items: center;
      gap: var(--rdt-pagination-gap, 0.5rem);
    }
    .rozie-data-table-wrap .rdt-page-btn {
      font: inherit;
      cursor: pointer;
      padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
      border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
      border-radius: var(--rdt-page-btn-radius, 4px);
      background: var(--rdt-page-btn-bg, transparent);
      color: inherit;
    }
    .rozie-data-table-wrap .rdt-page-btn:disabled {
      opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
      cursor: default;
    }
    .rozie-data-table-wrap .rdt-page-status {
      font-size: var(--rdt-page-status-size, 0.9em);
    }
    .rozie-data-table-wrap .rdt-page-size {
      font: inherit;
      padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
      border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
      border-radius: var(--rdt-page-size-radius, 4px);
      background: var(--rdt-page-size-bg, transparent);
      color: inherit;
    }
    .rozie-data-table .rdt-th {
      position: relative;
    }
    .rozie-data-table .rdt-resize-handle {
      position: absolute;
      top: 0;
      right: 0;
      height: 100%;
      width: var(--rdt-resize-handle-width, 6px);
      padding: 0;
      border: none;
      background: none;
      cursor: col-resize;
      touch-action: none;
      user-select: none;
    }
    .rozie-data-table .rdt-resize-grip {
      display: block;
      width: var(--rdt-resize-grip-width, 2px);
      height: 100%;
      margin: 0 auto;
      background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
    }
    .rozie-data-table .rdt-resize-handle:hover .rdt-resize-grip,
    .rozie-data-table .rdt-th-resizing .rdt-resize-grip {
      background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
    }
    .rozie-data-table .rdt-col-menu-trigger {
      font: inherit;
      font-size: var(--rdt-col-menu-trigger-size, 0.9em);
      line-height: 1;
      cursor: pointer;
      margin-left: var(--rdt-col-menu-trigger-margin, 0.35em);
      padding: var(--rdt-col-menu-trigger-padding, 0.15em 0.4em);
      border: var(--rdt-col-menu-trigger-border, 1px solid rgba(0, 0, 0, 0.15));
      border-radius: var(--rdt-col-menu-trigger-radius, 3px);
      background: var(--rdt-col-menu-trigger-bg, transparent);
      color: inherit;
    }
    .rozie-data-table .rdt-col-menu-trigger:hover {
      background: var(--rdt-col-menu-trigger-hover-bg, rgba(0, 0, 0, 0.06));
    }
    .rozie-data-table .rdt-col-menu-trigger:focus-visible {
      outline: var(--rdt-col-menu-trigger-focus-outline, 2px solid #2563eb);
      outline-offset: 1px;
    }
    .rozie-data-table .rdt-col-menu {
      display: flex;
      flex-direction: column;
      gap: var(--rdt-col-menu-item-gap, 0.15rem);
      min-width: var(--rdt-col-menu-min-width, 9rem);
    }
    .rozie-data-table .rdt-col-menu-item {
      display: block;
      width: 100%;
      text-align: left;
      font: inherit;
      cursor: pointer;
      padding: var(--rdt-col-menu-item-padding, 0.35em 0.6em);
      border: none;
      border-radius: var(--rdt-col-menu-item-radius, 3px);
      background: none;
      color: inherit;
      white-space: nowrap;
    }
    .rozie-data-table .rdt-col-menu-item:hover {
      background: var(--rdt-col-menu-item-hover-bg, rgba(0, 0, 0, 0.06));
    }
    .rozie-data-table .rdt-col-menu-item[aria-pressed='true'] {
      background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
      font-weight: 700;
    }
    .rozie-data-table .rdt-col-menu-sep {
      margin: var(--rdt-col-menu-sep-margin, 0.25rem 0);
      border: none;
      border-top: var(--rdt-col-menu-sep-border, 1px solid rgba(0, 0, 0, 0.1));
    }
    .rozie-data-table-wrap .rdt-colvis {
      position: relative;
    }
    .rozie-data-table-wrap .rdt-colvis-summary {
      cursor: pointer;
      font: inherit;
      padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
      border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
      border-radius: var(--rdt-colvis-summary-radius, 4px);
      list-style: none;
      user-select: none;
    }
    .rozie-data-table-wrap .rdt-colvis-menu {
      position: absolute;
      z-index: var(--rdt-colvis-menu-z, 5);
      margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
      padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
      display: flex;
      flex-direction: column;
      gap: var(--rdt-colvis-item-gap, 0.25rem);
      border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
      border-radius: var(--rdt-colvis-menu-radius, 4px);
      background: var(--rdt-colvis-menu-bg, #fff);
      box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
    }
    .rozie-data-table-wrap .rdt-colvis-item {
      display: flex;
      align-items: center;
      gap: var(--rdt-colvis-label-gap, 0.4em);
      cursor: pointer;
      white-space: nowrap;
    }
    .rozie-data-table .rdt-select-th,
    .rozie-data-table .rdt-select-td {
      width: var(--rdt-select-col-width, 44px);
      text-align: var(--rdt-select-col-align, center);
      white-space: nowrap;
    }
    .rozie-data-table .rdt-expander-th,
    .rozie-data-table .rdt-expander-td {
      width: var(--rdt-expander-col-width, 40px);
      text-align: var(--rdt-expander-col-align, center);
      white-space: nowrap;
    }
    .rozie-data-table .rdt-select-all,
    .rozie-data-table .rdt-select-row {
      cursor: pointer;
      accent-color: var(--rdt-select-accent, currentColor);
    }
  `],
  providers: [
    {
      provide: rozieToken('data-table:columns'),
      useFactory: () => { const __rozieCtxHost = inject(forwardRef(() => DataTable)); return ({
  registerColumn: (id: any, spec: any) => {
    if (id == null) return;
    const key = String(id);
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
    __rozieCtxHost.colReg.set({
      ...__rozieCtxHost.colReg(),
      [key]: spec
    });
  },
  unregisterColumn: (id: any) => {
    if (id == null) return;
    const r = {
      ...__rozieCtxHost.colReg()
    };
    delete r[String(id)];
    __rozieCtxHost.colReg.set(r);
  }
}); },
    },
  ],
})
export class DataTable {
  /**
   * The row data — `model: true`, so a committed cell/row edit writes a **fresh** array back through `r-model:data` (uncontrolled fallback `dataDefault`). A stable reference per Rozie's setup-once model — fed directly into table-core (never map/cloned in the watcher).
   * @example
   * <DataTable r-model:data="rows" :columns="cols" />
   */
  data = model.required<any[]>();
  /**
   * Config-array column fallback (lower precedence than `<Column>` children). Each entry: `{ id?, field, header?, sortable?, filterable?, pinned?, width? }`. Columns may come from this array, from `<Column>` children, or both (id-keyed last-write-wins union).
   */
  columns = input<any[]>((() => [])());
  /**
   * Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
   */
  selectionMode = input<string>('none');
  /**
   * `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
   */
  sorting = model<any[]>((() => [])());
  /**
   * The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
   */
  globalFilter = model<string>('');
  /**
   * `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
   */
  columnFilters = model<any[]>((() => [])());
  /**
   * `{ pageIndex, pageSize }`. Defaults to `{ pageIndex: 0, pageSize: 10 }`; feeds the prev/next + page-size chrome (and `getPaginationRowModel()`). Two-way: funnels a fresh object through `page-change`.
   */
  pagination = model<Record<string, any>>((() => ({
    pageIndex: 0,
    pageSize: 10
  }))());
  /**
   * Server-side hook: sets `manualPagination` / `manualFiltering` / `manualSorting` so table-core trusts the consumer-supplied rows and only emits the change events (the consumer fetches each page).
   */
  manual = input<boolean>(false);
  /**
   * Total server-side row count for `manual` pagination; lets the table compute page count when it doesn't hold the full dataset.
   */
  rowCount = input<(number) | null>(null);
  /**
   * Explicit total page count for `manual` pagination; overrides rowCount-derived count.
   */
  pageCount = input<(number) | null>(null);
  /**
   * Opt-in **expandable rows**. When `true`, a leading chevron expander column auto-injects (after the select column) and `getExpandedRowModel` activates; default `false` is byte-identical-off. Every row can expand to reveal a `#detail` panel unless `getSubRows` is supplied (then only rows with children expand). Bind `:expandable="true"` (a bare attr only coerces on Vue+Lit).
   */
  expandable = input<boolean>(false);
  /**
   * `ExpandedState` — `{ [rowId]: true }`, or the `true` literal after `expandAll` (declared `type: [Object, Boolean]`). Multi-expand (multiple rows open at once). Surfaces through `expand-change`; uncontrolled fallback (`$data.expandedDefault`) when unbound — the default is `null` so the uncontrolled fallback AND the grouping auto-expand default are reachable (a non-null default would short-circuit them). When grouping is active and `expanded` is untouched, group subtrees auto-expand.
   */
  expanded = model<(Record<string, any> | boolean) | null>(null);
  /**
   * Table-level child-row accessor `(originalRow, index) => TData[] | undefined` that drives nested sub-rows. When supplied (with `expandable`), table-core flattens the hierarchy and the expand seam reveals depth-indented child rows. Null → the `#detail` scoped slot is the expand mode.
   */
  getSubRows = input<((...args: any[]) => any) | null>(null);
  /**
   * Opt-in gate for the **headless `#groupBar`** host region. Default `false` is byte-identical-off. `getGroupedRowModel` is wired unconditionally (inert when `grouping` is empty), so grouping is driven by the `grouping` model; this flag only gates the consumer-facing group-bar surface (the component ships **no** built-in drag UI).
   */
  groupable = input<boolean>(false);
  /**
   * `GroupingState` — an ordered `string[]` of column ids (multi-column → nested groups, e.g. `['region','category']`). An empty/unbound list is ungrouped (byte-identical-off). Group-header rows are collapsible (they ride the expand model). Surfaces through `group-change`; uncontrolled fallback (`$data.groupingDefault`, default `[]`) when unbound — the default is `null` (mirroring `expanded`) so the uncontrolled fallback is reachable and the grouping auto-expand default can activate when a consumer applies grouping without binding `r-model:grouping` (a non-null `[]` default would short-circuit it). All reads are null-guarded, so table-core still receives an array.
   */
  grouping = model<(any[]) | null>(null);
  /**
   * `RowSelectionState` — `{ [rowId]: true }`. Checkbox-only toggle (the row body does not select). Driven by the `selectionMode` chrome. Two-way: fires `selection-change` regardless of binding.
   */
  rowSelection = model<Record<string, any>>((() => ({}))());
  /**
   * `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
   */
  columnVisibility = model<Record<string, any>>((() => ({}))());
  /**
   * `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
   */
  columnSizing = model<Record<string, any>>((() => ({}))());
  /**
   * `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
   */
  columnOrder = model<any[]>((() => [])());
  /**
   * `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
   */
  columnPinning = model<Record<string, any>>((() => ({
    left: [],
    right: []
  }))());
  /**
   * Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
   */
  stickyHeader = input<boolean>(false);
  /**
   * `'table'` (default, row-oriented, byte-behaviorally identical to a plain accessible table) | `'grid'` (GA since Phase 63) — lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)**: `role="grid"`, a roving single tab-stop, 2-D APG arrow-key cell navigation, range selection, and clipboard support.
   */
  interactionMode = input<string>('table');
  /**
   * Grid mode only. When `true`, a plain click on an **editable** cell opens its editor immediately (single-click-to-edit) instead of just activating the cell. Default `false` keeps click-to-activate (double-click opens the editor). Shift+click (range selection) and clicks on non-editable cells are unaffected.
   */
  singleClickEdit = input<boolean>(false);
  /**
   * Grid mode. When `true`, every committed data mutation (cell/row edit, paste, fill, cut, clear) becomes one undo step: Ctrl/Cmd+Z undoes, Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z redoes. Default `false` records no history and Ctrl+Z/Y are inert.
   */
  undoable = input<boolean>(false);
  /**
   * The maximum number of undo steps retained (oldest evicted past this depth). Only consulted when `undoable` is `true`.
   */
  undoLimit = input<number>(100);
  /**
   * Opt-in vertical **row windowing**. When `true`, only the visible slice of rows renders inside a bounded `rdt-scroll` container (with leading/trailing spacer rows preserving total scroll height), windowing over the full filtered + sorted (pre-pagination) model and suppressing the client pagination chrome. Default `false` is byte-identical to a non-virtual table.
   */
  virtual = input<boolean>(false);
  /**
   * Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
   */
  estimateRowHeight = input<number>(40);
  /**
   * A CSS length string bounding the `rdt-scroll` container when `virtual` is on (e.g. `'400px'`). Mirrored to the `--rozie-data-table-max-height` custom property; the prop wins, the token is the fallback.
   */
  maxHeight = input<string>('');
  dataDefault = signal<any[]>([]);
  sortingDefault = signal<any[]>([]);
  globalFilterDefault = signal('');
  columnFiltersDefault = signal<any[]>([]);
  paginationDefault = signal({
    pageIndex: 0,
    pageSize: 10
  });
  rowSelectionDefault = signal({});
  expandedDefault = signal({});
  groupingDefault = signal<any[]>([]);
  columnVisibilityDefault = signal({});
  columnSizingDefault = signal({});
  columnOrderDefault = signal<any[]>([]);
  columnPinningDefault = signal({
    left: [],
    right: []
  });
  columnSizingInfo = signal({
    startOffset: null,
    startSize: null,
    deltaOffset: null,
    deltaPercentage: null,
    isResizingColumn: false,
    columnSizingStart: []
  });
  colReg = signal({});
  rows = signal<any[]>([]);
  headerGroups = signal<any[]>([]);
  rowModelVer = signal(0);
  windowVer = signal(0);
  activeRow = signal(0);
  activeColIndex = signal(0);
  activeIsHeader = signal(false);
  activeHeaderLevel = signal(0);
  activeInControl = signal(false);
  editingRow = signal(-1);
  editingCol = signal(-1);
  draftValue = signal<any>(null);
  invalidMsg = signal('');
  editVer = signal(0);
  editFocusColId = signal<any>(null);
  editingRowIndex = signal<any>(null);
  rowDraft = signal({});
  rangeAnchor = signal<any>(null);
  rangeFocus = signal<any>(null);
  pasteAnnounce = signal('');
  liveAnnounce = signal('');
  __rozieRoot = viewChild<ElementRef<HTMLDivElement>>('__rozieRoot');
  sortChange = output<unknown>({ alias: 'sort-change' });
  expandChange = output<unknown>({ alias: 'expand-change' });
  groupChange = output<unknown>({ alias: 'group-change' });
  filterChange = output<unknown>({ alias: 'filter-change' });
  pageChange = output<unknown>({ alias: 'page-change' });
  selectionChange = output<unknown>({ alias: 'selection-change' });
  visibilityChange = output<unknown>({ alias: 'visibility-change' });
  resizeChange = output<unknown>({ alias: 'resize-change' });
  reorderChange = output<unknown>({ alias: 'reorder-change' });
  pinChange = output<unknown>({ alias: 'pin-change' });
  historyChange = output<unknown>({ alias: 'history-change' });
  activecellChange = output<unknown>({ alias: 'activecell-change' });
  rangeChange = output<unknown>({ alias: 'range-change' });
  cellEditCommit = output<unknown>({ alias: 'cell-edit-commit' });
  rowEditCommit = output<unknown>({ alias: 'row-edit-commit' });
  @ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
  @ContentChild('groupBar', { read: TemplateRef }) groupBarTpl?: TemplateRef<GroupBarCtx>;
  @ContentChild('selectAll', { read: TemplateRef }) selectAllTpl?: TemplateRef<SelectAllCtx>;
  @ContentChild('colHeader', { read: TemplateRef }) colHeaderTpl?: TemplateRef<ColHeaderCtx>;
  @ContentChild('filter', { read: TemplateRef }) filterTpl?: TemplateRef<FilterCtx>;
  @ContentChild('selectCell', { read: TemplateRef }) selectCellTpl?: TemplateRef<SelectCellCtx>;
  @ContentChild('cell', { read: TemplateRef }) cellTpl?: TemplateRef<CellCtx>;
  @ContentChild('editor', { read: TemplateRef }) editorTpl?: TemplateRef<EditorCtx>;
  @ContentChild('detail', { read: TemplateRef }) detailTpl?: TemplateRef<DetailCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  __rozieFills = contentChildren(RozieSlot, { descendants: true });
  __rozieFillMap = computed(() => {
    const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
    for (const f of this.__rozieFills()) {
      const k = f.rozieSlot();
      if (k == null) continue;
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      map[k === '' ? 'defaultSlot' : k] = f.templateRef;
    }
    return map;
  });
  private __rozieWatchInitial_0 = true;
  private __rozieWatchInitial_1 = true;

  constructor() {
    inject(DestroyRef).onDestroy(() => {
      if (this.virtualizerCleanup) this.virtualizerCleanup();
      // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
      this.teardownFillDrag();
      // §6 (260709-3qt): remove any live drag-select document listeners on a mid-drag unmount.
      this.teardownRangeDrag();
    });
    effect(() => () => {
      this.maybeClearHistoryOnExternalSwap();
      if (!this.table) return;
      // Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
      // $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
      // bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
      // identical length) still re-feeds.
      const d = this.currentData() || [];
      if (d === this.lastData && d.length === this.lastDataLen) return;
      this.lastData = d;
      this.lastDataLen = d.length;
      this.reFeed();
    });
    effect(() => { const __watchVal = (() => [this.sorting(), this.globalFilter(), this.columnFilters(), this.pagination(),
    // Server-side page-count sources (#2): re-feed when the consumer's rowCount/pageCount
    // changes at runtime (e.g. a server response updates the total) so getPageCount() and the
    // Next button availability track the new total.
    this.rowCount(), this.pageCount(), this.rowSelection(), this.expanded(), this.expandable(), this.grouping(), this.groupable(), this.columnVisibility(), this.columnSizing(), this.columnOrder(), this.columnPinning(), this.selectionMode(), (this.data() || []).length,
    // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
    // even when the fresh array is the SAME length (a single-cell edit replaces one row
    // object → new array ref, identical length → the .length key alone would miss it). The
    // controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
    // writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
    this.data(), this.dataDefault(),
    // Column CONFIG prop (the `:columns` array form) — the sibling column source to
    // $data.colReg (the `<Column>` children). Watch it so a runtime `:columns` swap re-feeds:
    // columnDefs()/tableColumns() build the UNION of both, and reFeed re-passes columns.
    // (Consumers memoize the array as with $props.data/$props.sorting; the uncontrolled
    // <Column>-children path leaves $props.columns undefined — a stable no-op getter.)
    this.columns(), this.colReg()])(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
      this.reFeed();
      this.maybeClearHistoryOnExternalSwap();
    })(); }); });
    effect(() => { const __watchVal = (() => [this.sorting(), this.columnFilters(), this.globalFilter(), this.sortingDefault(), this.columnFiltersDefault(), this.globalFilterDefault()])(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => {
      const msg = this.buildSortFilterAnnounce();
      if (msg) this.liveAnnounce.set(msg);
    })(); }); });
  }

  ngAfterViewInit() {
    const __getSubRows = this.getSubRows();
    const __manual = this.manual();
    const __selectionMode = this.selectionMode();
    // Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
    // edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
    // a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
    // the bound prop when controlled, this fallback otherwise.
    this.dataDefault.set(this.data() || []);
    // Build the table instance HERE so the closures below capture the live `table`.
    // Build the table instance HERE so the closures below capture the live `table`.
    this.table = createTable({
      // Plain value (NOT a `get data()` getter): an object-literal getter rebinds
      // `this` to the options object, and the Angular/Lit emitters resolve $props via
      // `this.data` — so `get data() { return $props.data }` lowers to `this.data`
      // re-entering the getter → infinite recursion (max call stack). `data` is re-fed
      // on every change by the watch's setOptions below, exactly like columns/state, so
      // the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
      // currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
      // (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
      data: this.currentData(),
      columns: this.tableColumns(),
      state: this.currentState(),
      getCoreRowModel: getCoreRowModel(),
      getSortedRowModel: getSortedRowModel(),
      getFilteredRowModel: getFilteredRowModel(),
      getPaginationRowModel: getPaginationRowModel(),
      // Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
      // (mirrors the other models) — inert when `expanded` is empty + no getSubRows
      // (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
      // ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
      // (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
      // default `!!subRows.length` rule applies (only parents with children expand).
      getExpandedRowModel: getExpandedRowModel(),
      getSubRows: (__getSubRows || undefined) as any,
      getRowCanExpand: this.expandable() === true && __getSubRows == null ? () => true : undefined,
      onExpandedChange: this.onExpandedChangeCb,
      // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
      // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
      // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
      // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
      // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
      // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
      // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
      // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
      // are unaffected (they fire regardless of this flag).
      autoResetExpanded: false,
      // Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
      // UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
      // (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
      // table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
      // members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
      // nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
      // (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
      getGroupedRowModel: getGroupedRowModel(),
      onGroupingChange: this.onGroupingChangeCb,
      // Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
      // UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
      // column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
      // The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
      getFacetedRowModel: getFacetedRowModel(),
      getFacetedUniqueValues: makeFacetedUniqueValues(),
      getFacetedMinMaxValues: makeFacetedMinMaxValues(),
      // Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
      // rows verbatim (no client-side filter/sort/paginate) and only emits the change
      // events so the consumer can fetch the next page/filtered slice.
      manualPagination: __manual === true,
      manualFiltering: __manual === true,
      manualSorting: __manual === true,
      // Server-side page-count sources (#2): pass the consumer-supplied total row count and/or
      // explicit page count so table-core can compute getPageCount() under `manual` (where it
      // does not hold the full dataset). undefined when unset → table-core auto-derives from the
      // loaded data (client-pagination path byte-unchanged). Precedence is table-core's: explicit
      // pageCount wins, else ⌈rowCount / pageSize⌉, else auto. With a real count getCanNextPage()
      // becomes true, so a server-pagination consumer can leave page 0.
      rowCount: this.rowCount() ?? undefined,
      pageCount: this.pageCount() ?? undefined,
      // Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
      // (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
      // default, D-06 — NOT overridden).
      enableRowSelection: __selectionMode !== 'none',
      enableMultiRowSelection: __selectionMode === 'multiple',
      // PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
      // no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
      // $watch so React reads fresh currentState (the stale-closure fix, F6).
      onSortingChange: this.onSortingChangeCb,
      onGlobalFilterChange: this.onGlobalFilterChangeCb,
      onColumnFiltersChange: this.onColumnFiltersChangeCb,
      onPaginationChange: this.onPaginationChangeCb,
      onRowSelectionChange: this.onRowSelectionChangeCb,
      onColumnVisibilityChange: this.onColumnVisibilityChangeCb,
      onColumnSizingChange: this.onColumnSizingChangeCb,
      onColumnOrderChange: this.onColumnOrderChangeCb,
      onColumnPinningChange: this.onColumnPinningChangeCb,
      onColumnSizingInfoChange: this.onColumnSizingInfoChangeCb,
      // Resize mode: 'onChange' so the bound columnSizing model updates live during the
      // drag (the behavioral width-delta assertion observes the in-progress width). Column
      // resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
      columnResizeMode: 'onChange',
      enableColumnResizing: true,
      renderFallbackValue: null,
      // table-core's RESOLVED options type (TableOptionsResolved) requires a global
      // onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
      // callbacks above, so the global hook is a no-op. Present so the createTable() argument
      // satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
      onStateChange: () => {}
    });
    this.refreshRowModel = () => {
      if (!this.table) return;
      // Capture fresh locals; never write a $data key then re-read it in the same fn
      // (ROZ138 / React stale-read — setState is async on React, the closure binds the
      // PRE-write value).
      // windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
      // pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
      const nextRows = this.windowSource().slice();
      const nextGroups = this.table.getHeaderGroups().slice();
      this.rows.set(nextRows);
      this.headerGroups.set(nextGroups);
      this.rowModelVer.set(this.rowModelVer() + 1);
      // Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
      // into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
      // NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
      // setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
      if (this.virtual() && this.virtualizer) {
        this.virtualizer.setOptions(this.virtualizerOptions());
        this.virtualizer._willUpdate();
      }
      // D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
      // clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
      // no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
      // B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
      // async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
      // the range corners on React too — never re-reading the pre-change model.
      const nextRowCount = nextRows.length;
      const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
      this.clampActiveCell(nextRowCount, nextColCount);
      // #4: clamp a pageIndex that now points PAST the last page. When the consumer holds
      // pagination.pageIndex (controlled) and shrinks the data (filter / replace) so there are
      // fewer pages, the body renders blank ("Page 6 of 3" with Next disabled). Read table-core's
      // LIVE post-re-derive state: getPageCount() is the fresh count (now correct under `manual`
      // too, #2) and getState().pagination is the just-fed state. Funnel the correction through
      // writePagination (the single-emit + two-way-model funnel) so the consumer's controlled
      // pagination prop converges to the last valid page (page-change carries { pageIndex, pageSize }).
      //   • pc > 0 skips the manual-WITHOUT-count case (getPageCount() === -1) — never clamp toward
      //     an unknown total.
      //   • LOOP-GUARD: emit ONLY when the clamped index actually differs. After the consumer echoes
      //     the clamp back through the pagination prop, the re-feed re-enters here with
      //     pageIndex === pc - 1, so `pageIndex > pc - 1` is false → no re-emit; a consumer that
      //     ignores the event triggers no further re-feed, so it stays a single emit either way.
      //   • No fight with table-core's autoResetPageIndex: that reset only fires on table-core's OWN
      //     setX mutations, which this fully-controlled-state architecture never calls (filters/data
      //     flow through setOptions), so reading the live state here can only fire on a genuine
      //     overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
      //     writing paginationDefault, with no regression since table-core does not auto-clamp here).
      const pgState = this.table.getState().pagination;
      const pc = this.table.getPageCount();
      if (pc > 0 && pgState.pageIndex > pc - 1) {
        this.writePagination({
          pageIndex: pc - 1,
          pageSize: pgState.pageSize
        });
      }
      // B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
      // filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
      // indices), so resolve the committed row's NEW index by identity HERE (never from the React-
      // stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
      // gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
      // a no-relocation commit resolves the same index → byte-behaviorally identical to before.
      if (this.pendingEditFollow && this.isGrid()) {
        const follow = this.pendingEditFollow;
        this.pendingEditFollow = null;
        const followIdx = this.indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
        if (followIdx >= 0) this.focusCellWhenReady(followIdx, follow.col);
      }
      // keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
      // selection state (bound :indeterminate is inert on 5/6 targets). The box persists
      // across selection changes; a microtask defer covers React's post-render DOM patch.
      this.syncIndeterminate();
      if (typeof queueMicrotask !== 'undefined') queueMicrotask(this.syncIndeterminate);else Promise.resolve().then(this.syncIndeterminate);
    };

    // initial pull
    // initial pull
    this.refreshRowModel();

    // ── Grid mode: capture the table root ──────────────────────────────────────────────
    // $el is the component root; the <table class="rozie-data-table"> is the grid root the
    // cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
    // (post-mount) so it is non-null and ROZ123-clean.
    // ── Grid mode: capture the table root ──────────────────────────────────────────────
    // $el is the component root; the <table class="rozie-data-table"> is the grid root the
    // cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
    // (post-mount) so it is non-null and ROZ123-clean.
    this.gridRoot = this.__rozieRoot()?.nativeElement ? this.__rozieRoot()!.nativeElement.querySelector('.rozie-data-table') : null;
    // WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
    // page load AND was non-deterministic on React/Solid (the entry cell may not be
    // committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
    // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
    // mount"); the consumer drives focus by Tabbing/clicking in, never the component.

    // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
    // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
    // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
    // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
    // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
    // WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
    // page load AND was non-deterministic on React/Solid (the entry cell may not be
    // committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
    // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
    // mount"); the consumer drives focus by Tabbing/clicking in, never the component.

    // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
    // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
    // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
    // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
    // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
    if (this.virtual()) {
      this.gridScrollEl = this.__rozieRoot()?.nativeElement ? this.__rozieRoot()!.nativeElement.querySelector('.rdt-scroll') : null;
      this.virtualizer = new Virtualizer(this.virtualizerOptions());
      this.virtualizerCleanup = this.virtualizer._didMount();
      // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
      // evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
      // reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
      // non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
      // SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
      // only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
      // never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
      // re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
      // pulls getVirtualItems().
      this.windowVer.set(this.windowVer() + 1);
      // After the first window commits (next frame), refine heights + fire the dev-mode warns
      // ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
      // code and these warns can never fire there (req-1 byte-identical-off preserved).
      const afterFirstFrame = () => {
        // D-10: measure the rendered rows.
        this.remeasureWindow();
        // D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
        // bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
        // process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
        const h = this.gridScrollEl ? this.gridScrollEl.clientHeight : 0;
        if (!h) {
          console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
        }
        // D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
        // CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
        // virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
        // ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
        // detected as a pagination that DIFFERS from that default (a consumer who set a real page
        // size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
        // virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
        const pg = this.pagination();
        const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
        if (__manual !== true && pgConfigured) {
          console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
        }
      };
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
    }

    // #14: seed the sort/filter announce baseline from the initial (post-mount) state so the LAZY
    // watch's first fire — a real user sort/filter — compares against the true starting values and
    // is classified correctly (a null sentinel would misread the first filter change as a sort change).
    // #14: seed the sort/filter announce baseline from the initial (post-mount) state so the LAZY
    // watch's first fire — a real user sort/filter — compares against the true starting values and
    // is classified correctly (a null sentinel would misread the first filter change as a sort change).
    this.announceState.sorting = this.effectiveSorting();
    this.announceState.columnFilters = this.effectiveColumnFilters();
    this.announceState.globalFilter = this.effectiveGlobalFilter();
  }

  table: any = null;
  virtualizer: any = null;
  virtualizerCleanup: any = null;
  gridScrollEl: any = null;
  remeasurePending = false;
  GRID_PAGE_STEP = 10;
  gridRoot: any = null;
  programmatic = 0;
  focusIntentEpoch = 0;
  DATA_WRITE_TOKEN_KEY = '__rozieDataWriteToken';
  undoStack: unknown[] = [];
  redoStack: unknown[] = [];
  restoringHistory: boolean = false;
  expandedTouched = false;
  groupingActiveDefault = () => ((this.grouping() != null ? this.grouping() : this.groupingDefault()) || []).length > 0;
  effectiveColumnPinning = (): any => {
    const __columnPinning = this.columnPinning();
    const base = __columnPinning != null ? __columnPinning : this.columnPinningDefault();
    const rail: string[] = [];
    if (this.selectionEnabled()) rail.push(this.SELECT_COL_ID);
    if (this.expandable() === true) rail.push(this.EXPANDER_COL_ID);
    if (rail.length === 0) return base;
    const left = base && base.left ? base.left : [];
    const deduped = left.filter((id: string) => id !== this.SELECT_COL_ID && id !== this.EXPANDER_COL_ID);
    return {
      ...base,
      left: rail.concat(deduped)
    };
  };
  currentState = (): any => ({
    sorting: this.sorting() != null ? this.sorting() : this.sortingDefault(),
    globalFilter: this.globalFilter() != null ? this.globalFilter() : this.globalFilterDefault(),
    columnFilters: this.columnFilters() != null ? this.columnFilters() : this.columnFiltersDefault(),
    pagination: this.pagination() != null ? this.pagination() : this.paginationDefault(),
    rowSelection: this.rowSelection() != null ? this.rowSelection() : this.rowSelectionDefault(),
    // expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
    // literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
    // guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
    // GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
    // `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
    // literal so the grouped subtree is visible by default; the first toggle latches
    // expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
    // unchanged → byte-identical-off (the table + the expandable-rows feature both keep
    // $data.expandedDefault).
    expanded: this.expanded() != null ? this.expanded() : this.groupingActiveDefault() && !this.expandedTouched ? true : this.expandedDefault(),
    // grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
    // to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
    // is inert when this is empty (byte-identical-off, req-10).
    grouping: this.grouping() != null ? this.grouping() : this.groupingDefault(),
    columnVisibility: this.columnVisibility() != null ? this.columnVisibility() : this.columnVisibilityDefault(),
    columnSizing: this.columnSizing() != null ? this.columnSizing() : this.columnSizingDefault(),
    columnOrder: this.columnOrder() != null ? this.columnOrder() : this.columnOrderDefault(),
    columnPinning: this.effectiveColumnPinning(),
    // columnSizingInfo: table-core's transient resize-gesture state. We pass an
    // EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
    // `column.getIsResizing()` / `getResizeHandler()` read
    // `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
    // absent. Seed the default shape (matches table-core's
    // getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
    // every render. Not a two-way model slice (transient gesture state, not consumer
    // state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
    columnSizingInfo: this.columnSizingInfo()
  });
  currentData = (): any => this.data() != null ? this.data() : this.dataDefault();
  isSafeKey = (k: any) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
  wrapAggregationFn = (fn: any) => {
    if (typeof fn === 'string') return fn;
    if (typeof fn !== 'function') return undefined;
    return (columnId: any, leafRows: any, childRows: any) => {
      try {
        return fn(columnId, leafRows, childRows);
      } catch (err: any) {
        return undefined;
      }
    };
  };
  buildConfigDef = (c: any) => {
    if (!c) return null;
    // Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
    // getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
    // header spans its leaf children (B12). The group id falls back to its header text so it
    // stays addressable (no accessor; group columns carry no data).
    if (Array.isArray(c.columns)) {
      const kids = [];
      for (const child of c.columns as any) {
        const cd = this.buildConfigDef(child);
        if (cd) kids.push(cd);
      }
      if (!kids.length) return null;
      // Group id: an explicit c.id wins. Otherwise synthesize a STABLE UNIQUE id from the child
      // column ids (which are unique per leaf accessor / recursively-synthesized per nested group)
      // — NOT the header text. Falling back to c.header collided two same-titled groups (e.g. both
      // "Details") into one by-id map key, so the columnDefs LWW merge silently dropped one whole
      // group column + its children. The child-id derivation is deterministic (stable across
      // renders — no Math.random/Date). A group with neither id nor header (nor derivable children)
      // stays dropped as before.
      let gid = c.id;
      if (gid == null) gid = c.header != null ? '__grp_' + kids.map((k: any) => k.id).join('_') : null;
      if (gid == null) return null;
      const id = String(gid);
      if (!this.isSafeKey(id)) return null;
      return {
        id,
        header: c.header != null ? c.header : id,
        columns: kids
      };
    }
    const rawId = c.id != null ? c.id : c.field;
    if (rawId == null) return null;
    const id = String(rawId);
    if (!this.isSafeKey(id)) return null;
    return {
      id,
      accessorKey: c.field != null ? c.field : id,
      header: c.header != null ? c.header : id,
      enableSorting: c.sortable === true,
      // per-column filter opt-in (req-5). table-core gates the filter input + value
      // funnel on enableColumnFilter; a column with filterable !== true cannot be
      // filtered (and renders no per-column filter input in the chrome below).
      enableColumnFilter: c.filterable === true,
      filterable: c.filterable === true,
      // Expandable-rows reserved per-column metadata (phase 50, D-04).
      expandable: c.expandable === true,
      // Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
      // so every data column is offered to the headless #groupBar by default; the per-column
      // aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
      // a custom fn defensively wrapped (T-50-04).
      groupable: c.groupable !== false,
      aggregationFn: this.wrapAggregationFn(c.aggregationFn),
      pinned: c.pinned != null ? c.pinned : '',
      width: c.width != null ? c.width : '',
      // Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
      // metadata carrier the display↔editor branch + runValidator read. Off by default.
      meta: {
        editable: c.editable === true,
        editor: c.editor != null ? c.editor : 'text',
        editorOptions: c.editorOptions != null ? c.editorOptions : [],
        validate: typeof c.validate === 'function' ? c.validate : null
      }
    };
  };
  columnDefs = () => {
    const byId = Object.create(null);
    const order = [];
    const cfg = this.columns() || [];
    for (const c of cfg as any) {
      const def = this.buildConfigDef(c);
      if (!def) continue;
      const id = def.id;
      if (!(id in byId)) order.push(id);
      byId[id] = def;
    }
    const reg = this.colReg() || {};
    for (const id in reg) {
      if (!this.isSafeKey(id)) continue;
      const spec = reg[id];
      if (!spec) continue;
      if (!(id in byId)) order.push(id);
      byId[id] = {
        id,
        accessorKey: spec.field != null ? spec.field : id,
        header: spec.header != null ? spec.header : id,
        enableSorting: spec.sortable === true,
        enableColumnFilter: spec.filterable === true,
        filterable: spec.filterable === true,
        // Expandable-rows reserved per-column metadata (phase 50, D-04).
        expandable: spec.expandable === true,
        // Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
        groupable: spec.groupable !== false,
        aggregationFn: this.wrapAggregationFn(spec.aggregationFn),
        pinned: spec.pinned != null ? spec.pinned : '',
        width: spec.width != null ? spec.width : '',
        // Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
        meta: {
          editable: spec.editable === true,
          editor: spec.editor != null ? spec.editor : 'text',
          editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
          validate: typeof spec.validate === 'function' ? spec.validate : null
        }
      };
    }
    const out = [];
    for (const id of order as any) if (byId[id]) out.push(byId[id]);
    return out;
  };
  SELECT_COL_ID = '__rdt_select';
  EXPANDER_COL_ID = '__rdt_expander';
  selectionEnabled = () => this.selectionMode() === 'single' || this.selectionMode() === 'multiple';
  tableColumns = () => {
    const cols = this.columnDefs();
    // Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
    // isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
    // value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
    // → byte-identical-off (req-10).
    let withExpander = cols;
    if (this.expandable() === true) {
      const expanderCol = {
        id: this.EXPANDER_COL_ID,
        enableSorting: false,
        enableColumnFilter: false,
        filterable: false,
        isExpanderColumn: true,
        pinned: '',
        width: '',
        // Explicit narrow size so table-core's getSize()/getStart('left') match the RENDERED
        // width. Without it table-core assumes its 150px default, which is fine for an UNPINNED
        // chrome column (a CSS `width:1%` trick shrinks it visually) but breaks the moment the
        // column joins the left-pinned rail: pinStyle's sticky offset is Σ preceding pinned
        // SIZES, so a phantom 150px would push every real pinned column ~150px too far right and
        // overlap. Keep this in sync with the `--rdt-expander-col-width` CSS default (40px).
        size: 40
      };
      withExpander = [expanderCol].concat(cols);
    }
    if (this.selectionEnabled()) {
      const selectCol = {
        id: this.SELECT_COL_ID,
        enableSorting: false,
        enableColumnFilter: false,
        filterable: false,
        isSelectColumn: true,
        pinned: '',
        width: '',
        // Explicit narrow size so table-core's sticky-offset math (getStart('left')) matches the
        // rendered checkbox width once this column joins the left-pinned rail — see the expander
        // note above. Keep in sync with the `--rdt-select-col-width` CSS default (44px).
        size: 44
      };
      return [selectCol].concat(withExpander);
    }
    return withExpander;
  };
  writeSorting = (next: any) => {
    if (this.programmatic) return;
    this.programmatic++;
    this.sortingDefault.set(next); // fresh array only (never in-place)
    this.sorting.set(next); // two-way emit if bound (no-op-diff if not)
    this.sortChange.emit(next);
    this.programmatic--;
  };
  applyUpdater = (updater: any, current: any) => typeof updater === 'function' ? updater(current) : updater;
  writeExpanded = (next: any) => {
    if (this.programmatic) return;
    this.programmatic++;
    // Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
    // the user now owns the expanded state, so currentState() stops defaulting grouped rows to
    // the `true` expand-all literal and honors $data.expandedDefault from here on.
    this.expandedTouched = true;
    this.expandedDefault.set(next); // fresh value only (never in-place)
    this.expanded.set(next); // two-way emit if bound (no-op-diff if not)
    // Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
    // prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
    // interface, and an `expanded-change` event would camelCase to the SAME identifier
    // → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
    // sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
    // sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
    this.expandChange.emit(next);
    this.programmatic--;
  };
  writeGrouping = (next: any) => {
    if (this.programmatic) return;
    this.programmatic++;
    this.groupingDefault.set(next); // fresh ordered array only (never in-place push)
    this.grouping.set(next); // two-way emit if bound (no-op-diff if not)
    this.groupChange.emit(next);
    this.programmatic--;
  };
  writeGlobalFilter = (next: any) => {
    if (this.programmatic) return;
    this.programmatic++;
    this.globalFilterDefault.set(next);
    this.globalFilter.set(next);
    this.filterChange.emit({
      globalFilter: next
    });
    this.programmatic--;
  };
  writeColumnFilters = (next: any) => {
    if (this.programmatic) return;
    this.programmatic++;
    this.columnFiltersDefault.set(next);
    this.columnFilters.set(next);
    this.filterChange.emit({
      columnFilters: next
    });
    this.programmatic--;
  };
  writePagination = (next: any) => {
    if (this.programmatic) return;
    this.programmatic++;
    this.paginationDefault.set(next);
    this.pagination.set(next);
    this.pageChange.emit(next);
    this.programmatic--;
  };
  writeRowSelection = (next: any) => {
    if (this.programmatic) return;
    this.programmatic++;
    this.rowSelectionDefault.set(next);
    this.rowSelection.set(next);
    this.selectionChange.emit(next);
    this.programmatic--;
  };
  writeColumnVisibility = (next: any) => {
    if (this.programmatic) return;
    this.programmatic++;
    this.columnVisibilityDefault.set(next);
    this.columnVisibility.set(next);
    this.visibilityChange.emit(next);
    this.programmatic--;
  };
  writeColumnSizing = (next: any) => {
    if (this.programmatic) return;
    this.programmatic++;
    this.columnSizingDefault.set(next);
    this.columnSizing.set(next);
    this.resizeChange.emit(next);
    this.programmatic--;
  };
  writeColumnOrder = (next: any) => {
    if (this.programmatic) return;
    this.programmatic++;
    this.columnOrderDefault.set(next);
    this.columnOrder.set(next);
    this.reorderChange.emit(next);
    this.programmatic--;
  };
  writeColumnPinning = (next: any) => {
    if (this.programmatic) return;
    // effectiveColumnPinning() forces the auto-injected chrome ids (select/expander) into the
    // table-core `left` rail, so table-core hands them back here on every pin change. Strip them
    // before persisting: the CONSUMER's columnPinning model + the pin-change event must reflect
    // only their own columns, never our internal rail ids (which re-inject each render anyway).
    const strip = (ids: any) => (ids || []).filter((id: any) => id !== this.SELECT_COL_ID && id !== this.EXPANDER_COL_ID);
    const clean = {
      ...next,
      left: strip(next && next.left),
      right: strip(next && next.right)
    };
    this.programmatic++;
    this.columnPinningDefault.set(clean);
    this.columnPinning.set(clean);
    this.pinChange.emit(clean);
    this.programmatic--;
  };
  writeData = (next: any) => {
    if (this.programmatic) return;
    if (this.undoable() && !this.restoringHistory) {
      const prevU = this.canUndo();
      const prevR = this.canRedo();
      this.recordSnapshot(this.currentData());
      this.emitHistoryChangeIfEdged(prevU, prevR);
    }
    const fresh = Array.isArray(next) ? next.slice() : next;
    try {
      Object.defineProperty(fresh, this.DATA_WRITE_TOKEN_KEY, {
        value: true,
        enumerable: false,
        configurable: true,
        writable: true
      });
    } catch (_e: any) {/* a frozen/sealed array can't be stamped — our fresh arrays never are */}
    this.programmatic++;
    this.dataDefault.set(fresh); // fresh raw array only (never in-place, never a proxy)
    this.data.set(fresh); // two-way emit if bound (no-op-diff if not)
    this.programmatic--;
  };
  columnFilterValue = (colId: any) => {
    const cf = this.currentState().columnFilters || [];
    for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
    return '';
  };
  setColumnFilter = (colId: any, value: any) => {
    const prev = this.currentState().columnFilters || [];
    const next = [];
    for (const f of prev as any) if (f && f.id !== colId) next.push(f);
    if (value != null && value !== '') next.push({
      id: colId,
      value
    });
    this.writeColumnFilters(next);
  };
  recordSnapshot = (current: any) => {
    const __undoLimit = this.undoLimit();
    this.undoStack.push(current);
    const limit = __undoLimit != null ? __undoLimit : 100;
    while (this.undoStack.length > limit) this.undoStack.shift();
    this.redoStack = [];
  };
  canUndo = () => this.undoStack.length > 0;
  canRedo = () => this.redoStack.length > 0;
  clearHistory = () => {
    this.undoStack = [];
    this.redoStack = [];
  };
  emitHistoryChange = () => {
    this.historyChange.emit({
      canUndo: this.canUndo(),
      canRedo: this.canRedo()
    });
  };
  emitHistoryChangeIfEdged = (prevU: any, prevR: any) => {
    const nextU = this.canUndo();
    const nextR = this.canRedo();
    if (nextU !== prevU || nextR !== prevR) this.emitHistoryChange();
  };
  undo = () => {
    if (!this.canUndo()) return;
    const prev = this.undoStack.pop();
    this.redoStack.push(this.currentData());
    this.restoringHistory = true;
    this.writeData(prev);
    this.restoringHistory = false;
    this.emitHistoryChange();
  };
  redo = () => {
    if (!this.canRedo()) return;
    const next = this.redoStack.pop();
    this.undoStack.push(this.currentData());
    this.restoringHistory = true;
    this.writeData(next);
    this.restoringHistory = false;
    this.emitHistoryChange();
  };
  refreshRowModel: any = null;
  onSortingChangeCb = (updater: any) => {
    this.writeSorting(this.applyUpdater(updater, this.currentState().sorting));
  };
  onExpandedChangeCb = (updater: any) => {
    this.writeExpanded(this.applyUpdater(updater, this.currentState().expanded));
  };
  onGroupingChangeCb = (updater: any) => {
    this.writeGrouping(this.applyUpdater(updater, this.currentState().grouping));
  };
  onGlobalFilterChangeCb = (updater: any) => {
    this.writeGlobalFilter(this.applyUpdater(updater, this.currentState().globalFilter));
  };
  onColumnFiltersChangeCb = (updater: any) => {
    this.writeColumnFilters(this.applyUpdater(updater, this.currentState().columnFilters));
  };
  onPaginationChangeCb = (updater: any) => {
    this.writePagination(this.applyUpdater(updater, this.currentState().pagination));
  };
  onRowSelectionChangeCb = (updater: any) => {
    this.writeRowSelection(this.applyUpdater(updater, this.currentState().rowSelection));
  };
  onColumnVisibilityChangeCb = (updater: any) => {
    this.writeColumnVisibility(this.applyUpdater(updater, this.currentState().columnVisibility));
  };
  onColumnSizingChangeCb = (updater: any) => {
    this.writeColumnSizing(this.applyUpdater(updater, this.currentState().columnSizing));
  };
  onColumnOrderChangeCb = (updater: any) => {
    this.writeColumnOrder(this.applyUpdater(updater, this.currentState().columnOrder));
  };
  onColumnPinningChangeCb = (updater: any) => {
    this.writeColumnPinning(this.applyUpdater(updater, this.currentState().columnPinning));
  };
  onColumnSizingInfoChangeCb = (updater: any) => {
    const next = this.applyUpdater(updater, this.columnSizingInfo());
    this.columnSizingInfo.set(next != null ? next : this.columnSizingInfo());
  };
  windowSource = () => {
    if (!this.table) return [];
    if (this.virtual()) return this.table.getPrePaginationRowModel().rows;
    return this.table.getRowModel().rows;
  };
  scheduleRemeasure = () => {
    if (this.remeasurePending) return;
    this.remeasurePending = true;
    let ranMicro = false;
    const microPass = () => {
      this.remeasureWindow();
    };
    const rafPass = () => {
      this.remeasurePending = false;
      this.remeasureWindow();
    };
    if (typeof queueMicrotask !== 'undefined') {
      ranMicro = true;
      queueMicrotask(microPass);
    }
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else setTimeout(rafPass, 0);
  };
  pinnedEditIndex = () => {
    const __editingRow = this.editingRow();
    const __editingRowIndex = this.editingRowIndex();
    if (__editingRow >= 0) return __editingRow;
    if (__editingRowIndex != null) return __editingRowIndex;
    return -1;
  };
  pinnedMeasurement = (pin: any) => {
    if (!this.virtualizer || pin < 0) return null;
    const ms = this.virtualizer.getMeasurements();
    return ms && ms[pin] ? ms[pin] : null;
  };
  remeasureWindow = () => {
    if (!this.virtualizer || !this.gridRoot) return;
    // Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
    // exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
    // null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
    // scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
    // resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
    // regression); the next settled onChange re-measures the stable window. Manual-scroll
    // recycling (the CR-01 case) has scrollState === null, so it measures normally.
    if (this.virtualizer.scrollState) return;
    const trs = this.gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
    for (const tr of trs as any) this.virtualizer.measureElement(tr);
  };
  virtualItemKey = (i: any) => {
    const src = this.windowSource();
    return src && src[i] ? src[i].id : undefined;
  };
  virtualizerOptions = (): any => ({
    count: this.windowSource().length,
    getScrollElement: () => this.gridScrollEl,
    estimateSize: () => this.estimateRowHeight(),
    observeElementRect,
    observeElementOffset,
    scrollToFn: elementScroll,
    measureElement,
    overscan: 8,
    getItemKey: this.virtualItemKey,
    onChange: () => {
      this.windowVer.set(this.windowVer() + 1);
      // CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
      // virtual-core only observe()s a node you explicitly hand to measureElement (it does
      // NOT auto-discover rendered rows — measureElement is the SOLE caller of
      // observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
      // into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
      // estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
      // so the new <tr> set is in the DOM before we measure. Safe from an infinite
      // measure→onChange→measure loop: measureElement is idempotent on an already-observed
      // node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
      // measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
      // re-measure is a no-op.
      this.scheduleRemeasure();
    }
  });
  pinMeasurement = (pin: number): {
    start: number;
    size: number;
    index: number;
    end: number;
  } | null => this.pinnedMeasurement(pin);
  windowedRows = () => {
    const __rows = this.rows();
    // SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
    // early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
    // which happens at initial render while `virtualizer` is still null (it is built in $onMount,
    // after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
    // BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
    // signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
    // blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
    // placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
    // first re-run that picks up the now-non-null virtualizer.
    // ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
    // pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
    void this.windowVer();
    void this.editVer();
    if (!this.virtualizer) {
      // Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
      // but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
      // the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
      // rows appear on the first onChange after _didMount.
      if (!this.virtual()) {
        const rowList = __rows || [];
        return rowList.map((r: any) => ({
          vi: null,
          row: r
        }));
      }
      return [];
    }
    const items = this.virtualizer.getVirtualItems();
    const rowList = __rows || [];
    // WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
    // shrink window where the virtualizer count is stale relative to $data.rows on the async
    // onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
    // throw "Cannot read properties of undefined"; filter it here so the template never sees it.
    const out = items.map((vi: any) => ({
      vi,
      row: rowList[vi.index]
    })).filter((wr: any) => wr.row);
    // ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
    // window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
    // into another full-model row), LEADING the slice when it sits above the window and TRAILING
    // it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
    // padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
    // into the real windowing.
    const pin = this.pinnedEditIndex();
    if (pin >= 0 && rowList[pin]) {
      let inWindow = false;
      for (let i = 0; i < items.length; i++) {
        if (items[i].index === pin) {
          inWindow = true;
          break;
        }
      }
      if (!inWindow) {
        const pm = this.pinMeasurement(pin);
        const firstStart = items.length ? items[0].start : 0;
        const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
        const pinnedEntry = {
          vi: pm != null ? pm : {
            index: pin
          },
          row: rowList[pin],
          pinned: true
        };
        if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
      }
    }
    return out;
  };
  padTop = () => {
    // SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
    // spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
    // and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
    void this.windowVer();
    void this.editVer();
    if (!this.virtual() || !this.virtualizer) return 0;
    const items = this.virtualizer.getVirtualItems();
    let pad = items.length ? items[0].start : 0;
    // D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
    // in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
    // that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
    const pin = this.pinnedEditIndex();
    if (pin >= 0) {
      const pm = this.pinMeasurement(pin);
      const inWindow = this.pmIndexInWindow(items, pin);
      if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
    }
    return pad < 0 ? 0 : pad;
  };
  padBottom = () => {
    // subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
    // return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
    // on pin/unpin.
    void this.windowVer();
    void this.editVer();
    if (!this.virtual() || !this.virtualizer) return 0;
    const items = this.virtualizer.getVirtualItems();
    if (!items.length) return 0;
    let pad = this.virtualizer.getTotalSize() - items[items.length - 1].end;
    // D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
    // in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
    const pin = this.pinnedEditIndex();
    if (pin >= 0) {
      const pm = this.pinMeasurement(pin);
      const inWindow = this.pmIndexInWindow(items, pin);
      // WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
      // measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
      // index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
      // The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
      // the offset comparison only if the measurement lacks an index (defensive).
      const lastItemIdx = items[items.length - 1].index;
      const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
      if (pm && !inWindow && below) {
        // below the window → it trailed the slice; subtract its height from the trailing spacer.
        if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
      }
    }
    return pad < 0 ? 0 : pad;
  };
  pmIndexInWindow = (items: any, idx: any) => {
    for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
    return false;
  };
  rowIsOutsideWindow = (r: any) => {
    if (!this.virtual() || !this.virtualizer) return false;
    const items = this.virtualizer.getVirtualItems();
    for (const it of items as any) if (it.index === r) return false;
    return true;
  };
  announceState: {
    sorting: unknown;
    columnFilters: unknown;
    globalFilter: unknown;
  } = {
    sorting: null,
    columnFilters: null,
    globalFilter: null
  };
  effectiveSorting = () => this.sorting() != null ? this.sorting() : this.sortingDefault();
  effectiveColumnFilters = () => this.columnFilters() != null ? this.columnFilters() : this.columnFiltersDefault();
  effectiveGlobalFilter = () => this.globalFilter() != null ? this.globalFilter() : this.globalFilterDefault();
  buildSortFilterAnnounce = () => {
    const nextSorting = this.effectiveSorting();
    const nextColumnFilters = this.effectiveColumnFilters();
    const nextGlobalFilter = this.effectiveGlobalFilter();
    const sortChanged = nextSorting !== this.announceState.sorting;
    const filterChanged = nextColumnFilters !== this.announceState.columnFilters || nextGlobalFilter !== this.announceState.globalFilter;
    this.announceState.sorting = nextSorting;
    this.announceState.columnFilters = nextColumnFilters;
    this.announceState.globalFilter = nextGlobalFilter;
    if (sortChanged) {
      const active = nextSorting && nextSorting.length ? nextSorting[0] : null;
      if (!active) return 'Sorting cleared';
      const rawLabel = this.headerLabel(active.id);
      const label = typeof rawLabel === 'string' && rawLabel ? rawLabel : active.id;
      return 'Sorted by ' + label + ', ' + (active.desc ? 'descending' : 'ascending');
    }
    if (filterChanged) {
      return this.totalRowCount() + ' results';
    }
    return '';
  };
  reFeed = () => {
    if (!this.table) return;
    // NOTE: the external-swap history reset does NOT live here. reFeed() fires on EVERY watched
    // change — including our OWN synchronous internal `$data.dataDefault` write — so a clear keyed
    // on a `currentData()` read here would (on fine-grained targets) fire mid-round-trip against a
    // TRANSIENTLY-STALE `$props.data` and wrongly wipe a just-recorded edit's history. The reset is
    // keyed on the `$props.data` REFERENCE actually changing instead — see the $onUpdate backstop
    // below (`maybeClearHistoryOnExternalSwap`), which runs on all six targets.
    this.table.setOptions((prev: any) => ({
      ...prev,
      data: this.currentData(),
      columns: this.tableColumns(),
      state: this.currentState(),
      enableRowSelection: this.selectionMode() !== 'none',
      enableMultiRowSelection: this.selectionMode() === 'multiple',
      // Re-pass the server-side page-count sources (#2) so a RUNTIME rowCount/pageCount change
      // takes effect: setOptions REPLACES via `...prev`, which holds the value captured at
      // createTable time, so an omitted key would freeze the mount-time count. The re-feed
      // $watch keys on both props below.
      rowCount: this.rowCount() ?? undefined,
      pageCount: this.pageCount() ?? undefined,
      // Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
      // setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
      // onExpandedChange callback must re-capture fresh currentState each cycle, F6).
      getExpandedRowModel: getExpandedRowModel(),
      getSubRows: (this.getSubRows() || undefined) as any,
      getRowCanExpand: this.expandable() === true && this.getSubRows() == null ? () => true : undefined,
      onExpandedChange: this.onExpandedChangeCb,
      // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
      // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
      // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
      // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
      // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
      // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
      // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
      // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
      // are unaffected (they fire regardless of this flag).
      autoResetExpanded: false,
      // Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
      // omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
      // fresh currentState each cycle, F6).
      getGroupedRowModel: getGroupedRowModel(),
      onGroupingChange: this.onGroupingChangeCb,
      // Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
      // drop the model on re-feed; on React the faceted closures must re-capture so exposed
      // unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
      getFacetedRowModel: getFacetedRowModel(),
      getFacetedUniqueValues: makeFacetedUniqueValues(),
      getFacetedMinMaxValues: makeFacetedMinMaxValues(),
      // Re-pass the per-slice callbacks so React captures fresh currentState each cycle
      // (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
      onSortingChange: this.onSortingChangeCb,
      onGlobalFilterChange: this.onGlobalFilterChangeCb,
      onColumnFiltersChange: this.onColumnFiltersChangeCb,
      onPaginationChange: this.onPaginationChangeCb,
      onRowSelectionChange: this.onRowSelectionChangeCb,
      onColumnVisibilityChange: this.onColumnVisibilityChangeCb,
      onColumnSizingChange: this.onColumnSizingChangeCb,
      onColumnOrderChange: this.onColumnOrderChangeCb,
      onColumnPinningChange: this.onColumnPinningChangeCb,
      onColumnSizingInfoChange: this.onColumnSizingInfoChangeCb
    }));
    if (this.refreshRowModel) this.refreshRowModel();
  };
  lastPropsData: unknown = null;
  maybeClearHistoryOnExternalSwap = () => {
    const pd = this.data();
    if (pd === this.lastPropsData) return; // $props.data did not change → not an external swap
    this.lastPropsData = pd;
    if (!this.undoable()) return;
    if (pd != null && (pd as any)[this.DATA_WRITE_TOKEN_KEY] != null) return; // descends from our write → keep
    this.clearHistory();
  };
  lastData: any = null;
  lastDataLen = -1;
  onHeaderSort = (colId: any, evt: any) => {
    if (!this.table) return;
    const col = this.table.getColumn(colId);
    if (!col || !col.getCanSort()) return;
    const multi = !!(evt && evt.shiftKey);
    // toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
    col.toggleSorting(undefined, multi);
  };
  tick = () => this.rowModelVer();
  ariaSortFor = (colId: any) => {
    if (this.tick() < 0 || !this.table) return 'none';
    const col = this.table.getColumn(colId);
    if (!col) return 'none';
    const dir = col.getIsSorted();
    if (dir === 'asc') return 'ascending';
    if (dir === 'desc') return 'descending';
    return 'none';
  };
  sortIndicator = (colId: any) => {
    if (this.tick() < 0 || !this.table) return '';
    const col = this.table.getColumn(colId);
    if (!col) return '';
    const dir = col.getIsSorted();
    if (dir === 'asc') return '▲';
    if (dir === 'desc') return '▼';
    return '';
  };
  defFor = (colId: any) => {
    const defs = this.columnDefs();
    for (const d of defs as any) if (d.id === colId) return d;
    return null;
  };
  visibleCellsFor = (row: any) => this.rowModelVer() >= 0 ? row.getVisibleCells() : [];
  editMetaOf = (colId: any) => {
    const d = this.defFor(colId);
    return d && d.meta ? d.meta : null;
  };
  columnEditable = (colId: any) => {
    const m = this.editMetaOf(colId);
    return !!(m && m.editable === true);
  };
  editorTypeOf = (colId: any) => {
    const m = this.editMetaOf(colId);
    return m && m.editor != null ? m.editor : 'text';
  };
  editorOptionsOf = (colId: any) => {
    const m = this.editMetaOf(colId);
    return m && m.editorOptions != null ? m.editorOptions : [];
  };
  hasEditorSlot = (colId: any) => this.editorTypeOf(colId) === 'custom' && !!(this.editorTpl ?? this.__rozieFillMap()['editor'] ?? this.templates()?.['editor']);
  hasFilterSlot = () => !!(this.filterTpl ?? this.__rozieFillMap()['filter'] ?? this.templates()?.['filter']);
  columnIsFilterable = (colId: any) => {
    const d = this.defFor(colId);
    return !!(d && d.filterable);
  };
  headerLabel = (colId: any) => {
    const d = this.defFor(colId);
    return d ? d.header : colId;
  };
  headerWidth = (colId: any) => {
    if (this.tick() < 0 || !this.table) return null;
    const col = this.table.getColumn(colId);
    if (!col) return null;
    const w = col.getSize();
    return w != null && w > 0 ? w + 'px' : null;
  };
  onResizeStart = (colId: any, evt: any) => {
    // stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
    if (evt && evt.stopPropagation) evt.stopPropagation();
    if (!this.table) return;
    const header = this.findHeader(colId);
    if (!header || !header.getResizeHandler) return;
    const handler = header.getResizeHandler();
    if (handler) handler(evt);
  };
  findHeader = (colId: any) => {
    const groups = this.headerGroups() || [];
    for (const hg of groups as any) {
      const hs = hg.headers || [];
      for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
    }
    return null;
  };
  columnIsResizing = (colId: any) => {
    if (this.tick() < 0 || !this.table) return false;
    const header = this.findHeader(colId);
    return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
  };
  columnIsVisible = (colId: any) => {
    if (this.tick() < 0 || !this.table) return true;
    const col = this.table.getColumn(colId);
    return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
  };
  onToggleVisibility = (colId: any) => {
    if (!this.table) return;
    const col = this.table.getColumn(colId);
    if (col && col.toggleVisibility) col.toggleVisibility();
  };
  allLeafColumns = () => {
    if (this.tick() < 0 || !this.table) return [];
    const cols = this.table.getAllLeafColumns ? this.table.getAllLeafColumns() : [];
    const out = [];
    for (const c of cols as any) {
      if (!c || c.id === this.SELECT_COL_ID || c.id === this.EXPANDER_COL_ID) continue;
      out.push({
        id: c.id,
        label: this.headerLabel(c.id),
        visible: !!(c.getIsVisible && c.getIsVisible())
      });
    }
    return out;
  };
  columnPinSide = (colId: any) => {
    if (this.tick() < 0 || !this.table) return false;
    const col = this.table.getColumn(colId);
    if (!col || !col.getIsPinned) return false;
    return col.getIsPinned();
  };
  onPinColumn = (colId: any, side: any, evt: any) => {
    if (evt && evt.stopPropagation) evt.stopPropagation();
    if (!this.table) return;
    const col = this.table.getColumn(colId);
    if (col && col.pin) col.pin(side);
  };
  pinStyle = (colId: any, zIndex: any = 1) => {
    if (this.tick() < 0 || !this.table) return '';
    const col = this.table.getColumn(colId);
    if (!col || !col.getIsPinned) return '';
    const side = col.getIsPinned();
    if (side === 'left') {
      const left = col.getStart ? col.getStart('left') : 0;
      return 'position:sticky;left:' + left + 'px;z-index:' + zIndex + ';';
    }
    if (side === 'right') {
      const right = col.getAfter ? col.getAfter('right') : 0;
      return 'position:sticky;right:' + right + 'px;z-index:' + zIndex + ';';
    }
    return '';
  };
  thStyle = (colId: any) => {
    let s = '';
    const w = this.headerWidth(colId);
    if (w) s += 'width:' + w + ';';
    s += this.pinStyle(colId, 2);
    return s;
  };
  onGlobalFilterInput = (evt: any) => {
    const value = evt && evt.target ? evt.target.value : '';
    if (this.table) {
      this.table.setGlobalFilter(value);
      return;
    }
    this.writeGlobalFilter(value);
  };
  onColumnFilterInput = (colId: any, evt: any) => {
    const value = evt && evt.target ? evt.target.value : '';
    this.setColumnFilter(colId, value);
  };
  globalFilterValue = () => {
    const v = this.currentState().globalFilter;
    return v != null ? v : '';
  };
  pageIndex = () => {
    if (this.tick() >= 0 && this.table) return this.table.getState().pagination.pageIndex;
    const p = this.currentState().pagination;
    return p && p.pageIndex != null ? p.pageIndex : 0;
  };
  pageSize = () => {
    if (this.tick() >= 0 && this.table) return this.table.getState().pagination.pageSize;
    const p = this.currentState().pagination;
    return p && p.pageSize != null ? p.pageSize : 10;
  };
  displayPageCount = () => {
    if (this.tick() < 0 || !this.table) return 1;
    const c = this.table.getPageCount();
    return c != null && c > 0 ? c : 1;
  };
  canPrevPage = () => !!(this.tick() >= 0 && this.table && this.table.getCanPreviousPage());
  canNextPage = () => !!(this.tick() >= 0 && this.table && this.table.getCanNextPage());
  onPrevPage = () => {
    if (this.table) this.table.previousPage();
  };
  onNextPage = () => {
    if (this.table) this.table.nextPage();
  };
  onPageSizeChange = (evt: any) => {
    if (!this.table) return;
    const v = evt && evt.target ? evt.target.value : '';
    const n = parseInt(v, 10);
    this.table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
  };
  isSelectColumn = (colId: any) => colId === this.SELECT_COL_ID;
  isExpanderColumn = (colId: any) => colId === this.EXPANDER_COL_ID;
  rowCanExpand = (row: any) => !!(this.tick() >= 0 && row && row.getCanExpand && row.getCanExpand() && !(row.getIsGrouped && row.getIsGrouped()));
  rowIsExpanded = (row: any) => !!(this.tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
  rowShowsDetail = (row: any) => this.getSubRows() == null && !this.rowIsGrouped(row) && this.rowIsExpanded(row);
  onToggleExpand = (row: any, evt: any) => {
    if (!row || !row.toggleExpanded) return;
    // Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
    // expanded-state re-render. This guards a focus-drop that USED to happen on Solid: when the
    // cell loop reconciled by reference (bare <For>), table-core's fresh cell instances each
    // pull rebuilt the expander <td>/<button> (the <tr> persisted but its cells were rebuilt),
    // dropping DOM focus to <body> and breaking keyboard activation (Enter/Space on the focused
    // expander left nothing focused). Since the emitter now emits `<Key>` for the
    // `:key="cellCtx.id"` cell loop, Solid keeps the cell node on a stable key too — so the
    // expander is no longer recreated and this re-focus is now a defensive no-op on ALL six
    // targets (re-focusing the SAME kept element — the focusActiveCell imperative-refocus
    // precedent). Kept for safety; it costs nothing when the node is unchanged. The rAF defers
    // past the synchronous reactive flush so any (re)created node exists first.
    const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
    row.toggleExpanded();
    if (ownerRow && typeof requestAnimationFrame === 'function') {
      requestAnimationFrame(() => {
        const btn = ownerRow.querySelector('[data-expander]');
        if (btn) btn.focus();
      });
    }
  };
  bodyCellStyle = (row: any, colId: any) => {
    const base = this.pinStyle(colId);
    if (this.isExpanderColumn(colId) && row && row.depth) {
      // Only the expander column indents (the tree affordance lives here).
      const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
      return base ? base + pad : pad;
    }
    return base;
  };
  rowIsGrouped = (row: any) => !!(this.tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
  groupingActive = () => this.tick() >= 0 && (this.currentState().grouping || []).length > 0;
  cellIsGrouped = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
  cellIsAggregated = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
  cellIsPlaceholder = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsPlaceholder && cellCtx.getIsPlaceholder());
  groupSubRowCount = (row: any) => row && row.getLeafRows ? row.getLeafRows().length : row && row.subRows ? row.subRows.length : 0;
  groupingKeys = () => this.currentState().grouping || [];
  groupableColumns = () => {
    const out = [];
    const defs = this.columnDefs();
    for (const d of defs as any) {
      if (!d || d.groupable === false) continue;
      out.push({
        id: d.id,
        label: d.header != null ? d.header : d.id
      });
    }
    return out;
  };
  stopEvent = (evt: any) => {
    if (evt && evt.stopPropagation) evt.stopPropagation();
  };
  isAllRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsAllRowsSelected());
  isSomeRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsSomeRowsSelected());
  onToggleAllRows = (evt: any) => {
    if (!this.table) return;
    this.table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
  };
  rowIsSelected = (row: any) => {
    if (!row) return false;
    const id = row.id;
    const sel = this.currentState().rowSelection || {};
    if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
    return !!(row.getIsSelected && row.getIsSelected());
  };
  onToggleRow = (row: any, evt: any) => {
    if (!row || !row.toggleSelected) return;
    row.toggleSelected(!!(evt && evt.target && evt.target.checked));
  };
  onHideColumn = (colId: any, evt: any) => {
    if (evt && evt.stopPropagation) evt.stopPropagation();
    if (!this.table) return;
    const col = this.table.getColumn(colId);
    if (col && col.toggleVisibility) col.toggleVisibility(false);
  };
  hasAnyFilterableColumn = () => {
    const cols = this.allLeafColumns();
    for (const c of cols as any) {
      if (c && this.columnIsFilterable(c.id)) return true;
    }
    return false;
  };
  selectAllBox: any = null;
  syncIndeterminate = () => {
    if (!this.__rozieRoot()?.nativeElement || !this.__rozieRoot()!.nativeElement.querySelector) return;
    this.selectAllBox = this.__rozieRoot()!.nativeElement.querySelector('.rdt-select-all');
    if (this.selectAllBox) this.selectAllBox.indeterminate = this.isSomeRowsSelected() && !this.isAllRowsSelected();
  };
  sortColumn = (colId: any, desc: any) => {
    if (this.table) this.table.getColumn(colId) && this.table.getColumn(colId).toggleSorting(desc, false);
  };
  clearSorting = () => {
    if (this.table) this.table.resetSorting(true);
  };
  getColumnDefs = () => this.columnDefs();
  toggleAllRows = (value: any) => {
    if (this.table) this.table.toggleAllRowsSelected(value);
  };
  clearSelection = () => {
    if (this.table) this.table.resetRowSelection(true);
  };
  getSelectedRows = () => this.table ? this.table.getSelectedRowModel().rows.map((r: any) => r.original) : [];
  setPage = (idx: any) => {
    if (this.table) this.table.setPageIndex(idx);
  };
  setRowsPerPage = (size: any) => {
    if (this.table) this.table.setPageSize(size);
  };
  toggleColumnVisibility = (colId: any) => {
    if (this.table) {
      const c = this.table.getColumn(colId);
      if (c && c.toggleVisibility) c.toggleVisibility();
    }
  };
  applyColumnOrder = (order: any) => {
    if (this.table) this.table.setColumnOrder(order);
  };
  resetColumnSizing = () => {
    if (this.table) this.table.resetColumnSizing(true);
  };
  pinColumn = (colId: any, side: any) => {
    if (this.table) {
      const c = this.table.getColumn(colId);
      if (c && c.pin) c.pin(side);
    }
  };
  getRowIndexRelativeToPage = (absRow: any) => {
    const abs = absRow == null ? this.toAbsRow(this.activeRow()) : Math.trunc(Number(absRow)) || 0;
    if (this.virtual()) return abs;
    return abs - this.pageRowOffset();
  };
  cut = () => this.cutRange();
  isGrid = () => this.interactionMode() === 'grid';
  tableRole = () => this.isGrid() ? 'grid' : 'table';
  cellRole = () => this.isGrid() ? 'gridcell' : 'cell';
  rowIndexOf = (row: any) => this.tick() >= 0 ? (this.rows() || []).indexOf(row) : -1;
  colIndexOf = (row: any, cellCtx: any) => this.tick() >= 0 ? this.visibleCellsFor(row).indexOf(cellCtx) : -1;
  headerColIndexOf = (hg: any, header: any) => (hg && hg.headers ? hg.headers : []).indexOf(header);
  pageRowOffset = () => {
    if (!this.isGrid() || this.virtual()) return 0;
    return this.pageIndex() * this.pageSize();
  };
  toAbsRow = (localRow: any) => localRow + this.pageRowOffset();
  prePaginationRowCount = () => {
    if (!this.table || this.virtual()) return this.bodyRowCount();
    const pm = this.table.getPrePaginationRowModel();
    return pm && pm.rows ? pm.rows.length : this.bodyRowCount();
  };
  cellTabindex = (rowKey: any, colIndex: any, level: any = null) => {
    const __activeColIndex = this.activeColIndex();
    if (!this.isGrid()) return null;
    // B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
    // the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
    // tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
    if (this.bodyRowCount() === 0) {
      return rowKey === '__header' && colIndex === 0 && level === this.headerLeafLevel() ? 0 : -1;
    }
    // B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
    // grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
    // lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
    // tab-stops (the roving invariant broke under grouped headers).
    if (this.activeIsHeader()) {
      if (rowKey !== '__header') return -1;
      return colIndex === __activeColIndex && level === this.activeHeaderLevel() ? 0 : -1;
    }
    const isActive = rowKey === String(this.activeRow()) && colIndex === __activeColIndex;
    return isActive ? 0 : -1;
  };
  isActiveCell = (rowKey: any, colIndex: any, level: any = null) => {
    const __activeColIndex = this.activeColIndex();
    if (!this.isGrid()) return false;
    if (this.activeIsHeader()) {
      if (rowKey !== '__header') return false;
      return colIndex === __activeColIndex && level === this.activeHeaderLevel();
    }
    if (rowKey === '__header') return false;
    return rowKey === String(this.activeRow()) && colIndex === __activeColIndex;
  };
  resolveCellEl = (rowKey: any, colIndex: any, level: any = null) => {
    if (!this.gridRoot) return null;
    // B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
    // same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
    // integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
    // match (the parent) when the leaf is meant. level is an integer (NO consumer string is
    // interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
    // byte-unchanged.
    let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
    if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
    return this.gridRoot.querySelector(sel);
  };
  focusActiveCell = (nextRow: any = null, nextCol: any = null, nextIsHeader: any = null, nextLevel: any = null) => {
    if (!this.isGrid() || !this.gridRoot) return;
    // #9 focus-intent epoch: focusActiveCell is THE single seam every keyboard nav re-asserts
    // focus through, so it establishes a fresh "where focus should be" on every call — bump the
    // epoch here (BEFORE arming the virtual-scroll focusWhenReady poll below). A SUBSEQUENT
    // focusActiveCell (the next user nav) bumps again → any pending focusWhenReady captured the
    // OLD value → aborts instead of yanking focus back. The poll captures the POST-bump value so
    // a lone scroll-to-focus with no later nav still lands (epoch stable across its own frames).
    this.focusIntentEpoch = this.focusIntentEpoch + 1;
    const r = nextRow == null ? this.activeRow() : nextRow;
    const c = nextCol == null ? this.activeColIndex() : nextCol;
    // B12: thread the FRESH post-write header level (the grouped-header analog of the
    // nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
    // level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
    const lvl = nextLevel == null ? this.activeHeaderLevel() : nextLevel;
    // Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
    // crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
    // Angular's signal write are async within one handler — re-reading $data.activeIsHeader
    // here returns the PRE-write value, resolving focus to the BODY cell instead of the
    // header. Callers pass the fresh isHeader local; falls back to $data when omitted.
    const header = nextIsHeader == null ? this.activeIsHeader() : nextIsHeader;
    // ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
    // rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
    // double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
    // in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
    // The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
    // while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
    if (this.virtual() && this.virtualizer && !header && this.rowIsOutsideWindow(r)) {
      this.virtualizer.scrollToIndex(r, {
        align: 'center'
      });
      // Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
      // bump → the framework commits the scrolled-in row. On React that commit is async (setState →
      // reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
      // fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
      // React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
      // fast-committing targets resolve on the first attempt (behavior unchanged), React retries
      // across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
      // cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
      let focusAttempts = 0;
      // #9: capture the epoch AFTER this call's own bump (above) so the poll never aborts itself
      // (its captured value equals the current epoch). A LATER focusActiveCell / focusCell /
      // active-cell-moving focusin bumps the epoch → the check below aborts this stale poll.
      const myEpoch = this.focusIntentEpoch;
      const focusWhenReady = () => {
        // A newer focus intent superseded this poll — abort WITHOUT focusing (the user has since
        // navigated / clicked elsewhere; re-focusing this off-window target would yank focus back).
        if (this.focusIntentEpoch !== myEpoch) return;
        const el = this.resolveCellEl(String(r), c);
        if (el) {
          el.focus();
          return;
        }
        focusAttempts = focusAttempts + 1;
        if (focusAttempts >= 30) return;
        if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
      };
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
      return;
    }
    const rowKey = header ? '__header' : String(r);
    const el = this.resolveCellEl(rowKey, c, header ? lvl : null);
    if (el) el.focus();
  };
  totalRowCount = () => {
    const __rows = this.rows();
    if (!this.table) return (__rows || []).length;
    const fm = this.table.getFilteredRowModel();
    return fm && fm.rows ? fm.rows.length : (__rows || []).length;
  };
  headerRowCount = () => (this.headerGroups() || []).length;
  gridAriaRowCount = () => this.headerRowCount() + this.totalRowCount();
  ariaPageOffset = () => this.table ? this.pageIndex() * this.pageSize() : 0;
  bodyAriaRowIndex = (row: any) => this.headerRowCount() + this.rowIndexOf(row) + this.ariaPageOffset() + 1;
  visibleColCount = () => {
    // NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
    // state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
    // self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
    const rowList = this.rows() || [];
    if (rowList.length) return rowList[0].getVisibleCells().length;
    const hg = this.headerGroups() || [];
    return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
  };
  bodyRowCount = () => (this.rows() || []).length;
  clamp = (v: any, lo: any, hi: any) => v < lo ? lo : v > hi ? hi : v;
  headerLeafLevel = () => {
    const hg = this.headerGroups() || [];
    return hg.length ? hg.length - 1 : 0;
  };
  headerCountAtLevel = (level: any) => {
    const hg = this.headerGroups() || [];
    if (!hg.length) return this.visibleColCount();
    const grp = level >= 0 && level < hg.length ? hg[level] : null;
    if (!grp || !grp.headers) return this.visibleColCount();
    return grp.headers.length;
  };
  headerAt = (level: any, colIndex: any) => {
    const hg = this.headerGroups() || [];
    const grp = hg[level];
    if (!grp || !grp.headers) return null;
    return grp.headers[colIndex] || null;
  };
  parentHeaderColIndex = (level: any, colIndex: any) => {
    if (level <= 0) return -1;
    const h = this.headerAt(level, colIndex);
    if (!h || !h.column || !h.column.parent) return -1;
    const parentId = h.column.parent.id;
    const hg = this.headerGroups() || [];
    const pg = hg[level - 1];
    if (!pg || !pg.headers) return -1;
    for (let i = 0; i < pg.headers.length; i++) {
      const ph = pg.headers[i];
      if (ph && ph.column && ph.column.id === parentId) return i;
    }
    return -1;
  };
  firstChildHeaderColIndex = (level: any, colIndex: any) => {
    const h = this.headerAt(level, colIndex);
    if (!h || !h.column) return -1;
    const kids = h.column.columns || [];
    if (!kids.length) return -1;
    const childId = kids[0].id;
    const hg = this.headerGroups() || [];
    const cg = hg[level + 1];
    if (!cg || !cg.headers) return -1;
    for (let i = 0; i < cg.headers.length; i++) {
      const ch = cg.headers[i];
      if (ch && ch.column && ch.column.id === childId) return i;
    }
    return -1;
  };
  moveCol = (delta: any) => {
    // #10: when a grouped PARENT header is active, clamp against the header count AT THE ACTIVE
    // LEVEL (which may be fewer than the leaf-column count) so ArrowRight never overruns onto a
    // phantom cell past that level's headers. Body cells + the leaf header level keep visibleColCount().
    const count = this.activeIsHeader() ? this.headerCountAtLevel(this.activeHeaderLevel()) : this.visibleColCount();
    const max = count - 1;
    const nextCol = this.clamp(this.activeColIndex() + delta, 0, max < 0 ? 0 : max);
    this.activeColIndex.set(nextCol);
    return nextCol;
  };
  moveRow = (delta: any) => {
    const lastRow = this.bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const leafLevel = this.headerLeafLevel();
    if (this.activeIsHeader()) {
      if (delta > 0) {
        // B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
        // level down); from the LEAF header level, drop into the body (row 0). A header-level
        // move re-targets activeColIndex (parent↔child column indices differ), so the fresh
        // col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
        if (this.activeHeaderLevel() < leafLevel) {
          const childCol = this.firstChildHeaderColIndex(this.activeHeaderLevel(), this.activeColIndex());
          if (childCol >= 0) {
            const nextLevel = this.activeHeaderLevel() + 1;
            this.activeHeaderLevel.set(nextLevel);
            this.activeColIndex.set(childCol);
            return {
              row: this.activeRow(),
              col: childCol,
              isHeader: true,
              level: nextLevel
            };
          }
        }
        // At the leaf header: an empty grid has no body to drop into → stay put.
        if (this.bodyRowCount() === 0) return {
          row: this.activeRow(),
          col: this.activeColIndex(),
          isHeader: true,
          level: this.activeHeaderLevel()
        };
        // B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
        // (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
        // page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
        // (delta-1 = 0); clamped to the page-last body row.
        const landRow = this.clamp(delta - 1, 0, maxRow);
        this.activeIsHeader.set(false);
        this.activeRow.set(landRow);
        return {
          row: landRow,
          col: this.activeColIndex(),
          isHeader: false,
          level: 0
        };
      }
      // B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
      // spans the active column; at the top level (or no real parent) stay put. The parent col
      // index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
      const parentCol = this.parentHeaderColIndex(this.activeHeaderLevel(), this.activeColIndex());
      if (parentCol >= 0) {
        const nextLevel = this.activeHeaderLevel() - 1;
        this.activeHeaderLevel.set(nextLevel);
        this.activeColIndex.set(parentCol);
        return {
          row: this.activeRow(),
          col: parentCol,
          isHeader: true,
          level: nextLevel
        };
      }
      return {
        row: this.activeRow(),
        col: this.activeColIndex(),
        isHeader: true,
        level: this.activeHeaderLevel()
      };
    }
    // In the body: an upward move from row 0 crosses into the LEAF header level (the header row
    // adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
    // activeColIndex carries over unchanged.
    if (delta < 0 && this.activeRow() === 0) {
      this.activeIsHeader.set(true);
      this.activeHeaderLevel.set(leafLevel);
      return {
        row: this.activeRow(),
        col: this.activeColIndex(),
        isHeader: true,
        level: leafLevel
      };
    }
    const nextRow = this.clamp(this.activeRow() + delta, 0, maxRow);
    this.activeRow.set(nextRow);
    this.activeIsHeader.set(false);
    return {
      row: nextRow,
      col: this.activeColIndex(),
      isHeader: false,
      level: 0
    };
  };
  gotoColEdge = (toEnd: any) => {
    // #10: End on a grouped PARENT header lands on that level's LAST header (headerCountAtLevel-1),
    // not the leaf-column max — otherwise the ring strands on a phantom cell past the level's
    // headers. Home is index 0 either way. Body cells + the leaf header level keep visibleColCount().
    const count = this.activeIsHeader() ? this.headerCountAtLevel(this.activeHeaderLevel()) : this.visibleColCount();
    const max = count - 1;
    const nextCol = toEnd ? max < 0 ? 0 : max : 0;
    this.activeColIndex.set(nextCol);
    return nextCol;
  };
  gotoRowEdge = (toEnd: any) => {
    const lastRow = this.bodyRowCount() - 1;
    const nextRow = toEnd ? lastRow < 0 ? 0 : lastRow : 0;
    this.activeRow.set(nextRow);
    this.activeIsHeader.set(false);
    return nextRow;
  };
  gotoStart = () => {
    this.activeIsHeader.set(false);
    this.activeRow.set(0);
    this.activeColIndex.set(0);
    return {
      row: 0,
      col: 0
    };
  };
  gotoEnd = () => {
    const lastRow = this.bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const max = this.visibleColCount() - 1;
    const maxCol = max < 0 ? 0 : max;
    this.activeIsHeader.set(false);
    this.activeRow.set(maxRow);
    this.activeColIndex.set(maxCol);
    return {
      row: maxRow,
      col: maxCol
    };
  };
  currentCellEl = () => {
    const __activeIsHeader = this.activeIsHeader();
    const rowKey = __activeIsHeader ? '__header' : String(this.activeRow());
    return this.resolveCellEl(rowKey, this.activeColIndex(), __activeIsHeader ? this.activeHeaderLevel() : null);
  };
  focusables = (cellEl: any) => {
    if (!cellEl || !cellEl.querySelectorAll) return [];
    const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
    return list.filter((n: any) => !n.disabled);
  };
  enterControl = () => {
    const cellEl = this.currentCellEl();
    const list = this.focusables(cellEl);
    if (!list.length) return;
    this.activeInControl.set(true);
    list[0].focus();
  };
  cycleWithinCell = (cellEl: any, forward: any) => {
    const list = this.focusables(cellEl);
    if (!list.length) return;
    const active = this.gridRoot ? this.gridRoot.getRootNode().activeElement : null;
    const cur = list.indexOf(active);
    let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
    if (i >= list.length) i = 0;
    if (i < 0) i = list.length - 1;
    list[i].focus();
  };
  onGridKeyDown = (e: any) => {
    const __activeRow = this.activeRow();
    const __activeColIndex = this.activeColIndex();
    const __activeIsHeader = this.activeIsHeader();
    const __undoable = this.undoable();
    const __rows = this.rows();
    if (!this.isGrid() || !e) return;
    const key = e.key;
    // Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
    // via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
    // early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
    // modes (editing / in-control / navigation) stay mutually exclusive and ordered.
    if (this.editingRow() >= 0) return;
    // Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
    // editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
    // keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
    if (this.editingRowIndex() != null) return;
    // Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
    if (this.activeInControl()) {
      if (key === 'Escape') {
        e.preventDefault();
        this.activeInControl.set(false);
        // Return focus to the OWNING cell (no move happened) — pass the current indices
        // explicitly (the React-emitted seam types both params as required; a zero-arg call
        // is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
        this.focusActiveCell(__activeRow, __activeColIndex);
      } else if (key === 'Tab') {
        e.preventDefault();
        this.cycleWithinCell(this.currentCellEl(), !e.shiftKey);
      }
      return;
    }
    // WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
    // grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
    // user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
    // NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
    // focused node; if it is not itself a [data-grid-cell], let the event pass through.
    const tgt = e.target;
    if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
    // Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
    // nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
    // async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
    // plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
    // in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
    // fires ONLY on a real move (a clamped no-op edge move leaves them identical).
    const prevRow = __activeRow;
    const prevCol = __activeColIndex;
    const prevIsHeader = __activeIsHeader;
    const prevLevel = this.activeHeaderLevel();
    let nextRow = prevRow;
    let nextCol = prevCol;
    let nextIsHeader = prevIsHeader;
    // B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
    // threaded into the focus seam so a leaf↔parent header move lands focus at the correct
    // level. moveRow returns it; the non-vertical branches keep the pre-move level.
    let nextLevel = prevLevel;
    // ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
    // the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
    // fall through to a plain navigation move). Body cells only (no range from a header). The
    // extendRange call owns focus + the range-change emit, so return immediately. ──────────
    // ── §8 (260709-3qt) Ctrl/Cmd+Arrow — jump the active cell to the data-region edge (plain
    // Ctrl) or EXTEND the range to that edge (Ctrl+Shift). Body cells only (a header-active
    // Ctrl+Arrow falls through to the plain-arrow branches unchanged). Tested BEFORE the
    // Shift+Arrow / plain-arrow cascade so the modifier combo is matched first. preventDefault
    // suppresses the browser's native Ctrl+Arrow scroll/word-jump. The Ctrl+Shift branch owns
    // extendRange's focus + range-change emit (returns); the plain-Ctrl branch sets the fresh
    // nextRow/nextCol locals and FALLS THROUGH to the shared focus seam (like Ctrl+Home/End). ──
    if ((e.ctrlKey || e.metaKey) && e.shiftKey && !__activeIsHeader && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
      e.preventDefault();
      if (key === 'ArrowUp') this.extendRange(-__activeRow, 0);else if (key === 'ArrowDown') this.extendRange(this.bodyRowCount() - 1 - __activeRow, 0);else if (key === 'ArrowLeft') this.extendRange(0, -__activeColIndex);else this.extendRange(0, this.visibleColCount() - 1 - __activeColIndex);
      return;
    } else if ((e.ctrlKey || e.metaKey) && !__activeIsHeader && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
      e.preventDefault();
      this.clearRange();
      if (key === 'ArrowUp') {
        nextRow = this.gotoRowEdge(false);
        nextIsHeader = false;
      } else if (key === 'ArrowDown') {
        nextRow = this.gotoRowEdge(true);
        nextIsHeader = false;
      } else if (key === 'ArrowLeft') {
        nextCol = this.gotoColEdge(false);
      } else {
        nextCol = this.gotoColEdge(true);
      }
    } else if (key === 'ArrowRight' && e.shiftKey && !__activeIsHeader) {
      e.preventDefault();
      this.extendRange(0, 1);
      return;
    } else if (key === 'ArrowLeft' && e.shiftKey && !__activeIsHeader) {
      e.preventDefault();
      this.extendRange(0, -1);
      return;
    } else if (key === 'ArrowDown' && e.shiftKey && !__activeIsHeader) {
      e.preventDefault();
      this.extendRange(1, 0);
      return;
    } else if (key === 'ArrowUp' && e.shiftKey && !__activeIsHeader) {
      e.preventDefault();
      this.extendRange(-1, 0);
      return;
    } else if (key === 'ArrowRight') {
      e.preventDefault();
      this.clearRange();
      nextCol = this.moveCol(1);
    } else if (key === 'ArrowLeft') {
      e.preventDefault();
      this.clearRange();
      nextCol = this.moveCol(-1);
    } else if (key === 'ArrowDown') {
      e.preventDefault();
      this.clearRange();
      const m = this.moveRow(1);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'ArrowUp') {
      e.preventDefault();
      this.clearRange();
      const m = this.moveRow(-1);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'PageDown') {
      e.preventDefault();
      const m = this.moveRow(this.GRID_PAGE_STEP);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'PageUp') {
      e.preventDefault();
      const m = this.moveRow(-this.GRID_PAGE_STEP);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'Home') {
      e.preventDefault();
      if (e.ctrlKey || e.metaKey) {
        const s = this.gotoStart();
        nextRow = s.row;
        nextCol = s.col;
        nextIsHeader = false;
      } else {
        nextCol = this.gotoColEdge(false);
      }
    } else if (key === 'End') {
      e.preventDefault();
      if (e.ctrlKey || e.metaKey) {
        const en = this.gotoEnd();
        nextRow = en.row;
        nextCol = en.col;
        nextIsHeader = false;
      } else {
        nextCol = this.gotoColEdge(true);
      }
    }
    // ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
    // pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
    // edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
    // type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
    // gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
    // falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
    // (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
    else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
      e.preventDefault();
      this.copyRange();
      return;
    } else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
      e.preventDefault();
      this.pasteRange();
      return;
    }
    // ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
    // source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
    // Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
    // and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
    // shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
    else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
      e.preventDefault();
      this.cutRange();
      return;
    }
    // ── 260709-8ct (grid-wide undo/redo) — Ctrl/Cmd+Z undoes; Ctrl/Cmd+Y OR Ctrl/Cmd+Shift+Z
    // redoes. Undoable-gated (`$props.undoable`) — when off, neither preventDefault nor
    // undo()/redo() runs, so a shipped grid with undoable unset is byte-behaviorally unchanged
    // (the browser's own native undo/redo, if any, still fires). NOT clipboardActiveAllowed-
    // gated (unlike Ctrl+C/V/X/Delete above): undo/redo is GRID-WIDE and must work regardless of
    // whether a header or body cell is active. Tested the Ctrl+Shift+Z (redo) combo BEFORE the
    // plain Ctrl+Z (undo) branch so a Shift+Z never falls into undo.
    else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey) && e.shiftKey) {
      if (__undoable) {
        e.preventDefault();
        this.redo();
        return;
      }
    } else if ((key === 'y' || key === 'Y') && (e.ctrlKey || e.metaKey)) {
      if (__undoable) {
        e.preventDefault();
        this.redo();
        return;
      }
    } else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey)) {
      if (__undoable) {
        e.preventDefault();
        this.undo();
        return;
      }
    }
    // ── §7 (260709-3qt) — Delete/Backspace CLEARS the active cell / range through the SAME
    // write-funnel as Cut (applyGridToRange of an empty grid), MINUS the clipboard copy. B11-gated
    // by clipboardActiveAllowed so a header-active Delete/Backspace falls through to NATIVE behavior
    // (never a silent body mutation). The top-of-handler editing early-returns + the line-39
    // data-grid-cell guard keep this to navigation mode; applyGridToRange skips read-only/non-editable
    // cells. Reversible via Ctrl+Z when `undoable` is on (260709-8ct) — clearActiveRange funnels
    // through the SAME writeData seam undo/redo replay through, so no separate inverse machinery
    // is needed here.
    else if ((key === 'Delete' || key === 'Backspace') && this.clipboardActiveAllowed()) {
      e.preventDefault();
      this.clearActiveRange();
      return;
    }
    // ── §8 (260709-3qt) — Ctrl/Cmd+A selects the WHOLE BODY range (drives the same range corners
    // shift+arrow uses). preventDefault ALWAYS so the page is never selected in grid mode; only a
    // body-active Ctrl+A builds the range (a header-active Ctrl+A is a no-op — selects nothing). ──
    else if ((key === 'a' || key === 'A') && (e.ctrlKey || e.metaKey)) {
      e.preventDefault();
      if (!__activeIsHeader) this.selectAllBody();
      return;
    }
    // ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
    // EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
    // (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
    // collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
    // at least the active editable column); a non-editable active cell falls through unchanged.
    else if (key === 'F2' && e.shiftKey && this.isActiveCellEditable()) {
      e.preventDefault();
      this.beginRowEdit((__rows || [])[__activeRow]);
      return;
    }
    // ── Boolean in-place toggle (design doc 2026-07-05, Change 1) — a built-in
    // editor:'checkbox' cell toggles + commits INSTANTLY on Space/Enter/F2, no editor opens
    // (the spreadsheet-standard shape for a two-state value). Tested BEFORE the generic
    // Enter/F2 edit-entry branch below (a checkbox cell must never fall into the open-an-
    // editor ceremony) and gated the SAME way (isActiveCellEditable) plus editorTypeOf ===
    // 'checkbox'. Full-row edit mode is unaffected — the editingRowIndex early return at the
    // top of onGridKeyDown already excludes it.
    else if ((key === 'Enter' || key === 'F2' || key === ' ') && this.isActiveCellEditable() && this.editorTypeOf(this.activeCellColumnId()) === 'checkbox') {
      e.preventDefault();
      this.toggleActiveBooleanCell();
      return;
    }
    // ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
    // Gated by isActiveCellEditable(): a non-editable active cell falls through to
    // enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
    // (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
    else if ((key === 'Enter' || key === 'F2') && this.isActiveCellEditable()) {
      e.preventDefault();
      this.beginEdit(__activeRow, __activeColIndex, null);
      return;
    } else if (this.isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && this.editorTypeOf(this.activeCellColumnId()) !== 'checkbox') {
      // B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
      // checkbox/select/date editor must NOT take the typed char as its value (it would
      // force-check the checkbox, seed a garbage select option, or corrupt the date) — open
      // those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
      // Checkbox is excluded entirely (type-to-edit disabled — the branch above already
      // handles Space/Enter/F2; any OTHER printable key on a checkbox cell is a no-op).
      e.preventDefault();
      const editType = this.editorTypeOf(this.activeCellColumnId());
      const seed = editType === 'text' || editType === 'number' ? key : null;
      this.beginEdit(__activeRow, __activeColIndex, seed);
      return;
    }
    // ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
    // expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
    // verified invariant) so it never hits the edit branches above and would otherwise fall to
    // enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
    // Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
    // model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
    // ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
    // full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
    else if (key === 'Enter' && !__activeIsHeader && this.rowIsGrouped((__rows || [])[__activeRow])) {
      e.preventDefault();
      // C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
      // active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
      // the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
      // row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
      // fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
      // DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
      // while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
      // settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
      // onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
      // SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
      // until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
      // already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
      const grpRow = __activeRow;
      const grpCol = __activeColIndex;
      this.onToggleExpand((__rows || [])[__activeRow], e);
      // guardMoved=true: the group header row is UNCHANGED by its own collapse, so a stale late
      // rAF poll must not steal focus back after the user has already ArrowDown'd to another row.
      this.recoverGridFocus(String(grpRow), grpCol, null, true);
      return;
    } else if (key === 'Enter' || key === 'F2') {
      e.preventDefault();
      this.enterControl();
      return;
    } else return;
    // THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
    // focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
    this.focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
    // WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
    // changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
    // row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
    // B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
    // C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
    // keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
    // toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
    // The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
    if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
      // Mirror getActiveCell's shape (this payload + getActiveCell are documented to speak the
      // SAME language): a header cell has no body-row index, so emit rowIndex:null + isHeader:true
      // rather than a bogus toAbsRow(nextRow) — which would compute a real body-row absolute index
      // for a HEADER move, misleading a consumer into thinking that body row is the active cell.
      this.activecellChange.emit(nextIsHeader ? {
        rowIndex: null,
        colIndex: nextCol,
        isHeader: true
      } : {
        rowIndex: this.toAbsRow(nextRow),
        colIndex: nextCol,
        isHeader: false
      });
    }
  };
  syncActiveFromEvent = (e: any) => {
    if (!this.isGrid() || !e) return;
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null) return;
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(col)) return;
    // #9: snapshot the PRE-write active position so we can bump the focus-intent epoch ONLY when
    // this focusin genuinely MOVES the active cell (a click landing on a NEW cell). A no-op focusin
    // — focus arriving on the ALREADY-active cell, e.g. a scroll/page-switch poll's own el.focus()
    // or focusActiveCell's synchronous re-seat — must NOT bump, or it would abort a legitimate
    // in-flight recovery on its own settling frames (the poll would see a changed epoch and quit).
    const prevIsHeader = this.activeIsHeader();
    const prevRow = this.activeRow();
    const prevCol = this.activeColIndex();
    const prevLevel = this.activeHeaderLevel();
    const isHeader = rowAttr === '__header';
    this.activeIsHeader.set(isHeader);
    let movedRow = prevRow;
    let movedLevel = prevLevel;
    if (isHeader) {
      // B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
      // roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
      // one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
      const lvlAttr = cellEl.getAttribute('data-header-level');
      const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : this.headerLeafLevel();
      movedLevel = Number.isFinite(lvl) ? lvl : this.headerLeafLevel();
      this.activeHeaderLevel.set(movedLevel);
    } else {
      const row = parseInt(rowAttr, 10);
      if (Number.isFinite(row)) {
        movedRow = row;
        this.activeRow.set(row);
      }
    }
    this.activeColIndex.set(col);
    // #9: a genuine active-cell MOVE is a fresh focus intent — supersede any pending async focus
    // poll (scroll-to / page-switch). Compare against the PRE-write snapshot: bump only when the
    // header-flag, column, or (per mode) the header LEVEL / body ROW actually changed.
    if (isHeader !== prevIsHeader || col !== prevCol || (isHeader ? movedLevel !== prevLevel : movedRow !== prevRow)) {
      this.focusIntentEpoch = this.focusIntentEpoch + 1;
    }
    // A plain focus collapses any range back to the single active cell — EXCEPT (a) the
    // programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
    // ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
    // focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
    // BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
    // path owns the shift case and flags it here so the collapse is skipped.
    if (this.rangeTransition) {
      this.rangeTransition = false;
    } else if (this.rangeClickPending) {
      this.rangeClickPending = false;
    } else {
      this.clearRange();
    }
    // The cell box (not an inner control) receiving focus = navigation mode.
    if (tgt === cellEl) this.activeInControl.set(false);
  };
  onGridMouseDown = (e: any) => {
    if (!this.isGrid() || !e) return;
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    // §6: a plain mousedown inside the fill handle is owned by the handle's own pointerdown drag —
    // never begin a range paint from it (the shift path never lands on the 8px handle).
    if (!e.shiftKey && tgt.closest('[data-fill-handle]')) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
    const row = parseInt(rowAttr, 10);
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(row) || !Number.isFinite(col)) return;
    if (e.shiftKey) {
      // Shift+Click: set the moving corner (keeping the anchor) and flag rangeClickPending so the
      // follow-up focusin does not collapse the range (a focusin carries no reliable shiftKey).
      this.setRangeFocus(row, col);
      this.activeIsHeader.set(false);
      this.activeRow.set(row);
      this.activeColIndex.set(col);
      this.rangeClickPending = true;
      return;
    }
    // §6 plain mousedown → begin a document-level drag-select anchored at this cell. The mousedown's
    // native focusin commits the ACTIVE cell to (row,col); beginRangeDrag's first cross-cell
    // pointermove paints the range via setRangeFocus (anchored at the active cell). A mousedown with
    // no move collapses to a single active cell (no range).
    this.beginRangeDrag(row, col);
  };
  onGridDblClick = (e: any) => {
    if (!this.isGrid() || !e) return;
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
    const row = parseInt(rowAttr, 10);
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(row) || !Number.isFinite(col)) return;
    // NB the local is `rowObj` (NOT `activeRow`): $data.activeRow lowers to the bare React state
    // binding `activeRow`, so a `const activeRow = …` local self-shadows it (TS2448 TDZ — the
    // visibleColCount `rowList` self-shadow class). ($data.rows || [])[row] is the active flattened
    // row (page-relative non-virtual / full-model virtual — both index $data.rows, matching the C2
    // Enter-on-group path + syncActiveFromEvent's row parse).
    const rowObj = (this.rows() || [])[row];
    if (this.rowIsGrouped(rowObj)) {
      // Group-header cell → toggle its collapse/expand through the SAME onToggleExpand funnel the
      // chevron uses (mirrors the C2 Enter-on-group path verbatim), then re-seat focus after the
      // re-render (guardMoved=true — the group-header row is unchanged by its own collapse, so a
      // stale late rAF must not steal focus back after a subsequent nav).
      e.preventDefault();
      this.onToggleExpand(rowObj, e);
      this.recoverGridFocus(String(row), col, null, true);
      return;
    }
    // Editable body cell → open its editor (seed=null → seed the EXISTING value, the in-place F2/
    // Enter entry). A non-editable body cell is a no-op: the cell stays active (focusin already set
    // it + the §1 ring), matching the spreadsheet display-vs-edit convention.
    const colId = this.columnIdAt(row, col);
    if (colId != null && this.columnEditable(colId)) {
      e.preventDefault();
      this.beginEdit(row, col, null);
    }
  };
  onGridClick = (e: any) => {
    if (!this.isGrid() || !e) return;
    if (!this.singleClickEdit()) return;
    if (e.shiftKey) return;
    // §6 (260709-3qt): a drag-select that MOVED must never open the editor — the editor opens only
    // on a genuine mouseup-no-drag click. beginRangeDrag resets rangeDragMoved=false per gesture, so
    // the flag is always fresh; consume it here so a subsequent plain click still edits.
    if (this.rangeDragMoved) {
      this.rangeDragMoved = false;
      return;
    }
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
    const row = parseInt(rowAttr, 10);
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(row) || !Number.isFinite(col)) return;
    // Already editing THIS exact cell → no-op (a click inside an open editor must not re-open it).
    if (this.editingRow() === row && this.editingCol() === col) return;
    const colId = this.columnIdAt(row, col);
    if (colId != null && this.columnEditable(colId)) this.beginEdit(row, col, null);
  };
  onGridFocusOut = (e: any) => {
    if (!this.isGrid() || !this.activeInControl()) return;
    const next = e ? e.relatedTarget : null;
    const cellEl = this.currentCellEl();
    if (!cellEl || !next || !cellEl.contains(next)) this.activeInControl.set(false);
  };
  recoverGridFocus = (rowKey: any, col: any, level: any, guardMoved: any = false) => {
    if (!this.gridRoot) return;
    let attempts = 0;
    const tryFocus = () => {
      if (guardMoved) {
        const ae = this.gridRoot && this.gridRoot.getRootNode ? this.gridRoot.getRootNode().activeElement : null;
        const aeCell = ae && ae.closest ? ae.closest('[data-grid-cell]') : null;
        if (aeCell && this.gridRoot.contains(aeCell)) {
          const aeRow = aeCell.getAttribute('data-row');
          if (aeRow != null && aeRow !== rowKey) return;
        }
      }
      const el = this.resolveCellEl(rowKey, col, level);
      if (el) {
        el.focus();
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  };
  clampActiveCell = (rowCount: any, colCount: any) => {
    if (!this.isGrid()) return;
    // B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
    // just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
    // async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
    // and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
    // out of bounds on React only). Falls back to the live helpers when called without bounds.
    const colN = colCount != null ? colCount : this.visibleColCount();
    const rowN = rowCount != null ? rowCount : this.bodyRowCount();
    // B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
    // shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
    // framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
    // — true on all six, incl React's async setState), so the doomed cell + its focus are still
    // observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
    // programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
    // Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
    // doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
    // The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
    // NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
    // shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
    // DOM coords are always fresh.
    let recoverFocus = false;
    let doomedRow = -1;
    let doomedCol = 0;
    if (this.gridRoot) {
      const rootNode = this.gridRoot.getRootNode ? this.gridRoot.getRootNode() : null;
      const focusedEl = rootNode ? rootNode.activeElement : null;
      const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
      if (focusedCell && this.gridRoot.contains(focusedCell)) {
        const fRowAttr = focusedCell.getAttribute('data-row');
        const fColAttr = focusedCell.getAttribute('data-col-index');
        if (fRowAttr != null && fRowAttr !== '__header') {
          const fr = parseInt(fRowAttr, 10);
          const fc = parseInt(fColAttr, 10);
          if (Number.isFinite(fr) && fr > rowN - 1) {
            recoverFocus = true;
            doomedRow = fr;
            doomedCol = Number.isFinite(fc) ? fc : 0;
          }
        }
      }
    }
    const maxCol = colN - 1;
    const col = this.clamp(this.activeColIndex(), 0, maxCol < 0 ? 0 : maxCol);
    if (col !== this.activeColIndex()) this.activeColIndex.set(col);
    // B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
    // cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
    // an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
    // re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
    if (rowN <= 0) {
      this.activeIsHeader.set(true);
      this.activeHeaderLevel.set(this.headerLeafLevel());
      this.activeColIndex.set(0);
      // B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
      // reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
      // async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
      // class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
      // recovery branch below actually runs on React too.
      this.gridEmptyFallback = true;
      this.clampRange(rowN - 1, colN - 1);
      // B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
      // reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
      // Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
      // (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
      // the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
      return;
    }
    // B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
    // re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
    // cell. A user-driven header position (not the empty fallback) is left untouched.
    if (this.gridEmptyFallback) {
      this.gridEmptyFallback = false;
      this.activeIsHeader.set(false);
      this.activeRow.set(0);
    }
    if (!this.activeIsHeader()) {
      const lastRow = rowN - 1;
      const maxRow = lastRow < 0 ? 0 : lastRow;
      const row = this.clamp(this.activeRow(), 0, maxRow);
      if (row !== this.activeRow()) this.activeRow.set(row);
    }
    // B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
    // shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
    // out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
    this.clampRange(rowN - 1, colN - 1);
    // B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
    // when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
    // into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
    if (recoverFocus) {
      const recRow = this.clamp(doomedRow, 0, rowN - 1);
      const recCol = this.clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
      this.recoverGridFocus(String(recRow), recCol, null);
    }
  };
  gridEmptyFallback = false;
  rangeTransition = false;
  rangeClickPending = false;
  rangeActive = false;
  inRange = (rIdx: any, cIdx: any) => {
    const a = this.rangeAnchor();
    const f = this.rangeFocus();
    if (!a || !f) return false;
    const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
    const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
    const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
    const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
    return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
  };
  getSelectedRange = () => {
    // B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
    // payload) never reports a corner past a shrunken model — React-stale-safe (the eager
    // refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
    const a = this.rangeAnchor();
    const f = this.rangeFocus();
    if (!a && !f) return {
      anchor: null,
      focus: null
    };
    const maxRow = this.bodyRowCount() - 1;
    const maxCol = this.visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return {
      anchor: null,
      focus: null
    };
    const clampCorner = (c: any) => c == null ? null : {
      rowIndex: this.clamp(c.rowIndex, 0, maxRow),
      colIndex: this.clamp(c.colIndex, 0, maxCol)
    };
    return {
      anchor: clampCorner(a),
      focus: clampCorner(f)
    };
  };
  isFillHandleCell = (rIdx: any, cIdx: any) => {
    const a = this.rangeAnchor();
    const f = this.rangeFocus();
    if (!a || !f) return false;
    const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
    const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
    return rIdx === r1 && cIdx === c1;
  };
  emitRangeChange = (anchor: any, focus: any) => {
    this.rangeChange.emit({
      anchor,
      focus
    });
  };
  extendRange = (dRow: any, dCol: any) => {
    if (this.activeIsHeader()) return;
    const maxRow = this.bodyRowCount() - 1;
    const maxCol = this.visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return;
    // Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
    let anchor = this.rangeAnchor();
    let focus = this.rangeFocus();
    const hadRange = !!(anchor && focus);
    if (!anchor || !focus) {
      anchor = {
        rowIndex: this.activeRow(),
        colIndex: this.activeColIndex()
      };
      focus = {
        rowIndex: this.activeRow(),
        colIndex: this.activeColIndex()
      };
    }
    const nextRow = this.clamp(focus.rowIndex + dRow, 0, maxRow);
    const nextCol = this.clamp(focus.colIndex + dCol, 0, maxCol);
    const nextFocus = {
      rowIndex: nextRow,
      colIndex: nextCol
    };
    this.rangeAnchor.set(anchor);
    this.rangeFocus.set(nextFocus);
    this.rangeActive = true;
    // Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
    // from the range's leading edge, the spreadsheet convention).
    this.activeRow.set(nextRow);
    this.activeColIndex.set(nextCol);
    // Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
    // settle on the new focus corner is part of THIS range extension, not a fresh navigation.
    this.rangeTransition = true;
    this.focusActiveCell(nextRow, nextCol, false);
    // B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
    // and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
    // change → no emit. Seeding a brand-new range (no prior range) is always a change (the
    // rectangle came into existence) even if its first corner is a degenerate 1×1.
    if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
      this.emitRangeChange(anchor, nextFocus);
    }
  };
  setRangeFocus = (rIdx: any, cIdx: any) => {
    const maxRow = this.bodyRowCount() - 1;
    const maxCol = this.visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return;
    let anchor = this.rangeAnchor();
    if (!anchor) anchor = {
      rowIndex: this.activeRow(),
      colIndex: this.activeColIndex()
    };
    const r = this.clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
    const c = this.clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
    const nextFocus = {
      rowIndex: r,
      colIndex: c
    };
    this.rangeAnchor.set(anchor);
    this.rangeFocus.set(nextFocus);
    this.rangeActive = true;
    this.emitRangeChange(anchor, nextFocus);
  };
  selectAllBody = () => {
    const maxRow = this.bodyRowCount() - 1;
    const maxCol = this.visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return;
    const anchor = {
      rowIndex: 0,
      colIndex: 0
    };
    const focus = {
      rowIndex: maxRow,
      colIndex: maxCol
    };
    this.rangeAnchor.set(anchor);
    this.rangeFocus.set(focus);
    this.rangeActive = true;
    this.emitRangeChange(anchor, focus);
  };
  clearRange = () => {
    // B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
    // in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
    // move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
    // would let the SECOND call through and emit a duplicate range-change. rangeActive flips
    // synchronously → the second call returns here.
    if (!this.rangeActive) return;
    this.rangeActive = false;
    this.rangeAnchor.set(null);
    this.rangeFocus.set(null);
    this.emitRangeChange(null, null);
  };
  clampRange = (maxRowArg: any, maxColArg: any) => {
    const a = this.rangeAnchor();
    const f = this.rangeFocus();
    if (!a && !f) return;
    // Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
    // shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
    const maxRow = maxRowArg != null ? maxRowArg : this.bodyRowCount() - 1;
    const maxCol = maxColArg != null ? maxColArg : this.visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) {
      this.rangeAnchor.set(null);
      this.rangeFocus.set(null);
      this.rangeActive = false;
      return;
    }
    if (a) {
      const ar = this.clamp(a.rowIndex, 0, maxRow);
      const ac = this.clamp(a.colIndex, 0, maxCol);
      if (ar !== a.rowIndex || ac !== a.colIndex) this.rangeAnchor.set({
        rowIndex: ar,
        colIndex: ac
      });
    }
    if (f) {
      const fr = this.clamp(f.rowIndex, 0, maxRow);
      const fc = this.clamp(f.colIndex, 0, maxCol);
      if (fr !== f.rowIndex || fc !== f.colIndex) this.rangeFocus.set({
        rowIndex: fr,
        colIndex: fc
      });
    }
  };
  announce = (msg: any) => {
    this.pasteAnnounce.set(msg != null ? msg : '');
  };
  clipboardActiveAllowed = () => !this.activeIsHeader();
  fieldOfColId = (colId: any) => {
    const d = this.defFor(colId);
    return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
  };
  normalizedRange = () => {
    const a = this.rangeAnchor();
    const f = this.rangeFocus();
    if (!a || !f) return null;
    const maxRow = this.bodyRowCount() - 1;
    const maxCol = this.visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return null;
    const ar = this.clamp(a.rowIndex, 0, maxRow);
    const ac = this.clamp(a.colIndex, 0, maxCol);
    const fr = this.clamp(f.rowIndex, 0, maxRow);
    const fc = this.clamp(f.colIndex, 0, maxCol);
    return {
      r0: ar < fr ? ar : fr,
      r1: ar > fr ? ar : fr,
      c0: ac < fc ? ac : fc,
      c1: ac > fc ? ac : fc
    };
  };
  escapeTsvField = (s: any) => {
    if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
      return '"' + s.replace(/"/g, '""') + '"';
    }
    return s;
  };
  rangeToTsv = () => {
    const __activeRow = this.activeRow();
    const __activeColIndex = this.activeColIndex();
    const box = this.normalizedRange();
    const r0 = box ? box.r0 : __activeRow;
    const r1 = box ? box.r1 : __activeRow;
    const c0 = box ? box.c0 : __activeColIndex;
    const c1 = box ? box.c1 : __activeColIndex;
    const lines = [];
    for (let r = r0; r <= r1; r++) {
      const cells = [];
      for (let c = c0; c <= c1; c++) {
        const v = this.cellValueAt(r, c);
        cells.push(this.escapeTsvField(v == null ? '' : String(v)));
      }
      lines.push(cells.join('\t'));
    }
    return lines.join('\n');
  };
  parseTsv = (text: any) => {
    const str = text != null ? String(text) : '';
    // CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
    // large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
    // single-pass scan allocates a cell-per-character grid.
    if (str === '' || str.length > 2000000) return [];
    // B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
    // cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
    // newlines, and doubled quotes ("") inside it are literal content until the closing quote;
    // an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
    const rows = [];
    let row = [];
    let field = '';
    let inQuotes = false;
    let i = 0;
    const n = str.length;
    while (i < n) {
      const ch = str[i];
      if (inQuotes) {
        if (ch === '"') {
          if (i + 1 < n && str[i + 1] === '"') {
            field = field + '"';
            i = i + 2;
            continue;
          }
          inQuotes = false;
          i = i + 1;
          continue;
        }
        field = field + ch;
        i = i + 1;
        continue;
      }
      if (ch === '"' && field === '') {
        inQuotes = true;
        i = i + 1;
        continue;
      }
      if (ch === '\t') {
        row.push(field);
        field = '';
        i = i + 1;
        continue;
      }
      if (ch === '\r') {
        if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
        row.push(field);
        field = '';
        rows.push(row);
        row = [];
        i = i + 1;
        continue;
      }
      if (ch === '\n') {
        row.push(field);
        field = '';
        rows.push(row);
        row = [];
        i = i + 1;
        continue;
      }
      field = field + ch;
      i = i + 1;
    }
    // Flush the trailing field + row.
    row.push(field);
    rows.push(row);
    // Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
    if (rows.length > 1) {
      const last = rows[rows.length - 1];
      if (last.length === 1 && last[0] === '') rows.pop();
    }
    return rows;
  };
  copyRange = () => {
    // B11: never copy from a header-active state (the reusable clipboard guard).
    if (!this.clipboardActiveAllowed()) return;
    if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
    try {
      const p = navigator.clipboard.writeText(this.rangeToTsv());
      if (p && p.catch) p.catch(() => {});
    } catch (err: any) {/* best-effort copy */}
  };
  applyGridToRange = (grid: any, originRow: any, originCol: any) => {
    const maxRow = this.bodyRowCount() - 1;
    const maxCol = this.visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return {
      wrote: 0,
      total: 0
    };
    let total = 0;
    let wrote = 0;
    const committed = [];
    // Build the fresh data array incrementally so the whole paste is ONE writeData.
    let next = this.currentData();
    for (let gr = 0; gr < grid.length; gr++) {
      const r = originRow + gr;
      if (r > maxRow) break;
      const cols = grid[gr] || [];
      for (let gc = 0; gc < cols.length; gc++) {
        const c = originCol + gc;
        if (c > maxCol) break;
        total = total + 1;
        const colId = this.columnIdAt(r, c);
        if (colId == null || !this.columnEditable(colId)) continue;
        const rowObj = this.rowOriginalAt(r);
        // B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
        // single-cell commit coercion) — a numeric column commits a real Number, an empty cell
        // commits null; every other editor type passes through verbatim. No mixed/garbage types
        // ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
        const value = this.coerceCellValue(colId, cols[gc]);
        // T-51-01: validate the pasted value as plain DATA before any write.
        if (this.runValidator(colId, value, rowObj) !== true) continue;
        const field = this.fieldOfColId(colId);
        const srcIndex = this.sourceIndexOfRow(r);
        const oldValue = rowObj ? rowObj[field] : null;
        next = this.replaceRowValue(next, srcIndex, field, value);
        committed.push({
          rowId: this.rowIdAt(r),
          columnId: colId,
          oldValue,
          newValue: value
        });
        wrote = wrote + 1;
      }
    }
    if (wrote > 0) {
      this.editTransition = true;
      this.writeData(next);
      this.editTransition = false;
      // One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
      for (let i = 0; i < committed.length; i++) this.cellEditCommit.emit(committed[i]);
    }
    // WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
    // targeted real cells but every one was skipped (validation-failed / non-editable), announce a
    // distinct validation-failed message instead of a misleading "0 of M cells pasted".
    if (wrote > 0) this.announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) this.announce('No cells pasted — ' + total + ' cells were invalid or read-only');
    return {
      wrote,
      total
    };
  };
  rowOriginalAt = (rowIndex: any) => {
    const rowList = this.rows() || [];
    const row = rowList[rowIndex];
    return row ? row.original : null;
  };
  rowIdAt = (rowIndex: any) => {
    const rowList = this.rows() || [];
    const row = rowList[rowIndex];
    return row ? row.id : null;
  };
  tileGridToBox = (grid: any, box: any) => {
    const srcRows = grid.length;
    // srcCols is the MAX row width across ALL rows (not grid[0].length): a RAGGED clipboard
    // (a later row WIDER than the first, e.g. TSV "a\tb\nc\td\te") would otherwise never read
    // the extra column and silently drop those cells. A row SHORTER than srcCols tiles its
    // missing cells as '' (the `v != null ? v : ''` coercion below), never undefined.
    let srcCols = 0;
    for (let i = 0; i < srcRows; i++) {
      const w = grid[i] && grid[i].length ? grid[i].length : 0;
      if (w > srcCols) srcCols = w;
    }
    if (srcRows <= 0 || srcCols <= 0) return grid;
    const boxRows = box.r1 - box.r0 + 1;
    const boxCols = box.c1 - box.c0 + 1;
    const rows = boxRows > srcRows ? boxRows : srcRows;
    const cols = boxCols > srcCols ? boxCols : srcCols;
    const out = [];
    for (let r = 0; r < rows; r++) {
      const srcLine = grid[r % srcRows] || [];
      const line = [];
      for (let c = 0; c < cols; c++) {
        const v = srcLine[c % srcCols];
        line.push(v != null ? v : '');
      }
      out.push(line);
    }
    return out;
  };
  pasteRange = () => {
    // B11: never paste into a header-active state (the reusable clipboard guard) — a header
    // anchor would silently write body row 0 at the header's column.
    if (!this.clipboardActiveAllowed()) return;
    if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
    // CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
    // C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
    // single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
    // on React; re-reading them inside the async .then() returns the mount-render stale value, so a
    // selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
    // the box + anchor now and pass them into tileGridToBox / applyGridToRange.
    const box = this.normalizedRange();
    const anchorRow = box ? box.r0 : this.activeRow();
    const anchorCol = box ? box.c0 : this.activeColIndex();
    const destBox = box || {
      r0: anchorRow,
      r1: anchorRow,
      c0: anchorCol,
      c1: anchorCol
    };
    let p: any = null;
    try {
      p = navigator.clipboard.readText();
    } catch (err: any) {
      return;
    }
    if (!p || !p.then) return;
    p.then((text: any) => {
      const grid = this.parseTsv(text);
      if (!grid.length) return;
      // C3: tile the clipboard block to fill the destination range (single→range fill,
      // smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
      const tiled = this.tileGridToBox(grid, destBox);
      this.applyGridToRange(tiled, anchorRow, anchorCol);
    }).catch(() => {});
  };
  cutRange = () => {
    const __activeRow = this.activeRow();
    const __activeColIndex = this.activeColIndex();
    if (!this.clipboardActiveAllowed()) return;
    // Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
    const box = this.normalizedRange();
    const r0 = box ? box.r0 : __activeRow;
    const r1 = box ? box.r1 : __activeRow;
    const c0 = box ? box.c0 : __activeColIndex;
    const c1 = box ? box.c1 : __activeColIndex;
    // Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
    if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
      try {
        const cp = navigator.clipboard.writeText(this.rangeToTsv());
        if (cp && cp.catch) cp.catch(() => {});
      } catch (err: any) {/* best-effort copy */}
    }
    // Clear the source: a grid of empty strings sized to the range, applied at the top-left.
    const grid = [];
    for (let r = r0; r <= r1; r++) {
      const cols = [];
      for (let c = c0; c <= c1; c++) cols.push('');
      grid.push(cols);
    }
    this.applyGridToRange(grid, r0, c0);
  };
  clearActiveRange = () => {
    const __activeRow = this.activeRow();
    const __activeColIndex = this.activeColIndex();
    if (!this.clipboardActiveAllowed()) return;
    // Snapshot the source rectangle synchronously (the ROZ138 concern cutRange/pasteRange share).
    const box = this.normalizedRange();
    const r0 = box ? box.r0 : __activeRow;
    const r1 = box ? box.r1 : __activeRow;
    const c0 = box ? box.c0 : __activeColIndex;
    const c1 = box ? box.c1 : __activeColIndex;
    const grid = [];
    for (let r = r0; r <= r1; r++) {
      const cols = [];
      for (let c = c0; c <= c1; c++) cols.push('');
      grid.push(cols);
    }
    this.applyGridToRange(grid, r0, c0);
  };
  tileIndex = (i: any, lo: any, hi: any) => {
    const span = hi - lo + 1;
    if (span <= 1) return lo;
    let k = (i - lo) % span;
    if (k < 0) k = k + span;
    return lo + k;
  };
  fillRange = (sourceBox: any, endCell: any) => {
    // B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
    // the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
    // React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
    // grows), so deriving the box from the threaded endpoints is what makes the fill cover the
    // dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
    let box;
    if (sourceBox && sourceBox.r0 != null && endCell) {
      let r0 = sourceBox.r0;
      let r1 = sourceBox.r1;
      let c0 = sourceBox.c0;
      let c1 = sourceBox.c1;
      if (endCell.r < r0) r0 = endCell.r;
      if (endCell.r > r1) r1 = endCell.r;
      if (endCell.c < c0) c0 = endCell.c;
      if (endCell.c > c1) c1 = endCell.c;
      box = {
        r0,
        r1,
        c0,
        c1
      };
    } else {
      box = this.normalizedRange();
    }
    if (!box) return;
    const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
      r0: box.r0,
      r1: box.r0,
      c0: box.c0,
      c1: box.c0
    };
    const grid = [];
    for (let r = box.r0; r <= box.r1; r++) {
      const cols = [];
      for (let c = box.c0; c <= box.c1; c++) {
        const sr = this.tileIndex(r, src.r0, src.r1);
        const sc = this.tileIndex(c, src.c0, src.c1);
        const v = this.cellValueAt(sr, sc);
        cols.push(v == null ? '' : String(v));
      }
      grid.push(cols);
    }
    this.applyGridToRange(grid, box.r0, box.c0);
  };
  fillDragging = false;
  fillDragMove: any = null;
  fillDragUp: any = null;
  teardownFillDrag = () => {
    if (typeof document !== 'undefined') {
      if (this.fillDragMove) document.removeEventListener('pointermove', this.fillDragMove);
      if (this.fillDragUp) document.removeEventListener('pointerup', this.fillDragUp);
    }
    this.fillDragMove = null;
    this.fillDragUp = null;
    this.fillDragging = false;
  };
  cellIndexFromPoint = (clientX: any, clientY: any) => {
    if (typeof document === 'undefined' || !document.elementFromPoint) return null;
    let el = document.elementFromPoint(clientX, clientY);
    // Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
    // a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
    // and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
    // deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
    while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
      const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
      if (!inner || inner === el) break;
      el = inner;
    }
    if (!el || !el.closest) return null;
    const cellEl = el.closest('[data-grid-cell]');
    if (!cellEl) return null;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
    const r = parseInt(rowAttr, 10);
    const c = parseInt(colAttr, 10);
    if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
    return {
      r,
      c
    };
  };
  onFillHandlePointerDown = (e: any) => {
    if (!e) return;
    if (e.preventDefault) e.preventDefault();
    if (e.stopPropagation) e.stopPropagation();
    // #leak: tear down any orphaned PRIOR gesture BEFORE reassigning the module-let handlers. If a
    // pointerup was missed (pointer released off-window, context menu, alt-tab), the prior fillDrag's
    // document pointermove/pointerup stay attached; overwriting fillDragMove/fillDragUp below would
    // strand them (removeEventListener could never reach the old refs) → a permanent global
    // pointermove leak. teardownFillDrag is idempotent (no-op when nothing is attached).
    this.teardownFillDrag();
    this.fillDragging = true;
    // B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
    // range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
    // up/left drag copies from the real origin (not the post-drag corner that would flip to a
    // target cell). Captured per-gesture in the closure (no module-let needed).
    const sourceBox = this.normalizedRange();
    // B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
    // the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
    let lastCell = sourceBox ? {
      r: sourceBox.r1,
      c: sourceBox.c1
    } : null;
    const move = (ev: any) => {
      if (!this.fillDragging) return;
      const cell = this.cellIndexFromPoint(ev.clientX, ev.clientY);
      // B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
      // pointermove (the pointer fires many per cell) spams the event with identical payloads.
      // Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
      // seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
      // or re-enters the same cell is suppressed (the range is unchanged).
      if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
        lastCell = cell;
        this.setRangeFocus(cell.r, cell.c);
      }
    };
    const up = () => {
      // teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
      this.teardownFillDrag();
      // A plain click on the fill handle (pointerdown+up with NO intervening drag) leaves lastCell
      // at the source box's own origin corner (r1,c1), so fillRange(sourceBox, corner) would
      // recommit the source range onto ITSELF — a no-op write that pollutes undo history and fires
      // spurious per-cell cell-edit-commit events (oldValue === newValue). Only fill when the drag
      // actually reached a cell past the source origin.
      if (lastCell && sourceBox && (lastCell.r !== sourceBox.r1 || lastCell.c !== sourceBox.c1)) {
        this.fillRange(sourceBox, lastCell);
      }
    };
    // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
    this.fillDragMove = move;
    this.fillDragUp = up;
    if (typeof document !== 'undefined') {
      document.addEventListener('pointermove', move);
      document.addEventListener('pointerup', up);
    }
  };
  rangeDragging = false;
  rangeDragMove: any = null;
  rangeDragUp: any = null;
  rangeDragMoved = false;
  teardownRangeDrag = () => {
    if (typeof document !== 'undefined') {
      if (this.rangeDragMove) document.removeEventListener('pointermove', this.rangeDragMove);
      if (this.rangeDragUp) document.removeEventListener('pointerup', this.rangeDragUp);
    }
    this.rangeDragMove = null;
    this.rangeDragUp = null;
    this.rangeDragging = false;
  };
  beginRangeDrag = (anchorR: any, anchorC: any) => {
    // #leak: tear down any orphaned PRIOR range gesture BEFORE reassigning the module-let handlers.
    // A missed pointerup (off-window release, context menu, alt-tab) leaves the prior drag's document
    // pointermove/pointerup attached; overwriting rangeDragMove/rangeDragUp below would strand them
    // (removeEventListener could never reach the old refs) → a permanent global pointermove leak.
    // teardownRangeDrag is idempotent (no-op when nothing is attached) and does NOT touch
    // rangeDragMoved, which is reset per-gesture immediately below.
    this.teardownRangeDrag();
    this.rangeDragging = true;
    this.rangeDragMoved = false;
    let lastCell = {
      r: anchorR,
      c: anchorC
    };
    const move = (ev: any) => {
      if (!this.rangeDragging) return;
      const cell = this.cellIndexFromPoint(ev.clientX, ev.clientY);
      if (cell && (cell.r !== lastCell.r || cell.c !== lastCell.c)) {
        lastCell = cell;
        this.rangeDragMoved = true;
        this.setRangeFocus(cell.r, cell.c);
      }
    };
    const up = () => {
      // teardownRangeDrag clears rangeDragging + removes both listeners (the fill-drag CR-04 path).
      this.teardownRangeDrag();
    };
    // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
    this.rangeDragMove = move;
    this.rangeDragUp = up;
    if (typeof document !== 'undefined') {
      document.addEventListener('pointermove', move);
      document.addEventListener('pointerup', up);
    }
  };
  activeCellColumnId = () => {
    if (this.activeIsHeader()) return null;
    const rowList = this.rows() || [];
    const row = rowList[this.activeRow()];
    if (!row) return null;
    const cells = this.visibleCellsFor(row);
    const cell = cells[this.activeColIndex()];
    return cell && cell.column ? cell.column.id : null;
  };
  isActiveCellEditable = () => {
    const colId = this.activeCellColumnId();
    return colId != null && this.columnEditable(colId);
  };
  isEditing = (rowIndex: any, colIndex: any) => {
    const __editingRowIndex = this.editingRowIndex();
    if (this.editVer() < 0) return false;
    if (__editingRowIndex != null && __editingRowIndex === rowIndex) {
      const colId = this.columnIdAt(rowIndex, colIndex);
      return colId != null && this.columnEditable(colId);
    }
    return this.editingRow() === rowIndex && this.editingCol() === colIndex;
  };
  cellAriaInvalid = (rowIndex: any, colIndex: any): 'true' | null => this.isEditing(rowIndex, colIndex) && !!this.invalidMsg() ? 'true' : null;
  runValidator = (colId: any, value: any, row: any) => {
    const m = this.editMetaOf(colId);
    const v = m ? m.validate : null;
    if (typeof v !== 'function') return true;
    let r: any = null;
    try {
      r = v(value, row);
    } catch (err: any) {
      return 'Invalid value';
    }
    if (r === true) return true;
    if (typeof r === 'string') return r;
    return 'Invalid value';
  };
  setInvalid = (msg: any) => {
    this.invalidMsg.set(msg != null ? msg : '');
  };
  replaceRowValue = (rows: any, rowIndex: any, field: any, value: any) => {
    const src = rows || [];
    const out = [];
    for (let i = 0; i < src.length; i++) {
      if (i === rowIndex) {
        // WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
        // and would copy inherited enumerable props of typed/class-instance row objects.
        out.push({
          ...(src[i] || {}),
          [field]: value
        });
      } else {
        out.push(src[i]);
      }
    }
    return out;
  };
  sourceIndexOfRow = (visibleRowIndex: any) => {
    const rowList = this.rows() || [];
    const row = rowList[visibleRowIndex];
    if (!row) return visibleRowIndex;
    const orig = row.original;
    const data = this.currentData() || [];
    const idx = data.indexOf(orig);
    return idx >= 0 ? idx : visibleRowIndex;
  };
  editingColumnId = () => {
    const rowList = this.rows() || [];
    const row = rowList[this.editingRow()];
    if (!row) return null;
    const cells = this.visibleCellsFor(row);
    const cell = cells[this.editingCol()];
    return cell && cell.column ? cell.column.id : null;
  };
  editingColumnField = () => {
    const colId = this.editingColumnId();
    if (colId == null) return null;
    const d = this.defFor(colId);
    return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
  };
  editingCellValue = () => {
    const rowList = this.rows() || [];
    const row = rowList[this.editingRow()];
    if (!row) return null;
    const cells = this.visibleCellsFor(row);
    const cell = cells[this.editingCol()];
    return cell ? cell.getValue() : null;
  };
  editingRowOriginal = () => {
    const rowList = this.rows() || [];
    const row = rowList[this.editingRow()];
    return row ? row.original : null;
  };
  editingRowId = () => {
    const rowList = this.rows() || [];
    const row = rowList[this.editingRow()];
    return row ? row.id : null;
  };
  focusEditorWhenReady = (selectAll: any = true) => {
    const __editFocusColId = this.editFocusColId();
    if (!this.gridRoot) return;
    // Editor-owns-focus contract: when the CURRENT focus target is a #editor drop-in, the host
    // does NOT reach into its DOM — the drop-in self-focuses via its own autofocus prop.
    if (__editFocusColId != null && this.hasEditorSlot(__editFocusColId)) return;
    let attempts = 0;
    const tryFocus = () => {
      const el = this.gridRoot ? this.gridRoot.querySelector('[data-editing-cell]') : null;
      // Do NOT stomp focus a later interaction already placed in a DIFFERENT column's editor of
      // this row: focusEditorWhenReady only needs to get focus INTO the (first) freshly-mounted
      // editor; if focus already sits in another editable cell, a late rAF re-focus would steal it
      // back to the first editor and break row-mode Tab containment (the non-deterministic B21
      // focus-theft). Compare the OWNING cell's data-col-index (NOT node identity) so a stale
      // SAME-column editor node on Solid's node-replacing re-render still resolves as the target —
      // a genuinely dropped focus is still recovered.
      const ae = this.gridRoot && this.gridRoot.getRootNode ? this.gridRoot.getRootNode().activeElement : null;
      if (ae && el && ae !== el && ae.closest && this.gridRoot.contains(ae) && ae.hasAttribute && ae.hasAttribute('data-editing-cell')) {
        const aeCell = ae.closest('[data-grid-cell]');
        const elCell = el.closest ? el.closest('[data-grid-cell]') : null;
        const aeCol = aeCell ? aeCell.getAttribute('data-col-index') : null;
        const elCol = elCell ? elCell.getAttribute('data-col-index') : null;
        if (aeCol != null && aeCol !== elCol) return;
      }
      if (el) {
        el.focus();
        if (selectAll && el.select) {
          try {
            el.select();
          } catch (e: any) {}
        }
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  };
  columnIdAt = (rowIndex: any, colIndex: any) => {
    const rowList = this.rows() || [];
    const row = rowList[rowIndex];
    if (!row) return null;
    const cells = this.visibleCellsFor(row);
    const cell = cells[colIndex];
    return cell && cell.column ? cell.column.id : null;
  };
  cellValueAt = (rowIndex: any, colIndex: any) => {
    const rowList = this.rows() || [];
    const row = rowList[rowIndex];
    if (!row) return null;
    const cells = this.visibleCellsFor(row);
    const cell = cells[colIndex];
    return cell ? cell.getValue() : null;
  };
  beginEdit = (rowIndex: any, colIndex: any, seed: any) => {
    const colId = this.columnIdAt(rowIndex, colIndex);
    if (colId == null || !this.columnEditable(colId)) return;
    // A new edit session starts — reset the sync idempotency latch so THIS session's eventual
    // commit is not silently no-op'd by a PRIOR session's already-set latch.
    this.committedThisSession = false;
    this.setInvalid('');
    // Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
    // editor clears any row-edit state so isEditing never resolves both modes for one cell.
    this.editingRowIndex.set(null);
    this.rowDraft.set({});
    this.editingRow.set(rowIndex);
    this.editingCol.set(colIndex);
    this.draftValue.set(seed != null ? seed : this.cellValueAt(rowIndex, colIndex));
    this.activeInControl.set(true);
    this.editVer.set(this.editVer() + 1);
    // Editor-owns-focus contract (quick 260711-i5m): THIS cell's column is the current
    // focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope prop
    // from it. Cleared on endEdit.
    this.editFocusColId.set(colId);
    // B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
    // seeded char so subsequent typing appends instead of replacing it.
    this.focusEditorWhenReady(seed == null);
  };
  focusCellWhenReady = (row: any, col: any) => {
    if (!this.gridRoot) return;
    let attempts = 0;
    const tryFocus = () => {
      const el = this.resolveCellEl(String(row), col);
      if (el) {
        el.focus();
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  };
  indexOfRowIn = (rows: any, rowOriginal: any, rowId: any) => {
    const list = rows || [];
    for (let i = 0; i < list.length; i++) {
      const r = list[i];
      if (!r) continue;
      if (rowId != null && r.id === rowId) return i;
      if (rowOriginal != null && r.original === rowOriginal) return i;
    }
    return -1;
  };
  endEdit = () => {
    this.editingRow.set(-1);
    this.editingCol.set(-1);
    this.draftValue.set(null);
    this.invalidMsg.set('');
    this.activeInControl.set(false);
    this.editVer.set(this.editVer() + 1);
    this.editFocusColId.set(null);
  };
  endRowEdit = () => {
    this.editingRowIndex.set(null);
    this.rowDraft.set({});
    this.invalidMsg.set('');
    this.activeInControl.set(false);
    this.editVer.set(this.editVer() + 1);
    this.editFocusColId.set(null);
  };
  editorAutofocusFor = (colId: any, rowIndex: any) => {
    const __editingRowIndex = this.editingRowIndex();
    const __editFocusColId = this.editFocusColId();
    if (this.editVer() < 0) return false;
    if (__editingRowIndex != null) {
      if (__editingRowIndex !== rowIndex) return false;
    } else {
      if (this.editingRow() !== rowIndex) return false;
    }
    return __editFocusColId != null && __editFocusColId === colId;
  };
  coerceCellValue = (colId: any, raw: any) => {
    if (this.editorTypeOf(colId) !== 'number') return raw;
    if (raw == null) return null;
    if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
    const s = String(raw).trim();
    if (s === '') return null;
    const n = Number(s);
    return Number.isNaN(n) ? null : n;
  };
  commitEdit = (overrideValue: any = undefined, skipFocusReturn: any = false) => {
    const __editingRow = this.editingRow();
    if (__editingRow < 0) return false;
    // Sync idempotency latch (drop-in double cell-edit-commit fix): a second commitEdit call
    // within the SAME edit session — the deferred drop-in's unmount-blur re-entry, which on
    // React fires while $data.editingRow is still async-stale ≥ 0 — no-ops here instead of
    // re-validating/re-writing/re-emitting. Reset by beginEdit/beginRowEdit/editCell.
    if (this.committedThisSession) return false;
    const colId = this.editingColumnId();
    if (colId == null) {
      this.endEdit();
      return false;
    }
    const field = this.editingColumnField();
    const oldValue = this.editingCellValue();
    const rowOriginal = this.editingRowOriginal();
    const rowId = this.editingRowId();
    // B3: coerce by the column's editor type BEFORE validation + write so the validator
    // and the model both see the typed value (number/null), not the raw draft string.
    const rawValue = overrideValue !== undefined ? overrideValue : this.draftValue();
    const newValue = this.coerceCellValue(colId, rawValue);
    const err = this.runValidator(colId, newValue, rowOriginal);
    if (err !== true) {
      // D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
      this.setInvalid(err);
      this.focusEditorWhenReady();
      return false;
    }
    this.setInvalid('');
    // #5: a no-op commit (the coerced value is UNCHANGED — a bare Enter/Tab/blur that edited
    // nothing) must do NO model write, NO history record, and NO commit event: writeData →
    // recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row identity, so an
    // unconditional write on a no-op would destroy redo + spuriously re-render + emit a no-op
    // cell-edit-commit. Compute `changed` and gate the write/emit on it; ALWAYS close the editor.
    const changed = !Object.is(newValue, oldValue);
    // Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
    const focusRow = __editingRow;
    const focusCol = this.editingCol();
    // Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
    // must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
    this.editTransition = true;
    // Sync idempotency latch: flip BEFORE writeData/endEdit so the async unmount-blur re-entry
    // (which fires AFTER this call returns, once editTransition is already back to false) finds
    // it set at the top-of-function guard above and no-ops. Set on BOTH paths so a no-op commit
    // is just as re-entry-safe as a real one.
    this.committedThisSession = true;
    if (changed) {
      const srcIndex = this.sourceIndexOfRow(__editingRow);
      const next = this.replaceRowValue(this.currentData(), srcIndex, field, newValue);
      this.writeData(next);
      // Exactly one emit per commit, from this single call site (writeData does NOT emit).
      this.cellEditCommit.emit({
        rowId,
        columnId: colId,
        oldValue,
        newValue
      });
    }
    this.endEdit();
    this.editTransition = false;
    if (changed) {
      // Defer the focus return so the display↔editor re-render commits first (async on
      // React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
      // editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
      // Tab-advance (the caller immediately opens the next editor and focuses THAT).
      // B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
      // RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
      // index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
      // consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
      // fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
      // active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
      // index → byte-behaviorally identical to before.
      if (skipFocusReturn !== true) this.pendingEditFollow = {
        rowOriginal,
        rowId,
        col: focusCol
      };
    } else if (skipFocusReturn !== true) {
      // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume
      // a pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
      // relocate (no write), so the B23 relocation hazard that forces the pendingEditFollow path on
      // a real commit does not apply here: the fixed (focusRow, focusCol) is correct and safe.
      this.focusCellWhenReady(focusRow, focusCol);
    }
    return true;
  };
  toggleActiveBooleanCell = () => {
    const __activeRow = this.activeRow();
    const __activeColIndex = this.activeColIndex();
    const colId = this.columnIdAt(__activeRow, __activeColIndex);
    if (colId == null || !this.columnEditable(colId)) return;
    const rowList = this.rows() || [];
    const row = rowList[__activeRow];
    if (!row) return;
    const rowOriginal = row.original;
    const rowId = row.id;
    const oldValue = this.cellValueAt(__activeRow, __activeColIndex);
    const newValue = !oldValue;
    // D-01: same discipline as commitEdit — a rejecting validator blocks the toggle. There is
    // no editor to keep open here, so the toggle simply does not apply (no model write).
    const err = this.runValidator(colId, newValue, rowOriginal);
    if (err !== true) {
      this.setInvalid(err);
      return;
    }
    this.setInvalid('');
    const def = this.defFor(colId);
    const field = def && def.accessorKey != null ? def.accessorKey : colId;
    const srcIndex = this.sourceIndexOfRow(__activeRow);
    // Sync idempotency latch: this toggle is a commit-equivalent (mirrors commitEdit's D-07
    // single-emit discipline) — flip it too so a stray re-entry after this toggle no-ops.
    this.committedThisSession = true;
    this.writeData(this.replaceRowValue(this.currentData(), srcIndex, field, newValue));
    // Exactly one emit per toggle, from this single call site (writeData does NOT emit) —
    // mirrors commitEdit's D-07 single-emit discipline.
    this.cellEditCommit.emit({
      rowId,
      columnId: colId,
      oldValue,
      newValue
    });
    // Follow the toggled row's focus through a boolean sort/filter relocation AND a
    // fine-grained keyed-row replace (Solid) — the SAME recovery commitEdit relies on; even
    // with no editor to unmount, writeData's re-render can still drop focus.
    this.pendingEditFollow = {
      rowOriginal,
      rowId,
      col: __activeColIndex
    };
  };
  cancelEdit = () => {
    const __editingRow = this.editingRow();
    if (__editingRow < 0) return;
    // CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
    // Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
    // an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
    // instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
    const focusRow = __editingRow;
    const focusCol = this.editingCol();
    this.editTransition = true;
    this.endEdit();
    this.editTransition = false;
    this.focusCellWhenReady(focusRow, focusCol);
  };
  editableColumnsForRow = (rowIndex: any) => {
    const rowList = this.rows() || [];
    const row = rowList[rowIndex];
    if (!row) return [];
    const cells = this.visibleCellsFor(row);
    const out = [];
    for (let c = 0; c < cells.length; c++) {
      const cell = cells[c];
      const colId = cell && cell.column ? cell.column.id : null;
      if (colId == null || !this.columnEditable(colId)) continue;
      const d = this.defFor(colId);
      const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
      // colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
      // Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
      // can address a SPECIFIC editor by column, not just the first [data-editing-cell].
      out.push({
        colId,
        field,
        colIndex: c
      });
    }
    return out;
  };
  focusRowEditorAt = (rowIndex: any, colIndex: any) => {
    if (!this.gridRoot) return;
    const colId = this.columnIdAt(rowIndex, colIndex);
    if (colId != null && this.hasEditorSlot(colId)) return;
    let attempts = 0;
    const tryFocus = () => {
      const cellEl = this.resolveCellEl(String(rowIndex), colIndex);
      const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
      if (ed) {
        ed.focus();
        if (ed.select) {
          try {
            ed.select();
          } catch (e: any) {}
        }
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  };
  beginRowEdit = (row: any) => {
    const rowIndex = this.rowIndexOf(row);
    if (rowIndex < 0) return;
    const editable = this.editableColumnsForRow(rowIndex);
    if (editable.length === 0) return;
    // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
    this.committedThisSession = false;
    // Clear any single-cell editor first (mutual exclusivity).
    this.editingRow.set(-1);
    this.editingCol.set(-1);
    this.draftValue.set(null);
    this.setInvalid('');
    // Seed each editable cell's draft from its current value.
    const draft = {};
    const rowList = this.rows() || [];
    const r = rowList[rowIndex];
    const orig = r ? r.original : null;
    for (let i = 0; i < editable.length; i++) {
      const ec = editable[i];
      draft[ec.colId] = orig ? orig[ec.field] : null;
    }
    this.rowDraft.set(draft);
    this.editingRowIndex.set(rowIndex);
    this.activeInControl.set(true);
    this.editVer.set(this.editVer() + 1);
    // Editor-owns-focus contract (quick 260711-i5m): the row's FIRST editable column is the
    // initial focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope
    // prop from it (a built-in column is also host-focused below via focusEditorWhenReady; a
    // drop-in column self-focuses via its own $onMount, gated off the host reach-in in Task 3).
    this.editFocusColId.set(editable[0].colId);
    this.focusEditorWhenReady();
  };
  commitRow = () => {
    const __editingRowIndex = this.editingRowIndex();
    if (__editingRowIndex == null) return false;
    const rowIndex = __editingRowIndex;
    const editable = this.editableColumnsForRow(rowIndex);
    if (editable.length === 0) {
      this.endRowEdit();
      return false;
    }
    const rowList = this.rows() || [];
    const r = rowList[rowIndex];
    const rowOriginal = r ? r.original : null;
    const rowId = r ? r.id : null;
    const draft = this.rowDraft() || {};
    // Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
    // B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
    // 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
    // commitEdit already coerces via coerceCellValue; the row path silently committed strings →
    // a number column ended up holding '99'). Coerce once here so the validator and the model both
    // see the typed value, identical to the single-cell funnel.
    for (let i = 0; i < editable.length; i++) {
      const ec = editable[i];
      const err = this.runValidator(ec.colId, this.coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
      if (err !== true) {
        this.setInvalid(err);
        // Editor-owns-focus contract (quick 260711-i5m): the OFFENDING column becomes the new
        // reactive focus target BEFORE the host-focus call below — a #editor drop-in already
        // mounted (full-row edit opens every editable cell at once) picks this up via its own
        // lazy $watch on the `autofocus` scope prop flipping false→true. Bump editVer so the
        // coarse-render targets (React/Vue/Angular/Svelte) re-derive the slot binding (Solid's
        // fine-grained accessor re-runs without the bump, but the bump keeps all 6 in lockstep).
        this.editFocusColId.set(ec.colId);
        this.editVer.set(this.editVer() + 1);
        // B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
        // unconditionally the first editor (focusEditorWhenReady resolves the first
        // [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
        // Gated (Task 3) so a #editor drop-in self-focuses instead of a host DOM reach-in.
        this.focusRowEditorAt(rowIndex, ec.colIndex);
        return false;
      }
    }
    this.setInvalid('');
    // Build the changes payload (only the columns whose value actually changed) + the field→
    // value map for the single row-object replace.
    const changes = [];
    const fieldValues = {};
    for (let i = 0; i < editable.length; i++) {
      const ec = editable[i];
      // B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
      // string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
      const newValue = this.coerceCellValue(ec.colId, draft[ec.colId]);
      const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
      fieldValues[ec.field] = newValue;
      if (oldValue !== newValue) changes.push({
        columnId: ec.colId,
        oldValue,
        newValue
      });
    }
    // Snapshot the active cell to return focus to (the whole row is in edit, so the active-cell
    // row/column is the roving focus target), BEFORE endRowEdit clears editing state.
    const focusRow = this.activeRow();
    const focusCol = this.activeColIndex();
    // #5: a no-op row commit (NO column's value actually changed — a bare Enter/save/outside-click
    // that edited nothing) must do NO model write, NO history record, NO row-edit-commit event:
    // writeData → recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row
    // identity, so an unconditional write on a no-op destroys redo + spuriously re-renders + emits
    // a no-op row-edit-commit. Gate the write/emit on `changes.length`; ALWAYS close the editor.
    const changed = changes.length > 0;
    this.editTransition = true;
    if (changed) {
      // ONE fresh-array replace of the SINGLE row object with all field values applied at once.
      const srcIndex = this.sourceIndexOfRow(rowIndex);
      const next = this.replaceRowValues(this.currentData(), srcIndex, fieldValues);
      this.writeData(next);
      // EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
      this.rowEditCommit.emit({
        rowId,
        changes
      });
    }
    this.endRowEdit();
    this.editTransition = false;
    if (changed) {
      // WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
      // like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
      // col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
      // $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
      // DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
      // refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
      // display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
      // the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
      // poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
      this.pendingEditFollow = {
        rowOriginal,
        rowId,
        col: focusCol
      };
    } else {
      // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume a
      // pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
      // relocate (no write), so the B23 relocation hazard does not apply: (focusRow, focusCol) is safe.
      this.focusCellWhenReady(focusRow, focusCol);
    }
    return true;
  };
  cancelRow = () => {
    if (this.editingRowIndex() == null) return;
    const focusRow = this.activeRow();
    const focusCol = this.activeColIndex();
    this.editTransition = true;
    this.endRowEdit();
    this.editTransition = false;
    this.focusCellWhenReady(focusRow, focusCol);
  };
  replaceRowValues = (rows: any, rowIndex: any, fieldValues: any) => {
    const src = rows || [];
    const fv = fieldValues || {};
    const out = [];
    for (let i = 0; i < src.length; i++) {
      if (i === rowIndex) {
        // WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
        // prototype-walking copy. Spread copies own enumerable props only.
        out.push({
          ...(src[i] || {}),
          ...fv
        });
      } else {
        out.push(src[i]);
      }
    }
    return out;
  };
  nextEditableCell = (fromRow: any, fromCol: any) => {
    const rowList = this.rows() || [];
    const rowCount = rowList.length;
    if (rowCount === 0) return null;
    let r = fromRow;
    let c = fromCol + 1;
    while (r < rowCount) {
      const row = rowList[r];
      const cells = row ? this.visibleCellsFor(row) : [];
      while (c < cells.length) {
        const cell = cells[c];
        const cid = cell && cell.column ? cell.column.id : null;
        if (cid != null && this.columnEditable(cid)) return {
          row: r,
          col: c
        };
        c = c + 1;
      }
      r = r + 1;
      c = 0;
    }
    return null;
  };
  prevEditableCell = (fromRow: any, fromCol: any) => {
    const rowList = this.rows() || [];
    const rowCount = rowList.length;
    if (rowCount === 0) return null;
    let r = fromRow;
    let c = fromCol - 1;
    while (r >= 0) {
      const row = rowList[r];
      const cells = row ? this.visibleCellsFor(row) : [];
      while (c >= 0) {
        const cell = cells[c];
        const cid = cell && cell.column ? cell.column.id : null;
        if (cid != null && this.columnEditable(cid)) return {
          row: r,
          col: c
        };
        c = c - 1;
      }
      r = r - 1;
      if (r >= 0) {
        const prow = rowList[r];
        const pcells = prow ? this.visibleCellsFor(prow) : [];
        c = pcells.length - 1;
      }
    }
    return null;
  };
  editTransition = false;
  pendingEditFollow: any = null;
  committedThisSession = false;
  inRowEdit = () => this.editingRowIndex() != null;
  editorValueFor = (colId: any) => this.inRowEdit() ? this.rowDraft() ? this.rowDraft()[colId] : null : this.draftValue();
  editorCheckedFor = (colId: any) => !!(this.inRowEdit() ? this.rowDraft() ? this.rowDraft()[colId] : null : this.draftValue());
  editorCommitFor = (colId: any) => (value: any) => {
    if (this.inRowEdit()) {
      this.setRowDraft(colId, value);
      return;
    }
    this.commitEdit(value);
  };
  editorCancelFor = () => () => {
    if (this.inRowEdit()) {
      this.cancelRow();
      return;
    }
    this.cancelEdit();
  };
  onCellEditorInput = (colId: any, evt: any) => {
    const v = evt && evt.target ? evt.target.value : '';
    if (this.inRowEdit()) {
      this.setRowDraft(colId, v);
      return;
    }
    this.draftValue.set(v);
  };
  onCellEditorCheckbox = (colId: any, evt: any) => {
    const v = !!(evt && evt.target && evt.target.checked);
    if (this.inRowEdit()) {
      this.setRowDraft(colId, v);
      return;
    }
    this.draftValue.set(v);
  };
  setRowDraft = (colId: any, value: any) => {
    const src = this.rowDraft() || {};
    const next = {};
    for (const k in src) next[k] = src[k];
    next[colId] = value;
    this.rowDraft.set(next);
  };
  rowEditTab = (target: any, backward: any) => {
    const rowIndex = this.editingRowIndex();
    if (rowIndex == null) return;
    const editable = this.editableColumnsForRow(rowIndex);
    if (editable.length === 0) return;
    const cols = editable.map((ec: any) => ec.colIndex);
    const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
    const curAttr = cell ? cell.getAttribute('data-col-index') : null;
    const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
    let pos = cols.indexOf(cur);
    if (pos < 0) pos = 0;
    const len = cols.length;
    const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
    // Editor-owns-focus contract (quick 260711-i5m): the Tab target becomes the new reactive
    // focus target BEFORE the host-focus call below, so Tab onto an already-mounted #editor
    // drop-in (row mode) also refocuses it via its own lazy $watch.
    this.editFocusColId.set(editable[nextPos].colId);
    this.editVer.set(this.editVer() + 1);
    this.focusRowEditorAt(rowIndex, cols[nextPos]);
  };
  onEditorKeyDown = (e: any) => {
    if (!e) return;
    const key = e.key;
    // Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
    // model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
    // row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
    // preventDefault it here.
    if (this.inRowEdit()) {
      if (key === 'Enter') {
        e.preventDefault();
        this.commitRow();
      } else if (key === 'Escape') {
        e.preventDefault();
        this.cancelRow();
      }
      // B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
      // editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
      // Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
      // first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
      // tab order across a Lit shadow boundary).
      else if (key === 'Tab') {
        e.preventDefault();
        this.rowEditTab(e.target, e.shiftKey);
      }
      return;
    }
    if (key === 'Enter') {
      e.preventDefault();
      this.commitEdit(undefined);
    } else if (key === 'Tab') {
      e.preventDefault();
      // Resolve the advance target from the EDITING pair (the cell that is open), not the
      // active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
      // moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
      // the editing pair BEFORE commit (commitEdit resets it to -1).
      const fromRow = this.editingRow();
      const fromCol = this.editingCol();
      const target = e.shiftKey ? this.prevEditableCell(fromRow, fromCol) : this.nextEditableCell(fromRow, fromCol);
      // skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
      // straight into the next editable cell's editor below. Use the RETURN value (not a
      // re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
      // failure returns false and keeps the editor open (the user must fix the value first).
      const committed = this.commitEdit(undefined, true);
      if (committed && target) {
        this.activeRow.set(target.row);
        this.activeColIndex.set(target.col);
        this.beginEdit(target.row, target.col, null);
      } else if (committed) {
        // B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
        // grid by returning it to the just-committed cell instead of letting it drop to <body>.
        this.focusCellWhenReady(fromRow, fromCol);
      }
    } else if (key === 'Escape') {
      e.preventDefault();
      this.cancelEdit();
    }
  };
  onEditorBlur = (e: any) => {
    const __editingRow = this.editingRow();
    // Full-row mode (req-6): a blur that stays WITHIN the row editor — Tab/click between the
    // row's OWN fields — is a normal focus move and must NOT commit (a per-cell blur-commit
    // would split the row into N writes + N events, violating the one-write/one-event contract).
    // But an OUTSIDE-click blur (#7) MUST commit the row: otherwise the model is never written
    // AND editingRowIndex stays set, freezing onGridKeyDown's editingRowIndex early-return so
    // arrow-nav is dead the moment the user clicks back into the grid. Mirror the single-cell
    // branch's relatedTarget shape to tell an in-row focus move from a genuine click-away.
    if (this.inRowEdit()) {
      // Guard the teardown blur: commitRow's writeData/endRowEdit re-render unmounts the row's
      // editors → a same-tick re-render blur must NOT re-enter commitRow (double row-edit-commit).
      // commitRow sets editTransition synchronously BEFORE writeData, so it is set here during the
      // teardown window (the async unmount-blur that fires after endRowEdit finds editingRowIndex
      // already null → inRowEdit() false → the single-cell tail's editingRow<0 guard returns).
      if (this.editTransition) return;
      const rowNext = e ? e.relatedTarget : null;
      const rowNextCell = rowNext && rowNext.closest ? rowNext.closest('[data-grid-cell]') : null;
      const rowNextRow = rowNextCell ? rowNextCell.getAttribute('data-row') : null;
      // Focus landing on a cell of the SAME editing row (Tab/click between the row's own fields) →
      // controlled in-row move, do NOT commit. Anything else — a null relatedTarget, another row,
      // a toolbar/widget, or outside the grid entirely — is an outside-click → commit the row as a
      // unit. commitRow clears editingRowIndex, releasing onGridKeyDown's early-return so nav
      // resumes; a no-op row (nothing changed) takes commitRow's clean #5 no-write/no-emit path.
      if (rowNextRow != null && rowNextRow === String(this.editingRowIndex())) return;
      this.commitRow();
      return;
    }
    if (__editingRow < 0 || this.editTransition) return;
    const next = e ? e.relatedTarget : null;
    // A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
    // keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
    // a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
    // conservative null=skip behavior.
    if (next == null) return;
    // Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
    // the editor open on an invalid value).
    if (!(this.gridRoot && this.gridRoot.contains && this.gridRoot.contains(next))) {
      this.commitEdit(undefined);
      return;
    }
    // Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
    // keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
    // (which must commit + close so the grid is not wedged with an open editor).
    const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
    const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
    // Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
    // controlled move; skip. Also skip when either cell can't be resolved (an unmounting
    // editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
    if (!nextCell || !fromCell || nextCell === fromCell) return;
    // A Tab-advance already committed the old editor and opened the next one, so the live
    // editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
    // editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
    // double commit, WR-04).
    const fromRow = fromCell.getAttribute('data-row');
    const fromCol = fromCell.getAttribute('data-col-index');
    if (fromRow !== String(__editingRow) || fromCol !== String(this.editingCol())) return;
    // Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
    // commit does NOT bounce focus back to the just-committed editing cell (which would fight
    // the click destination). The commit's writeData re-renders the table and can DROP DOM
    // focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
    // DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
    // (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
    // late, which would steal focus back from a subsequent navigation.
    const destRow = nextCell.getAttribute('data-row');
    const destCol = nextCell.getAttribute('data-col-index');
    this.commitEdit(undefined, true);
    const reseatDestFocus = () => {
      if (!this.gridRoot || destRow == null || destCol == null || destRow === '__header') return;
      const root = this.gridRoot.getRootNode ? this.gridRoot.getRootNode() : null;
      const act = root && root.activeElement ? root.activeElement : null;
      // Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
      if (act && this.gridRoot.contains && this.gridRoot.contains(act)) return;
      const el = this.resolveCellEl(destRow, parseInt(destCol, 10));
      if (el) el.focus();
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
  };
  editCell = (rowIndex: any, colIndex: any) => {
    const lastRow = this.bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const maxCol = this.visibleColCount() - 1;
    const r = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
    const c = this.clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
    // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
    this.committedThisSession = false;
    this.activeIsHeader.set(false);
    this.activeRow.set(r);
    this.activeColIndex.set(c);
    this.beginEdit(r, c, null);
  };
  commitEditing = () => {
    if (this.inRowEdit()) {
      this.commitRow();
      return;
    }
    if (this.editingRow() >= 0) this.commitEdit(undefined);
  };
  editRow = (rowIndex: any) => {
    const lastRow = this.bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const r = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
    const rowList = this.rows() || [];
    const row = rowList[r];
    if (!row) return;
    this.activeIsHeader.set(false);
    this.activeRow.set(r);
    this.beginRowEdit(row);
  };
  focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
    if (!this.gridRoot) return;
    let attempts = 0;
    const want = String(this.headerRowCount() + absRow + 1);
    // #9: capture the focus-intent epoch at arm time (AFTER focusCell's own bump at its top, so
    // this poll never aborts itself). A LATER focus intent — a click landing on a new cell
    // (syncActiveFromEvent) or another focusCell / keyboard nav — bumps the epoch, so this
    // paginated page-switch poll aborts instead of grabbing focus frames after the user moved on.
    const myEpoch = this.focusIntentEpoch;
    const tryFocus = () => {
      if (this.focusIntentEpoch !== myEpoch) return;
      const el = this.resolveCellEl(String(localRow), col);
      if (el) {
        const rowEl = el.closest ? el.closest('[role="row"]') : null;
        const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
        if (ari === want) {
          el.focus();
          return;
        }
      }
      attempts = attempts + 1;
      if (attempts >= 60) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  };
  focusCell = (rowIndex: any, colIndex: any) => {
    // B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
    // is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
    // isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
    // instance does not leak a spurious activecell-change.
    if (!this.isGrid()) return;
    // #9: focusCell is a focus-INTENT entry point — bump the epoch BEFORE arming any poll (the
    // switched-page focusAbsCellWhenReady captures the post-bump value; the same-page / virtual
    // branches route through focusActiveCell, which bumps again — harmless). A subsequent focusCell
    // or user nav bumps again → a pending focusAbsCellWhenReady from THIS call aborts.
    this.focusIntentEpoch = this.focusIntentEpoch + 1;
    const maxCol = this.visibleColCount() - 1;
    const c = this.clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
    // C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
    const absLast = this.prePaginationRowCount() - 1;
    const absRow = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
    // B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
    // real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
    // active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
    const prevAbs = this.toAbsRow(this.activeRow());
    const prevIsHeader = this.activeIsHeader();
    if (this.virtual()) {
      // Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
      // the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
      // focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
      this.activeIsHeader.set(false);
      this.activeInControl.set(false);
      this.activeRow.set(absRow);
      this.activeColIndex.set(c);
      this.focusActiveCell(absRow, c, false);
    } else {
      // Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
      // in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
      // body's data-row markers (and the roving tabindex) address.
      const size = this.pageSize();
      const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
      const localRow = absRow - targetPage * size;
      const switched = targetPage !== this.pageIndex();
      if (switched) this.setPage(targetPage);
      this.activeIsHeader.set(false);
      this.activeInControl.set(false);
      this.activeRow.set(localRow);
      this.activeColIndex.set(c);
      if (switched) {
        // The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
        // TARGET page's body aria-rowindex (headerRowCount + absRow + 1, #13) before focusing, so
        // the OLD page's same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only.
        this.focusAbsCellWhenReady(absRow, localRow, c);
      } else {
        // Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
        // Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
        // async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
        this.focusActiveCell(localRow, c, false);
      }
    }
    if (absRow !== prevAbs || prevIsHeader) {
      this.activecellChange.emit({
        rowIndex: absRow,
        colIndex: c
      });
    }
  };
  getActiveCell = () => this.activeIsHeader() ? {
    rowIndex: null,
    colIndex: this.activeColIndex(),
    isHeader: true
  } : {
    rowIndex: this.toAbsRow(this.activeRow()),
    colIndex: this.activeColIndex(),
    isHeader: false
  };
  clearActiveCell = () => {
    if (!this.isGrid()) return;
    this.activeIsHeader.set(false);
    this.activeInControl.set(false);
    this.activeRow.set(0);
    this.activeColIndex.set(0);
  };
  toggleRowExpanded = (rowId: any) => {
    if (!this.table) return;
    const target = String(rowId);
    const flat = this.table.getCoreRowModel().flatRows;
    for (const r of flat as any) {
      if (r.id === target || r.original && String(r.original.id) === target) {
        r.toggleExpanded();
        return;
      }
    }
  };
  expandAll = () => {
    if (!this.table) return;
    this.table.toggleAllRowsExpanded(true);
  };
  collapseAll = () => {
    if (!this.table) return;
    this.table.resetExpanded(true);
  };
  getExpandedRows = () => {
    if (!this.table) return [];
    const out = [];
    const flat = this.table.getCoreRowModel().flatRows;
    for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
    return out;
  };
  applyGrouping = (cols: any) => {
    if (this.table) this.table.setGrouping(cols);
  };
  clearGrouping = () => {
    if (this.table) this.table.setGrouping([]);
  };
  getFacetedUniqueValues = (colId: any) => {
    if (this.tick() < 0 || !this.table) return [];
    const col = this.table.getColumn(colId);
    if (!col || !col.getFacetedUniqueValues) return [];
    const map = col.getFacetedUniqueValues(); // Map<any, number>
    return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
  };
  getFacetedMinMaxValues = (colId: any) => {
    if (this.tick() < 0 || !this.table) return null;
    const col = this.table.getColumn(colId);
    if (!col || !col.getFacetedMinMaxValues) return null;
    return col.getFacetedMinMaxValues() || null; // [number, number] | null
  };

  static ngTemplateContextGuard(
    _dir: DataTable,
    _ctx: unknown,
  ): _ctx is DefaultCtx | GroupBarCtx | SelectAllCtx | ColHeaderCtx | FilterCtx | SelectCellCtx | CellCtx | EditorCtx | DetailCtx {
    return true;
  }

  protected get __style() {
      const __maxHeight = this.maxHeight();
      return __maxHeight ? 'max-height:' + __maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + __maxHeight : 'overflow:auto';
    }

  private _selectCell_ctx = (wr: any, cell: any) => ({ $implicit: { row: wr.row.original, checked: this.rowIsSelected(wr.row), toggle: e => this.onToggleRow(wr.row, e) }, row: wr.row.original, checked: this.rowIsSelected(wr.row), toggle: e => this.onToggleRow(wr.row, e) });

  private _selectCell_ctx_1 = (row: any, cell: any) => ({ $implicit: { row: row.original, checked: this.rowIsSelected(row), toggle: e => this.onToggleRow(row, e) }, row: row.original, checked: this.rowIsSelected(row), toggle: e => this.onToggleRow(row, e) });

  protected readonly String = String;

  rozieDisplay(v: unknown): string { return __rozieDisplay(v); }

  rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}

export default DataTable;
tsx
import type { JSX } from 'solid-js';
import { Show, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { Key } from '@solid-primitives/keyed';
import { __rozieInjectStyle, createControllableSignal, parseInlineStyle, rozieAttr, rozieClass, rozieContext, rozieDisplay } from '@rozie/runtime-solid';
import Popover from '@rozie-ui/popover-solid';
import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';

// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).

__rozieInjectStyle('DataTable-d5dcab4c', `.rozie-data-table[data-rozie-s-d5dcab4c] {
  border-collapse: collapse;
  width: 100%;
  font: var(--rdt-font, 14px system-ui, sans-serif);
  color: var(--rdt-color, inherit);
}
.rdt-sr-live[data-rozie-s-d5dcab4c] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-cell-editor[data-rozie-s-d5dcab4c] {
  font: inherit;
  width: 100%;
  box-sizing: border-box;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[aria-invalid="true"][data-rozie-s-d5dcab4c] {
  outline: var(--rdt-invalid-outline, 2px solid #d33);
  outline-offset: -2px;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td.rdt-in-range[data-rozie-s-d5dcab4c] {
  background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td.rdt-cell-active[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th.rdt-cell-active[data-rozie-s-d5dcab4c] {
  outline: var(--rdt-active-cell-outline, 2px solid #2563eb);
  outline-offset: -2px;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
  position: relative;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-fill-handle[data-rozie-s-d5dcab4c] {
  position: absolute;
  right: -3px;
  bottom: -3px;
  width: 8px;
  height: 8px;
  background: var(--rdt-fill-handle-bg, #2563eb);
  border: 1px solid #fff;
  cursor: crosshair;
  z-index: 1;
  touch-action: none;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
  padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
  text-align: left;
  border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
  font-weight: var(--rdt-header-weight, 600);
  /* OPAQUE default (was rgba(0,0,0,0.03)): a translucent header lets the scrolling body
     bleed through in sticky mode. #f7f7f7 is the visual equivalent of the old 3%-black
     tint over white, but solid. The three design-system themes already ship opaque
     header backgrounds; this makes the zero-config default consistent with them. */
  background: var(--rdt-header-bg, #f7f7f7);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-btn[data-rozie-s-d5dcab4c] {
  display: inline-flex;
  align-items: center;
  gap: var(--rdt-sort-gap, 0.35em);
  background: none;
  border: none;
  font: inherit;
  font-weight: inherit;
  color: inherit;
  cursor: pointer;
  padding: 0;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-ind[data-rozie-s-d5dcab4c] {
  font-size: 0.8em;
  opacity: var(--rdt-sort-ind-opacity, 0.7);
}
.rozie-data-table.rdt-sticky[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
  position: sticky;
  top: var(--rdt-sticky-top, 0);
  z-index: var(--rdt-sticky-z, 2);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-scroll[data-rozie-s-d5dcab4c] {
  max-height: var(--rozie-data-table-max-height);
  overflow: auto;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-bar-host[data-rozie-s-d5dcab4c] {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--rdt-group-bar-gap, 0.375rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-token {
  display: inline-flex;
  align-items: center;
  padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
  border-radius: var(--rdt-group-token-radius, 999px);
  background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
  font-size: var(--rdt-group-token-size, 0.8125em);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-header[data-rozie-s-d5dcab4c] {
  background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
  font-weight: var(--rdt-group-header-weight, 600);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-toggle[data-rozie-s-d5dcab4c] {
  margin-right: var(--rdt-group-toggle-gap, 0.375rem);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-count[data-rozie-s-d5dcab4c] {
  margin-left: var(--rdt-group-count-gap, 0.375rem);
  opacity: var(--rdt-group-count-opacity, 0.65);
  font-weight: 400;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] {
  display: flex;
  flex-direction: column;
  gap: var(--rdt-chrome-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-toolbar[data-rozie-s-d5dcab4c] {
  display: flex;
  gap: var(--rdt-toolbar-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-global-filter[data-rozie-s-d5dcab4c],
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter {
  font: inherit;
  /* border-box so the padding + border count INSIDE the declared width — without it
     the col-filter's \`width: 100%\` + padding overflows its (constrained) header cell. */
  box-sizing: border-box;
  padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
  border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-filter-radius, 4px);
  background: var(--rdt-filter-bg, transparent);
  color: inherit;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter {
  display: block;
  margin-top: var(--rdt-col-filter-gap, 0.25rem);
  width: 100%;
  font-weight: normal;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-filter-row[data-rozie-s-d5dcab4c] {
  background: var(--rdt-filter-row-bg, rgba(0, 0, 0, 0.015));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-filter-cell[data-rozie-s-d5dcab4c] {
  padding: var(--rdt-filter-cell-padding, 0.35rem 0.75rem);
  border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-filter-row[data-rozie-s-d5dcab4c] .rdt-col-filter {
  font-size: var(--rdt-filter-row-input-size, 0.9em);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-pagination[data-rozie-s-d5dcab4c] {
  display: flex;
  align-items: center;
  gap: var(--rdt-pagination-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c] {
  font: inherit;
  cursor: pointer;
  padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
  border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-page-btn-radius, 4px);
  background: var(--rdt-page-btn-bg, transparent);
  color: inherit;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c]:disabled {
  opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
  cursor: default;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-status[data-rozie-s-d5dcab4c] {
  font-size: var(--rdt-page-status-size, 0.9em);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-size[data-rozie-s-d5dcab4c] {
  font: inherit;
  padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
  border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-page-size-radius, 4px);
  background: var(--rdt-page-size-bg, transparent);
  color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
  position: relative;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c] {
  position: absolute;
  top: 0;
  right: 0;
  height: 100%;
  width: var(--rdt-resize-handle-width, 6px);
  padding: 0;
  border: none;
  background: none;
  cursor: col-resize;
  touch-action: none;
  user-select: none;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
  display: block;
  width: var(--rdt-resize-grip-width, 2px);
  height: 100%;
  margin: 0 auto;
  background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c]:hover .rdt-resize-grip[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th-resizing[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
  background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-trigger[data-rozie-s-d5dcab4c] {
  font: inherit;
  font-size: var(--rdt-col-menu-trigger-size, 0.9em);
  line-height: 1;
  cursor: pointer;
  margin-left: var(--rdt-col-menu-trigger-margin, 0.35em);
  padding: var(--rdt-col-menu-trigger-padding, 0.15em 0.4em);
  border: var(--rdt-col-menu-trigger-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-col-menu-trigger-radius, 3px);
  background: var(--rdt-col-menu-trigger-bg, transparent);
  color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-trigger[data-rozie-s-d5dcab4c]:hover {
  background: var(--rdt-col-menu-trigger-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-trigger[data-rozie-s-d5dcab4c]:focus-visible {
  outline: var(--rdt-col-menu-trigger-focus-outline, 2px solid #2563eb);
  outline-offset: 1px;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu[data-rozie-s-d5dcab4c] {
  display: flex;
  flex-direction: column;
  gap: var(--rdt-col-menu-item-gap, 0.15rem);
  min-width: var(--rdt-col-menu-min-width, 9rem);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-item[data-rozie-s-d5dcab4c] {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  cursor: pointer;
  padding: var(--rdt-col-menu-item-padding, 0.35em 0.6em);
  border: none;
  border-radius: var(--rdt-col-menu-item-radius, 3px);
  background: none;
  color: inherit;
  white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-item[data-rozie-s-d5dcab4c]:hover {
  background: var(--rdt-col-menu-item-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-item[aria-pressed='true'][data-rozie-s-d5dcab4c] {
  background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
  font-weight: 700;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-sep[data-rozie-s-d5dcab4c] {
  margin: var(--rdt-col-menu-sep-margin, 0.25rem 0);
  border: none;
  border-top: var(--rdt-col-menu-sep-border, 1px solid rgba(0, 0, 0, 0.1));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis[data-rozie-s-d5dcab4c] {
  position: relative;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-summary[data-rozie-s-d5dcab4c] {
  cursor: pointer;
  font: inherit;
  padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
  border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-colvis-summary-radius, 4px);
  list-style: none;
  user-select: none;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-menu[data-rozie-s-d5dcab4c] {
  position: absolute;
  z-index: var(--rdt-colvis-menu-z, 5);
  margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
  padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
  display: flex;
  flex-direction: column;
  gap: var(--rdt-colvis-item-gap, 0.25rem);
  border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-colvis-menu-radius, 4px);
  background: var(--rdt-colvis-menu-bg, #fff);
  box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-item[data-rozie-s-d5dcab4c] {
  display: flex;
  align-items: center;
  gap: var(--rdt-colvis-label-gap, 0.4em);
  cursor: pointer;
  white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-td[data-rozie-s-d5dcab4c] {
  width: var(--rdt-select-col-width, 44px);
  text-align: var(--rdt-select-col-align, center);
  white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-expander-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-expander-td[data-rozie-s-d5dcab4c] {
  width: var(--rdt-expander-col-width, 40px);
  text-align: var(--rdt-expander-col-align, center);
  white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-all[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-row[data-rozie-s-d5dcab4c] {
  cursor: pointer;
  accent-color: var(--rdt-select-accent, currentColor);
}`);

interface GroupBarSlotCtx { grouping: any; groupableColumns: any; applyGrouping: any; clearGrouping: any; }

interface SelectAllSlotCtx { checked: any; indeterminate: any; toggle: any; }

interface ColHeaderSlotCtx { columnId: any; column: any; label: any; }

interface FilterSlotCtx { columnId: any; value: any; uniqueValues: any; minMax: any; setFilter: any; }

interface SelectCellSlotCtx { row: any; checked: any; toggle: any; }

interface CellSlotCtx { columnId: any; column: any; row: any; value: any; }

interface EditorSlotCtx { columnId: any; column: any; row: any; value: any; commit: any; cancel: any; autofocus: any; }

interface DetailSlotCtx { row: any; }

interface DataTableProps {
  /**
   * The row data — `model: true`, so a committed cell/row edit writes a **fresh** array back through `r-model:data` (uncontrolled fallback `dataDefault`). A stable reference per Rozie's setup-once model — fed directly into table-core (never map/cloned in the watcher).
   * @example
   * <DataTable r-model:data="rows" :columns="cols" />
   */
  data: any[];
  defaultData?: any[];
  onDataChange?: (data: any[]) => void;
  /**
   * Config-array column fallback (lower precedence than `<Column>` children). Each entry: `{ id?, field, header?, sortable?, filterable?, pinned?, width? }`. Columns may come from this array, from `<Column>` children, or both (id-keyed last-write-wins union).
   */
  columns?: any[];
  /**
   * Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
   */
  selectionMode?: string;
  /**
   * `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
   */
  sorting?: any[];
  defaultSorting?: any[];
  onSortingChange?: (sorting: any[]) => void;
  /**
   * The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
   */
  globalFilter?: string;
  defaultGlobalFilter?: string;
  onGlobalFilterChange?: (globalFilter: string) => void;
  /**
   * `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
   */
  columnFilters?: any[];
  defaultColumnFilters?: any[];
  onColumnFiltersChange?: (columnFilters: any[]) => void;
  /**
   * `{ pageIndex, pageSize }`. Defaults to `{ pageIndex: 0, pageSize: 10 }`; feeds the prev/next + page-size chrome (and `getPaginationRowModel()`). Two-way: funnels a fresh object through `page-change`.
   */
  pagination?: Record<string, any>;
  defaultPagination?: Record<string, any>;
  onPaginationChange?: (pagination: Record<string, any>) => void;
  /**
   * Server-side hook: sets `manualPagination` / `manualFiltering` / `manualSorting` so table-core trusts the consumer-supplied rows and only emits the change events (the consumer fetches each page).
   */
  manual?: boolean;
  /**
   * Total server-side row count for `manual` pagination; lets the table compute page count when it doesn't hold the full dataset.
   */
  rowCount?: (number) | null;
  /**
   * Explicit total page count for `manual` pagination; overrides rowCount-derived count.
   */
  pageCount?: (number) | null;
  /**
   * Opt-in **expandable rows**. When `true`, a leading chevron expander column auto-injects (after the select column) and `getExpandedRowModel` activates; default `false` is byte-identical-off. Every row can expand to reveal a `#detail` panel unless `getSubRows` is supplied (then only rows with children expand). Bind `:expandable="true"` (a bare attr only coerces on Vue+Lit).
   */
  expandable?: boolean;
  /**
   * `ExpandedState` — `{ [rowId]: true }`, or the `true` literal after `expandAll` (declared `type: [Object, Boolean]`). Multi-expand (multiple rows open at once). Surfaces through `expand-change`; uncontrolled fallback (`$data.expandedDefault`) when unbound — the default is `null` so the uncontrolled fallback AND the grouping auto-expand default are reachable (a non-null default would short-circuit them). When grouping is active and `expanded` is untouched, group subtrees auto-expand.
   */
  expanded?: (Record<string, any> | boolean) | null;
  defaultExpanded?: (Record<string, any> | boolean) | null;
  onExpandedChange?: (expanded: (Record<string, any> | boolean) | null) => void;
  /**
   * Table-level child-row accessor `(originalRow, index) => TData[] | undefined` that drives nested sub-rows. When supplied (with `expandable`), table-core flattens the hierarchy and the expand seam reveals depth-indented child rows. Null → the `#detail` scoped slot is the expand mode.
   */
  getSubRows?: ((...args: any[]) => any) | null;
  /**
   * Opt-in gate for the **headless `#groupBar`** host region. Default `false` is byte-identical-off. `getGroupedRowModel` is wired unconditionally (inert when `grouping` is empty), so grouping is driven by the `grouping` model; this flag only gates the consumer-facing group-bar surface (the component ships **no** built-in drag UI).
   */
  groupable?: boolean;
  /**
   * `GroupingState` — an ordered `string[]` of column ids (multi-column → nested groups, e.g. `['region','category']`). An empty/unbound list is ungrouped (byte-identical-off). Group-header rows are collapsible (they ride the expand model). Surfaces through `group-change`; uncontrolled fallback (`$data.groupingDefault`, default `[]`) when unbound — the default is `null` (mirroring `expanded`) so the uncontrolled fallback is reachable and the grouping auto-expand default can activate when a consumer applies grouping without binding `r-model:grouping` (a non-null `[]` default would short-circuit it). All reads are null-guarded, so table-core still receives an array.
   */
  grouping?: (any[]) | null;
  defaultGrouping?: (any[]) | null;
  onGroupingChange?: (grouping: (any[]) | null) => void;
  /**
   * `RowSelectionState` — `{ [rowId]: true }`. Checkbox-only toggle (the row body does not select). Driven by the `selectionMode` chrome. Two-way: fires `selection-change` regardless of binding.
   */
  rowSelection?: Record<string, any>;
  defaultRowSelection?: Record<string, any>;
  onRowSelectionChange?: (rowSelection: Record<string, any>) => void;
  /**
   * `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
   */
  columnVisibility?: Record<string, any>;
  defaultColumnVisibility?: Record<string, any>;
  onColumnVisibilityChange?: (columnVisibility: Record<string, any>) => void;
  /**
   * `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
   */
  columnSizing?: Record<string, any>;
  defaultColumnSizing?: Record<string, any>;
  onColumnSizingChange?: (columnSizing: Record<string, any>) => void;
  /**
   * `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
   */
  columnOrder?: any[];
  defaultColumnOrder?: any[];
  onColumnOrderChange?: (columnOrder: any[]) => void;
  /**
   * `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
   */
  columnPinning?: Record<string, any>;
  defaultColumnPinning?: Record<string, any>;
  onColumnPinningChange?: (columnPinning: Record<string, any>) => void;
  /**
   * Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
   */
  stickyHeader?: boolean;
  /**
   * `'table'` (default, row-oriented, byte-behaviorally identical to a plain accessible table) | `'grid'` (GA since Phase 63) — lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)**: `role="grid"`, a roving single tab-stop, 2-D APG arrow-key cell navigation, range selection, and clipboard support.
   */
  interactionMode?: string;
  /**
   * Grid mode only. When `true`, a plain click on an **editable** cell opens its editor immediately (single-click-to-edit) instead of just activating the cell. Default `false` keeps click-to-activate (double-click opens the editor). Shift+click (range selection) and clicks on non-editable cells are unaffected.
   */
  singleClickEdit?: boolean;
  /**
   * Grid mode. When `true`, every committed data mutation (cell/row edit, paste, fill, cut, clear) becomes one undo step: Ctrl/Cmd+Z undoes, Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z redoes. Default `false` records no history and Ctrl+Z/Y are inert.
   */
  undoable?: boolean;
  /**
   * The maximum number of undo steps retained (oldest evicted past this depth). Only consulted when `undoable` is `true`.
   */
  undoLimit?: number;
  /**
   * Opt-in vertical **row windowing**. When `true`, only the visible slice of rows renders inside a bounded `rdt-scroll` container (with leading/trailing spacer rows preserving total scroll height), windowing over the full filtered + sorted (pre-pagination) model and suppressing the client pagination chrome. Default `false` is byte-identical to a non-virtual table.
   */
  virtual?: boolean;
  /**
   * Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
   */
  estimateRowHeight?: number;
  /**
   * A CSS length string bounding the `rdt-scroll` container when `virtual` is on (e.g. `'400px'`). Mirrored to the `--rozie-data-table-max-height` custom property; the prop wins, the token is the fallback.
   */
  maxHeight?: string;
  onSortChange?: (...args: unknown[]) => void;
  onExpandChange?: (...args: unknown[]) => void;
  onGroupChange?: (...args: unknown[]) => void;
  onFilterChange?: (...args: unknown[]) => void;
  onPageChange?: (...args: unknown[]) => void;
  onSelectionChange?: (...args: unknown[]) => void;
  onVisibilityChange?: (...args: unknown[]) => void;
  onResizeChange?: (...args: unknown[]) => void;
  onReorderChange?: (...args: unknown[]) => void;
  onPinChange?: (...args: unknown[]) => void;
  onHistoryChange?: (...args: unknown[]) => void;
  onActivecellChange?: (...args: unknown[]) => void;
  onRangeChange?: (...args: unknown[]) => void;
  onCellEditCommit?: (...args: unknown[]) => void;
  onRowEditCommit?: (...args: unknown[]) => void;
  // D-131: default slot resolved via children() at body top
  children?: JSX.Element;
  groupBarSlot?: (ctx: GroupBarSlotCtx) => JSX.Element;
  selectAllSlot?: (ctx: SelectAllSlotCtx) => JSX.Element;
  colHeaderSlot?: (ctx: ColHeaderSlotCtx) => JSX.Element;
  filterSlot?: (ctx: FilterSlotCtx) => JSX.Element;
  selectCellSlot?: (ctx: SelectCellSlotCtx) => JSX.Element;
  cellSlot?: (ctx: CellSlotCtx) => JSX.Element;
  editorSlot?: (ctx: EditorSlotCtx) => JSX.Element;
  detailSlot?: (ctx: DetailSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
  ref?: (h: DataTableHandle) => void;
}

export interface DataTableHandle {
  sortColumn: (...args: any[]) => any;
  clearSorting: (...args: any[]) => any;
  toggleRowExpanded: (...args: any[]) => any;
  expandAll: (...args: any[]) => any;
  collapseAll: (...args: any[]) => any;
  getExpandedRows: (...args: any[]) => any;
  applyGrouping: (...args: any[]) => any;
  clearGrouping: (...args: any[]) => any;
  getFacetedUniqueValues: (...args: any[]) => any;
  getFacetedMinMaxValues: (...args: any[]) => any;
  getColumnDefs: (...args: any[]) => any;
  toggleAllRows: (...args: any[]) => any;
  clearSelection: (...args: any[]) => any;
  getSelectedRows: (...args: any[]) => any;
  setPage: (...args: any[]) => any;
  setRowsPerPage: (...args: any[]) => any;
  toggleColumnVisibility: (...args: any[]) => any;
  applyColumnOrder: (...args: any[]) => any;
  resetColumnSizing: (...args: any[]) => any;
  pinColumn: (...args: any[]) => any;
  focusCell: (...args: any[]) => any;
  getActiveCell: (...args: any[]) => any;
  clearActiveCell: (...args: any[]) => any;
  getRowIndexRelativeToPage: (...args: any[]) => any;
  editCell: (...args: any[]) => any;
  commitEditing: (...args: any[]) => any;
  editRow: (...args: any[]) => any;
  getSelectedRange: (...args: any[]) => any;
  cut: (...args: any[]) => any;
  undo: (...args: any[]) => any;
  redo: (...args: any[]) => any;
  canUndo: (...args: any[]) => any;
  canRedo: (...args: any[]) => any;
  clearHistory: (...args: any[]) => any;
}

export default function DataTable(_props: DataTableProps): JSX.Element {
  const _merged = mergeProps({ columns: (() => [])() as any[], selectionMode: 'none', manual: false, rowCount: null, pageCount: null, expandable: false, getSubRows: null, groupable: false, stickyHeader: false, interactionMode: 'table', singleClickEdit: false, undoable: false, undoLimit: 100, virtual: false, estimateRowHeight: 40, maxHeight: '' }, _props);
  const [local, attrs] = splitProps(_merged, ['data', 'columns', 'selectionMode', 'sorting', 'globalFilter', 'columnFilters', 'pagination', 'manual', 'rowCount', 'pageCount', 'expandable', 'expanded', 'getSubRows', 'groupable', 'grouping', 'rowSelection', 'columnVisibility', 'columnSizing', 'columnOrder', 'columnPinning', 'stickyHeader', 'interactionMode', 'singleClickEdit', 'undoable', 'undoLimit', 'virtual', 'estimateRowHeight', 'maxHeight', 'children', 'ref', 'onSortChange', 'onExpandChange', 'onGroupChange', 'onFilterChange', 'onPageChange', 'onSelectionChange', 'onVisibilityChange', 'onResizeChange', 'onReorderChange', 'onPinChange', 'onHistoryChange', 'onActivecellChange', 'onRangeChange', 'onCellEditCommit', 'onRowEditCommit']);
  const resolved = () => local.children;
  onMount(() => { local.ref?.({ sortColumn, clearSorting, toggleRowExpanded, expandAll, collapseAll, getExpandedRows, applyGrouping, clearGrouping, getFacetedUniqueValues, getFacetedMinMaxValues, getColumnDefs, toggleAllRows, clearSelection, getSelectedRows, setPage, setRowsPerPage, toggleColumnVisibility, applyColumnOrder, resetColumnSizing, pinColumn, focusCell, getActiveCell, clearActiveCell, getRowIndexRelativeToPage, editCell, commitEditing, editRow, getSelectedRange, cut, undo, redo, canUndo, canRedo, clearHistory }); });

  const __ctx_data_table_columns = rozieContext("data-table:columns");
  const [data, setData] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'data', []);
  const [sorting, setSorting] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'sorting', (() => [])());
  const [globalFilter, setGlobalFilter] = createControllableSignal<string>(_props as unknown as Record<string, unknown>, 'globalFilter', '');
  const [columnFilters, setColumnFilters] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'columnFilters', (() => [])());
  const [pagination, setPagination] = createControllableSignal<Record<string, any>>(_props as unknown as Record<string, unknown>, 'pagination', (() => ({
    pageIndex: 0,
    pageSize: 10
  }))());
  const [expanded, setExpanded] = createControllableSignal<Record<string, any> | boolean | null>(_props as unknown as Record<string, unknown>, 'expanded', null);
  const [grouping, setGrouping] = createControllableSignal<any[] | null>(_props as unknown as Record<string, unknown>, 'grouping', null);
  const [rowSelection, setRowSelection] = createControllableSignal<Record<string, any>>(_props as unknown as Record<string, unknown>, 'rowSelection', (() => ({}))());
  const [columnVisibility, setColumnVisibility] = createControllableSignal<Record<string, any>>(_props as unknown as Record<string, unknown>, 'columnVisibility', (() => ({}))());
  const [columnSizing, setColumnSizing] = createControllableSignal<Record<string, any>>(_props as unknown as Record<string, unknown>, 'columnSizing', (() => ({}))());
  const [columnOrder, setColumnOrder] = createControllableSignal<any[]>(_props as unknown as Record<string, unknown>, 'columnOrder', (() => [])());
  const [columnPinning, setColumnPinning] = createControllableSignal<Record<string, any>>(_props as unknown as Record<string, unknown>, 'columnPinning', (() => ({
    left: [],
    right: []
  }))());
  const [dataDefault, setDataDefault] = createSignal<any[]>([]);
  const [sortingDefault, setSortingDefault] = createSignal<any[]>([]);
  const [globalFilterDefault, setGlobalFilterDefault] = createSignal('');
  const [columnFiltersDefault, setColumnFiltersDefault] = createSignal<any[]>([]);
  const [paginationDefault, setPaginationDefault] = createSignal({
    pageIndex: 0,
    pageSize: 10
  });
  const [rowSelectionDefault, setRowSelectionDefault] = createSignal<Record<string, any>>({});
  const [expandedDefault, setExpandedDefault] = createSignal<Record<string, any>>({});
  const [groupingDefault, setGroupingDefault] = createSignal<any[]>([]);
  const [columnVisibilityDefault, setColumnVisibilityDefault] = createSignal<Record<string, any>>({});
  const [columnSizingDefault, setColumnSizingDefault] = createSignal<Record<string, any>>({});
  const [columnOrderDefault, setColumnOrderDefault] = createSignal<any[]>([]);
  const [columnPinningDefault, setColumnPinningDefault] = createSignal({
    left: [],
    right: []
  });
  const [columnSizingInfo, setColumnSizingInfo] = createSignal({
    startOffset: null,
    startSize: null,
    deltaOffset: null,
    deltaPercentage: null,
    isResizingColumn: false,
    columnSizingStart: []
  });
  const [colReg, setColReg] = createSignal<Record<string, any>>({});
  const [rows, setRows] = createSignal<any[]>([]);
  const [headerGroups, setHeaderGroups] = createSignal<any[]>([]);
  const [rowModelVer, setRowModelVer] = createSignal(0);
  const [windowVer, setWindowVer] = createSignal(0);
  const [activeRow, setActiveRow] = createSignal(0);
  const [activeColIndex, setActiveColIndex] = createSignal(0);
  const [activeIsHeader, setActiveIsHeader] = createSignal(false);
  const [activeHeaderLevel, setActiveHeaderLevel] = createSignal(0);
  const [activeInControl, setActiveInControl] = createSignal(false);
  const [editingRow, setEditingRow] = createSignal(-1);
  const [editingCol, setEditingCol] = createSignal(-1);
  const [draftValue, setDraftValue] = createSignal<any>(null);
  const [invalidMsg, setInvalidMsg] = createSignal('');
  const [editVer, setEditVer] = createSignal(0);
  const [editFocusColId, setEditFocusColId] = createSignal<any>(null);
  const [editingRowIndex, setEditingRowIndex] = createSignal<any>(null);
  const [rowDraft, setRowDraft] = createSignal<Record<string, any>>({});
  const [rangeAnchor, setRangeAnchor] = createSignal<any>(null);
  const [rangeFocus, setRangeFocus] = createSignal<any>(null);
  const [pasteAnnounce, setPasteAnnounce] = createSignal('');
  const [liveAnnounce, setLiveAnnounce] = createSignal('');
  onMount(() => {
    // Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
    // edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
    // a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
    // the bound prop when controlled, this fallback otherwise.
    setDataDefault(data() || []);
    // Build the table instance HERE so the closures below capture the live `table`.
    table = createTable({
      // Plain value (NOT a `get data()` getter): an object-literal getter rebinds
      // `this` to the options object, and the Angular/Lit emitters resolve $props via
      // `this.data` — so `get data() { return $props.data }` lowers to `this.data`
      // re-entering the getter → infinite recursion (max call stack). `data` is re-fed
      // on every change by the watch's setOptions below, exactly like columns/state, so
      // the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
      // currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
      // (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
      data: currentData(),
      columns: tableColumns(),
      state: currentState(),
      getCoreRowModel: getCoreRowModel(),
      getSortedRowModel: getSortedRowModel(),
      getFilteredRowModel: getFilteredRowModel(),
      getPaginationRowModel: getPaginationRowModel(),
      // Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
      // (mirrors the other models) — inert when `expanded` is empty + no getSubRows
      // (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
      // ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
      // (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
      // default `!!subRows.length` rule applies (only parents with children expand).
      getExpandedRowModel: getExpandedRowModel(),
      getSubRows: (local.getSubRows || undefined) as any,
      getRowCanExpand: local.expandable === true && local.getSubRows == null ? () => true : undefined,
      onExpandedChange: onExpandedChangeCb,
      // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
      // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
      // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
      // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
      // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
      // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
      // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
      // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
      // are unaffected (they fire regardless of this flag).
      autoResetExpanded: false,
      // Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
      // UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
      // (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
      // table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
      // members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
      // nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
      // (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
      getGroupedRowModel: getGroupedRowModel(),
      onGroupingChange: onGroupingChangeCb,
      // Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
      // UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
      // column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
      // The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
      getFacetedRowModel: getFacetedRowModel(),
      getFacetedUniqueValues: makeFacetedUniqueValues(),
      getFacetedMinMaxValues: makeFacetedMinMaxValues(),
      // Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
      // rows verbatim (no client-side filter/sort/paginate) and only emits the change
      // events so the consumer can fetch the next page/filtered slice.
      manualPagination: local.manual === true,
      manualFiltering: local.manual === true,
      manualSorting: local.manual === true,
      // Server-side page-count sources (#2): pass the consumer-supplied total row count and/or
      // explicit page count so table-core can compute getPageCount() under `manual` (where it
      // does not hold the full dataset). undefined when unset → table-core auto-derives from the
      // loaded data (client-pagination path byte-unchanged). Precedence is table-core's: explicit
      // pageCount wins, else ⌈rowCount / pageSize⌉, else auto. With a real count getCanNextPage()
      // becomes true, so a server-pagination consumer can leave page 0.
      rowCount: local.rowCount ?? undefined,
      pageCount: local.pageCount ?? undefined,
      // Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
      // (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
      // default, D-06 — NOT overridden).
      enableRowSelection: local.selectionMode !== 'none',
      enableMultiRowSelection: local.selectionMode === 'multiple',
      // PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
      // no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
      // $watch so React reads fresh currentState (the stale-closure fix, F6).
      onSortingChange: onSortingChangeCb,
      onGlobalFilterChange: onGlobalFilterChangeCb,
      onColumnFiltersChange: onColumnFiltersChangeCb,
      onPaginationChange: onPaginationChangeCb,
      onRowSelectionChange: onRowSelectionChangeCb,
      onColumnVisibilityChange: onColumnVisibilityChangeCb,
      onColumnSizingChange: onColumnSizingChangeCb,
      onColumnOrderChange: onColumnOrderChangeCb,
      onColumnPinningChange: onColumnPinningChangeCb,
      onColumnSizingInfoChange: onColumnSizingInfoChangeCb,
      // Resize mode: 'onChange' so the bound columnSizing model updates live during the
      // drag (the behavioral width-delta assertion observes the in-progress width). Column
      // resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
      columnResizeMode: 'onChange',
      enableColumnResizing: true,
      renderFallbackValue: null,
      // table-core's RESOLVED options type (TableOptionsResolved) requires a global
      // onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
      // callbacks above, so the global hook is a no-op. Present so the createTable() argument
      // satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
      onStateChange: () => {}
    });
    refreshRowModel = () => {
      if (!table) return;
      // Capture fresh locals; never write a $data key then re-read it in the same fn
      // (ROZ138 / React stale-read — setState is async on React, the closure binds the
      // PRE-write value).
      // windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
      // pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
      const nextRows = windowSource().slice();
      const nextGroups = table.getHeaderGroups().slice();
      setRows(nextRows);
      setHeaderGroups(nextGroups);
      setRowModelVer(rowModelVer() + 1);
      // Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
      // into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
      // NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
      // setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
      if (local.virtual && virtualizer) {
        virtualizer.setOptions(virtualizerOptions());
        virtualizer._willUpdate();
      }
      // D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
      // clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
      // no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
      // B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
      // async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
      // the range corners on React too — never re-reading the pre-change model.
      const nextRowCount = nextRows.length;
      const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
      clampActiveCell(nextRowCount, nextColCount);
      // #4: clamp a pageIndex that now points PAST the last page. When the consumer holds
      // pagination.pageIndex (controlled) and shrinks the data (filter / replace) so there are
      // fewer pages, the body renders blank ("Page 6 of 3" with Next disabled). Read table-core's
      // LIVE post-re-derive state: getPageCount() is the fresh count (now correct under `manual`
      // too, #2) and getState().pagination is the just-fed state. Funnel the correction through
      // writePagination (the single-emit + two-way-model funnel) so the consumer's controlled
      // pagination prop converges to the last valid page (page-change carries { pageIndex, pageSize }).
      //   • pc > 0 skips the manual-WITHOUT-count case (getPageCount() === -1) — never clamp toward
      //     an unknown total.
      //   • LOOP-GUARD: emit ONLY when the clamped index actually differs. After the consumer echoes
      //     the clamp back through the pagination prop, the re-feed re-enters here with
      //     pageIndex === pc - 1, so `pageIndex > pc - 1` is false → no re-emit; a consumer that
      //     ignores the event triggers no further re-feed, so it stays a single emit either way.
      //   • No fight with table-core's autoResetPageIndex: that reset only fires on table-core's OWN
      //     setX mutations, which this fully-controlled-state architecture never calls (filters/data
      //     flow through setOptions), so reading the live state here can only fire on a genuine
      //     overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
      //     writing paginationDefault, with no regression since table-core does not auto-clamp here).
      const pgState = table.getState().pagination;
      const pc = table.getPageCount();
      if (pc > 0 && pgState.pageIndex > pc - 1) {
        writePagination({
          pageIndex: pc - 1,
          pageSize: pgState.pageSize
        });
      }
      // B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
      // filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
      // indices), so resolve the committed row's NEW index by identity HERE (never from the React-
      // stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
      // gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
      // a no-relocation commit resolves the same index → byte-behaviorally identical to before.
      if (pendingEditFollow && isGrid()) {
        const follow = pendingEditFollow;
        pendingEditFollow = null;
        const followIdx = indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
        if (followIdx >= 0) focusCellWhenReady(followIdx, follow.col);
      }
      // keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
      // selection state (bound :indeterminate is inert on 5/6 targets). The box persists
      // across selection changes; a microtask defer covers React's post-render DOM patch.
      syncIndeterminate();
      if (typeof queueMicrotask !== 'undefined') queueMicrotask(syncIndeterminate);else Promise.resolve().then(syncIndeterminate);
    };

    // initial pull
    refreshRowModel();

    // ── Grid mode: capture the table root ──────────────────────────────────────────────
    // $el is the component root; the <table class="rozie-data-table"> is the grid root the
    // cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
    // (post-mount) so it is non-null and ROZ123-clean.
    gridRoot = __rozieRootRef! ? __rozieRootRef!.querySelector('.rozie-data-table') : null;
    // WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
    // page load AND was non-deterministic on React/Solid (the entry cell may not be
    // committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
    // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
    // mount"); the consumer drives focus by Tabbing/clicking in, never the component.

    // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
    // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
    // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
    // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
    // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
    if (local.virtual) {
      gridScrollEl = __rozieRootRef! ? __rozieRootRef!.querySelector('.rdt-scroll') : null;
      virtualizer = new Virtualizer(virtualizerOptions());
      virtualizerCleanup = virtualizer._didMount();
      // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
      // evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
      // reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
      // non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
      // SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
      // only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
      // never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
      // re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
      // pulls getVirtualItems().
      setWindowVer(windowVer() + 1);
      // After the first window commits (next frame), refine heights + fire the dev-mode warns
      // ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
      // code and these warns can never fire there (req-1 byte-identical-off preserved).
      const afterFirstFrame = () => {
        // D-10: measure the rendered rows.
        remeasureWindow();
        // D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
        // bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
        // process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
        const h = gridScrollEl ? gridScrollEl.clientHeight : 0;
        if (!h) {
          console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
        }
        // D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
        // CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
        // virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
        // ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
        // detected as a pagination that DIFFERS from that default (a consumer who set a real page
        // size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
        // virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
        const pg = pagination();
        const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
        if (local.manual !== true && pgConfigured) {
          console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
        }
      };
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
    }

    // #14: seed the sort/filter announce baseline from the initial (post-mount) state so the LAZY
    // watch's first fire — a real user sort/filter — compares against the true starting values and
    // is classified correctly (a null sentinel would misread the first filter change as a sort change).
    announceState.sorting = effectiveSorting();
    announceState.columnFilters = effectiveColumnFilters();
    announceState.globalFilter = effectiveGlobalFilter();
  });
  onCleanup(() => {
    if (virtualizerCleanup) virtualizerCleanup();
    // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
    teardownFillDrag();
    // §6 (260709-3qt): remove any live drag-select document listeners on a mid-drag unmount.
    teardownRangeDrag();
  });
  createEffect(() => {
    maybeClearHistoryOnExternalSwap();
    if (!table) return;
    // Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
    // $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
    // bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
    // identical length) still re-feeds.
    const d = currentData() || [];
    if (d === lastData && d.length === lastDataLen) return;
    lastData = d;
    lastDataLen = d.length;
    reFeed();
  });
  createEffect(on(() => (() => [sorting(), globalFilter(), columnFilters(), pagination(),
  // Server-side page-count sources (#2): re-feed when the consumer's rowCount/pageCount
  // changes at runtime (e.g. a server response updates the total) so getPageCount() and the
  // Next button availability track the new total.
  local.rowCount, local.pageCount, rowSelection(), expanded(), local.expandable, grouping(), local.groupable, columnVisibility(), columnSizing(), columnOrder(), columnPinning(), local.selectionMode, (data() || []).length, // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
  // even when the fresh array is the SAME length (a single-cell edit replaces one row
  // object → new array ref, identical length → the .length key alone would miss it). The
  // controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
  // writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
  data(), dataDefault(),
  // Column CONFIG prop (the `:columns` array form) — the sibling column source to
  // $data.colReg (the `<Column>` children). Watch it so a runtime `:columns` swap re-feeds:
  // columnDefs()/tableColumns() build the UNION of both, and reFeed re-passes columns.
  // (Consumers memoize the array as with $props.data/$props.sorting; the uncontrolled
  // <Column>-children path leaves $props.columns undefined — a stable no-op getter.)
  local.columns, colReg()])(), (v) => untrack(() => (() => {
    reFeed();
    maybeClearHistoryOnExternalSwap();
  })()), { defer: true }));
  createEffect(on(() => (() => [sorting(), columnFilters(), globalFilter(), sortingDefault(), columnFiltersDefault(), globalFilterDefault()])(), (v) => untrack(() => (() => {
    const msg = buildSortFilterAnnounce();
    if (msg) setLiveAnnounce(msg);
  })()), { defer: true }));
  let __rozieRootRef: HTMLElement | null = null;

  // table-core instance — top-level `let` referenced from hooks → React hoists to
  // useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
  // getRowModel-reading closures capture the LIVE instance, NOT an empty initial
  // snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
  // freezes the table at the empty-initial state on React).
  let table: any = null;

  // ── Vertical row windowing instance state (phase 53) ──────────────────────────────────
  // Mutable top-level instances (the `let table` precedent — React hoists to useRef; do NOT
  // const). NULL until $onMount, and ONLY constructed when $props.virtual. virtualizerCleanup
  // holds the _didMount() teardown for $onUnmount; gridScrollEl is the captured .rdt-scroll div
  // the virtualizer observes.
  let virtualizer: any = null;
  let virtualizerCleanup: any = null;
  let gridScrollEl: any = null;
  // CR-01 remeasure scheduling state. remeasurePending dedupes the deferred sweep — at most ONE
  // rAF is in flight, so a burst of onChange ticks (a fast scroll) collapses to a single measure
  // pass per frame instead of piling up rAF callbacks that fire mid-gesture. The piled-up
  // callbacks were what broke the Solid scroll-then-focus seam (D-12 focusActiveCell →
  // scrollToIndex → double-rAF focus): a stray remeasure firing inside that focus deferral
  // disrupted the focus landing. The sweep ALSO bails while virtual-core is mid-scroll
  // (virtualizer.isScrolling), so a measure can't run during scrollToIndex; the next settled
  // onChange re-measures the now-stable window. Scroll-driven recycling (the CR-01 case, measured
  // once motion settles between scroll steps) is unaffected.
  let remeasurePending = false;

  // ── Grid interaction-mode constants + DOM root (phase 49, REQ-2/6) ────────────────────
  // Fixed PageUp/PageDown row step (D-06). Phase 53 swaps this for the visible-window size
  // via the same focusActiveCell() scroll-into-view seam — kept a top-level const so that
  // later change is a one-line edit.
  const GRID_PAGE_STEP = 10;
  // The stable table-root element, captured in $onMount (the ONLY ROZ123-safe place to read
  // $el / query DOM across all six). focusActiveCell() resolves cells off this root; it is
  // shadow-safe because the query runs from INSIDE the component's own scope (the listbox
  // querySelector-off-root precedent, proven ×6 by plan 01's probe). NEVER read in a
  // computed/template binding (ROZ123).
  let gridRoot: any = null;

  // Echo-guard: while WE are writing a slice back, the re-feed watcher must not re-enter
  // the funnel. A counter (not a boolean) so nested writes are safe.
  let programmatic = 0;

  // Focus-intent epoch (#9) — a monotonic counter bumped at every focus-INTENT entry point
  // (focusActiveCell / focusCell+focusAbsCellWhenReady arm / a genuine active-cell-moving
  // focusin in syncActiveFromEvent). The two async focus-recovery polls (focusWhenReady for the
  // virtual off-window scroll, focusAbsCellWhenReady for the paginated page-switch) CAPTURE this
  // value at arm time (AFTER their own bump) and abort at the top of each iteration if it has since
  // changed — so a LATER user nav (ArrowKey / click) supersedes a stale poll instead of the poll
  // yanking focus back frames later. A naive guardMoved "abort if focus moved" check is WRONG here:
  // both polls arm while focus still sits on the OLD/being-left cell BY DESIGN (scroll-to /
  // page-switch), so an epoch — bumped only by a NEWER intent — is the correct abort signal.
  let focusIntentEpoch = 0;

  // ── Grid-wide undo/redo (260709-8ct) — history STATE lives in top-level `let` (mirroring
  // `programmatic` above), NOT $data: recording a snapshot on every keystroke must not trigger
  // a reactive re-render. React hoists each to useRef. undoStack/redoStack hold `data` array
  // REFERENCES (never deep copies — see undoHistory.rzts's header comment on the shared-row
  // invariant). restoringHistory suppresses re-recording while an undo()/redo() replay is
  // in flight.
  //
  // The external-swap history reset keys on data ORIGIN, not a timing window. Every internal
  // writeback stamps its fresh `data` array with a durable, non-enumerable marker under
  // DATA_WRITE_TOKEN_KEY (see writeData in writeFunnels.rzts); the reset (maybeClearHistoryOnExternal
  // Swap, below) clears history ONLY when a newly-supplied `$props.data` carries no marker — it did
  // not come from us, so it is a genuine external dataset swap. Presence of the marker ⟺ "descends
  // from one of our writes", and it survives EVERYTHING that defeated the four flag/timer variants:
  //   1. A raw-reference latch (`lastWrittenData === currentData()`) — Vue `reactive()` / Svelte 5
  //      `$state` / Solid store re-wrap a written array in a NEW Proxy on its way back through props,
  //      so `===` never holds. (A non-enumerable own PROPERTY, by contrast, is forwarded through
  //      every target's reactive Proxy via `Reflect.get` — readable through the wrap.)
  //   2. A single-consume boolean — the re-feed watch fires MULTIPLE times per write; the first pass
  //      consumed the flag, a later pass wrongly cleared.
  //   3. A content signature (`JSON.stringify`) — the watch can fire with a TRANSIENTLY STALE
  //      `currentData()` mid-settle (Solid/Lit), a real-but-older value → false mismatch.
  //   4. A deferred settle-window flag (rAF, then a 96ms macrotask) — a slow re-feed on a LARGE
  //      controlled table OUTRAN the window (#8); no fixed timeout can be correct (re-feed latency
  //      scales with dataset size).
  // A STRING key (not a JS Symbol) is deliberate: it is stable BY VALUE on all six targets with ZERO
  // caching, whereas a `Symbol()` needs a per-instance memo to hold one identity — and Lit lowers
  // `$computed(() => Symbol())` to a plain getter that RE-MINTS the Symbol on every read, so writeData
  // and the reset would stamp/read DIFFERENT symbols and the marker would never match. Non-enumerable
  // → invisible to JSON.stringify / spread / Object.keys (the consumer's data stays clean); namespaced
  // so a consumer array never collides.
  const DATA_WRITE_TOKEN_KEY = '__rozieDataWriteToken';
  let undoStack: unknown[] = [];
  let redoStack: unknown[] = [];
  let restoringHistory: boolean = false;

  // Grouping auto-expand latch (phase 50 req-4): when grouping is ACTIVE and the consumer
  // has not bound `expanded` and has not yet toggled any group, group-header rows default to
  // EXPANDED (so the grouped subtree is visible — the standard grouped-grid affordance + the
  // roundout-VR leaf-visible baseline). The FIRST group/row toggle sets this true (in
  // writeExpanded), after which the user's expanded state wins. Stays false (untouched) on the
  // non-grouping path → byte-identical-off (the `expanded` slice resolves to $data.expandedDefault
  // exactly as before, both for the plain table AND the expandable-rows feature).
  let expandedTouched = false;

  // groupingActiveDefault(): is grouping currently engaged (a non-empty ordered key list)? Reads
  // the same source order as currentState().grouping ($props.grouping ?? $data.groupingDefault) so
  // the expanded auto-default below tracks the live grouping state on every target.
  function groupingActiveDefault() {
    return ((grouping() != null ? grouping() : groupingDefault()) || []).length > 0;
  }

  // effectiveColumnPinning(): the auto-injected select/expander chrome columns are a STRUCTURAL
  // left-pinned rail — they ALWAYS lead the pinned-left group so the checkbox/chevron stay the
  // leftmost body cells in EVERY case (fresh, pinned, AND grouped). Two forces would otherwise
  // push a data column ahead of the checkbox:
  //   1. Pinning — a consumer pinning `name` left makes it left-pinned; getVisibleCells() returns
  //      [left-pinned, center, right-pinned], so an unpinned (center) checkbox renders AFTER it.
  //   2. Grouping — table-core's groupedColumnMode defaults to 'reorder', which moves a grouped
  //      column to the FRONT of the order, ahead of an unpinned center checkbox.
  // Pinning the rail left beats BOTH: the left group always precedes the (grouped-reordered)
  // center group. We prepend SELECT_COL_ID then EXPANDER_COL_ID (matching the tableColumns
  // injection order [select, expander, ...userCols]) ahead of any consumer left-pins.
  // REQUIRES: the chrome column defs carry an explicit `size` (columnBuilders.rzts) — pinStyle's
  // sticky offset is col.getStart('left') = Σ preceding pinned SIZES, so a size-less chrome column
  // (table-core's 150px default) would inflate every real pinned column's `left` and overlap.
  // The consumer's columnPinning model never sees these ids: writeColumnPinning() strips them
  // on the way back out (writeFunnels.rzts). Note: this ALWAYS-pin makes the default (no-pin)
  // checkbox a sticky-left rail — an intentional baseline change (VR/snapshot baselines drift).
  function effectiveColumnPinning(): any {
    const base = columnPinning() != null ? columnPinning() : columnPinningDefault();
    const rail: string[] = [];
    if (selectionEnabled()) rail.push(SELECT_COL_ID);
    if (local.expandable === true) rail.push(EXPANDER_COL_ID);
    if (rail.length === 0) return base;
    const left = base && base.left ? base.left : [];
    const deduped = left.filter((id: string) => id !== SELECT_COL_ID && id !== EXPANDER_COL_ID);
    return {
      ...base,
      left: rail.concat(deduped)
    };
  }

  // Assemble the live state object from bound r-model slices (?? uncontrolled fallback).
  // All NINE slices are wired (each ?? its own $data.<slice>Default). table-core reads
  // this whole object as `state`. Return type annotated `any`: the inferred object-literal
  // type does not structurally match table-core's `Partial<TableState>` under the strict
  // bundled-leaf tsc (the columnSizingInfo/pagination shapes widen to Record) — the
  // runtime shape is correct; `any` sidesteps the over-strict structural check (the
  // deferred-items strict-tsc #2 / leaf-output-strict-typecheck close).
  function currentState(): any {
    return {
      sorting: sorting() != null ? sorting() : sortingDefault(),
      globalFilter: globalFilter() != null ? globalFilter() : globalFilterDefault(),
      columnFilters: columnFilters() != null ? columnFilters() : columnFiltersDefault(),
      pagination: pagination() != null ? pagination() : paginationDefault(),
      rowSelection: rowSelection() != null ? rowSelection() : rowSelectionDefault(),
      // expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
      // literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
      // guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
      // GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
      // `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
      // literal so the grouped subtree is visible by default; the first toggle latches
      // expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
      // unchanged → byte-identical-off (the table + the expandable-rows feature both keep
      // $data.expandedDefault).
      expanded: expanded() != null ? expanded() : groupingActiveDefault() && !expandedTouched ? true : expandedDefault(),
      // grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
      // to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
      // is inert when this is empty (byte-identical-off, req-10).
      grouping: grouping() != null ? grouping() : groupingDefault(),
      columnVisibility: columnVisibility() != null ? columnVisibility() : columnVisibilityDefault(),
      columnSizing: columnSizing() != null ? columnSizing() : columnSizingDefault(),
      columnOrder: columnOrder() != null ? columnOrder() : columnOrderDefault(),
      columnPinning: effectiveColumnPinning(),
      // columnSizingInfo: table-core's transient resize-gesture state. We pass an
      // EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
      // `column.getIsResizing()` / `getResizeHandler()` read
      // `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
      // absent. Seed the default shape (matches table-core's
      // getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
      // every render. Not a two-way model slice (transient gesture state, not consumer
      // state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
      columnSizingInfo: columnSizingInfo()
    };
  }

  // The live row data (Phase 51 req-4): the bound `data` prop when controlled, else the
  // uncontrolled $data.dataDefault fallback (mirrors currentState's per-slice ?? pattern).
  // A committed edit funnels a FRESH array through writeData, which writes BOTH sinks; the
  // re-feed sources here so editing works whether or not the consumer binds r-model:data.
  function currentData(): any {
    return data() != null ? data() : dataDefault();
  }

  // Prototype-safe id-keyed column resolution (T-48-PP): the `:columns` config array is
  // applied FIRST (lower precedence), then the <Column> registry OVERRIDES by id (LWW).
  // byId is a null-prototype object so a consumer column id of "__proto__"/"constructor"
  // cannot pollute Object.prototype. Returns the table-core ColumnDef[]. (No per-column
  // render callbacks — cells render via the single #cell/#header scoped slot on this
  // component, dispatched by columnId; <Column> carries metadata only.)
  function isSafeKey(k: any) {
    return k !== '__proto__' && k !== 'constructor' && k !== 'prototype';
  }
  // wrapAggregationFn (phase 50 req-5, D-05, threat T-50-04): resolve a per-column
  // aggregationFn straight onto the ColumnDef (no component-side switch — RESEARCH
  // anti-pattern). A built-in NAME string ('sum'/'min'/'max'/'extent'/'mean'/'median'/
  // 'unique'/'uniqueCount'/'count') passes through verbatim — table-core resolves it from its
  // built-in `aggregationFns` map. A CUSTOM function `(columnId, leafRows, childRows) => any`
  // is DEFENSIVELY WRAPPED (the runValidator precedent): a consumer fn runs per group, so a
  // throw is coerced to `undefined` and can never crash getGroupedRowModel (DoS guard).
  // Anything else → undefined (no aggregation; the cell renders as a placeholder).
  function wrapAggregationFn(fn: any) {
    if (typeof fn === 'string') return fn;
    if (typeof fn !== 'function') return undefined;
    return (columnId: any, leafRows: any, childRows: any) => {
      try {
        return fn(columnId, leafRows, childRows);
      } catch (err: any) {
        return undefined;
      }
    };
  }
  // Build the table-core ColumnDef for ONE config-array entry. A LEAF entry
  // ({ id?, field, header?, … }) maps to an accessor ColumnDef; a GROUP entry
  // ({ id?, header, columns: [...] }) maps to a multi-level header GROUP column
  // whose children are built recursively (B12 — grouped/multi-level column headers).
  // Returns null for an unusable entry (no id/field, unsafe key, empty group).
  function buildConfigDef(c: any) {
    if (!c) return null;
    // Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
    // getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
    // header spans its leaf children (B12). The group id falls back to its header text so it
    // stays addressable (no accessor; group columns carry no data).
    if (Array.isArray(c.columns)) {
      const kids = [];
      for (const child of c.columns as any) {
        const cd = buildConfigDef(child);
        if (cd) kids.push(cd);
      }
      if (!kids.length) return null;
      // Group id: an explicit c.id wins. Otherwise synthesize a STABLE UNIQUE id from the child
      // column ids (which are unique per leaf accessor / recursively-synthesized per nested group)
      // — NOT the header text. Falling back to c.header collided two same-titled groups (e.g. both
      // "Details") into one by-id map key, so the columnDefs LWW merge silently dropped one whole
      // group column + its children. The child-id derivation is deterministic (stable across
      // renders — no Math.random/Date). A group with neither id nor header (nor derivable children)
      // stays dropped as before.
      let gid = c.id;
      if (gid == null) gid = c.header != null ? '__grp_' + kids.map((k: any) => k.id).join('_') : null;
      if (gid == null) return null;
      const id = String(gid);
      if (!isSafeKey(id)) return null;
      return {
        id,
        header: c.header != null ? c.header : id,
        columns: kids
      };
    }
    const rawId = c.id != null ? c.id : c.field;
    if (rawId == null) return null;
    const id = String(rawId);
    if (!isSafeKey(id)) return null;
    return {
      id,
      accessorKey: c.field != null ? c.field : id,
      header: c.header != null ? c.header : id,
      enableSorting: c.sortable === true,
      // per-column filter opt-in (req-5). table-core gates the filter input + value
      // funnel on enableColumnFilter; a column with filterable !== true cannot be
      // filtered (and renders no per-column filter input in the chrome below).
      enableColumnFilter: c.filterable === true,
      filterable: c.filterable === true,
      // Expandable-rows reserved per-column metadata (phase 50, D-04).
      expandable: c.expandable === true,
      // Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
      // so every data column is offered to the headless #groupBar by default; the per-column
      // aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
      // a custom fn defensively wrapped (T-50-04).
      groupable: c.groupable !== false,
      aggregationFn: wrapAggregationFn(c.aggregationFn),
      pinned: c.pinned != null ? c.pinned : '',
      width: c.width != null ? c.width : '',
      // Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
      // metadata carrier the display↔editor branch + runValidator read. Off by default.
      meta: {
        editable: c.editable === true,
        editor: c.editor != null ? c.editor : 'text',
        editorOptions: c.editorOptions != null ? c.editorOptions : [],
        validate: typeof c.validate === 'function' ? c.validate : null
      }
    };
  }
  function columnDefs() {
    const byId = Object.create(null);
    const order = [];
    const cfg = local.columns || [];
    for (const c of cfg as any) {
      const def = buildConfigDef(c);
      if (!def) continue;
      const id = def.id;
      if (!(id in byId)) order.push(id);
      byId[id] = def;
    }
    const reg = colReg() || {};
    for (const id in reg) {
      if (!isSafeKey(id)) continue;
      const spec = reg[id];
      if (!spec) continue;
      if (!(id in byId)) order.push(id);
      byId[id] = {
        id,
        accessorKey: spec.field != null ? spec.field : id,
        header: spec.header != null ? spec.header : id,
        enableSorting: spec.sortable === true,
        enableColumnFilter: spec.filterable === true,
        filterable: spec.filterable === true,
        // Expandable-rows reserved per-column metadata (phase 50, D-04).
        expandable: spec.expandable === true,
        // Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
        groupable: spec.groupable !== false,
        aggregationFn: wrapAggregationFn(spec.aggregationFn),
        pinned: spec.pinned != null ? spec.pinned : '',
        width: spec.width != null ? spec.width : '',
        // Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
        meta: {
          editable: spec.editable === true,
          editor: spec.editor != null ? spec.editor : 'text',
          editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
          validate: typeof spec.validate === 'function' ? spec.validate : null
        }
      };
    }
    const out = [];
    for (const id of order as any) if (byId[id]) out.push(byId[id]);
    return out;
  }

  // The constant id of the auto-injected leading checkbox column (D-04). Distinct from
  // any consumer column id (the registry/config guard never produces a leading "__").
  const SELECT_COL_ID = '__rdt_select';

  // The constant id of the auto-injected leading chevron expander column (phase 50, D-04).
  // Distinct from any consumer column id (the registry/config guard never produces a leading
  // "__"). Injected AFTER the select column (so order is [select, expander, ...userCols]).
  const EXPANDER_COL_ID = '__rdt_expander';

  // The table-core ColumnDef set actually fed to createTable / setOptions: the resolved
  // user columns, PLUS a LEADING checkbox column when selectionMode is 'single' OR
  // 'multiple' (D-04). The select column carries enableSorting/enableColumnFilter:false
  // and an isSelectColumn marker the template uses to render checkbox chrome (NOT an
  // accessor value). 'none' injects nothing. In 'single' mode the per-row checkbox
  // renders but the select-all HEADER checkbox is suppressed (selecting a row caps at
  // ≤1 via enableMultiRowSelection:false) — a single-select needs a per-row control,
  // not a select-all, so without injecting the column single mode would expose NO
  // selection UI at all.
  function selectionEnabled() {
    return local.selectionMode === 'single' || local.selectionMode === 'multiple';
  }
  function tableColumns() {
    const cols = columnDefs();
    // Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
    // isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
    // value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
    // → byte-identical-off (req-10).
    let withExpander = cols;
    if (local.expandable === true) {
      const expanderCol = {
        id: EXPANDER_COL_ID,
        enableSorting: false,
        enableColumnFilter: false,
        filterable: false,
        isExpanderColumn: true,
        pinned: '',
        width: '',
        // Explicit narrow size so table-core's getSize()/getStart('left') match the RENDERED
        // width. Without it table-core assumes its 150px default, which is fine for an UNPINNED
        // chrome column (a CSS `width:1%` trick shrinks it visually) but breaks the moment the
        // column joins the left-pinned rail: pinStyle's sticky offset is Σ preceding pinned
        // SIZES, so a phantom 150px would push every real pinned column ~150px too far right and
        // overlap. Keep this in sync with the `--rdt-expander-col-width` CSS default (40px).
        size: 40
      };
      withExpander = [expanderCol].concat(cols);
    }
    if (selectionEnabled()) {
      const selectCol = {
        id: SELECT_COL_ID,
        enableSorting: false,
        enableColumnFilter: false,
        filterable: false,
        isSelectColumn: true,
        pinned: '',
        width: '',
        // Explicit narrow size so table-core's sticky-offset math (getStart('left')) matches the
        // rendered checkbox width once this column joins the left-pinned rail — see the expander
        // note above. Keep in sync with the `--rdt-select-col-width` CSS default (44px).
        size: 44
      };
      return [selectCol].concat(withExpander);
    }
    return withExpander;
  }

  // ── sorting slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────────
  // table-core hands an Updater<SortingState> = value | (old)=>new; the onSortingChange
  // callback applies it against the CURRENT sorting, then this funnel writes a FRESH
  // array to the uncontrolled default + the two-way model + fires the change event
  // REGARDLESS of binding. STATIC key (`$data.sortingDefault` / `$model.sorting`) — a
  // dynamic-key funnel is ROZ106 on all six. The remaining 8 slices each get their own
  // such funnel in Plans 04/05.
  function writeSorting(next: any) {
    if (programmatic) return;
    programmatic++;
    setSortingDefault(next); // fresh array only (never in-place)
    setSorting(next); // two-way emit if bound (no-op-diff if not)
    _props.onSortChange?.(next);
    programmatic--;
  }
  function applyUpdater(updater: any, current: any) {
    return typeof updater === 'function' ? updater(current) : updater;
  }

  // ── expanded slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────────
  // table-core hands an Updater<ExpandedState> = value | (old)=>new; onExpandedChange
  // applies it against the CURRENT expanded, then this funnel writes a FRESH value to the
  // uncontrolled default + the two-way model + fires `expanded-change` REGARDLESS of binding.
  // `next` may be the `true` expand-all literal OR a { [rowId]: true } object — written
  // verbatim (Pitfall 2). One emit per change (the shared `programmatic` guard dedups the
  // React multi-render re-entry, D-07). STATIC key ($data.expandedDefault / $model.expanded).
  function writeExpanded(next: any) {
    if (programmatic) return;
    programmatic++;
    // Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
    // the user now owns the expanded state, so currentState() stops defaulting grouped rows to
    // the `true` expand-all literal and honors $data.expandedDefault from here on.
    expandedTouched = true;
    setExpandedDefault(next); // fresh value only (never in-place)
    setExpanded(next); // two-way emit if bound (no-op-diff if not)
    // Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
    // prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
    // interface, and an `expanded-change` event would camelCase to the SAME identifier
    // → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
    // sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
    // sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
    _props.onExpandChange?.(next);
    programmatic--;
  }

  // ── grouping slice: STATIC-KEY fresh-array echo-guarded write funnel (phase 50 reqs 4-7) ──
  // table-core hands an Updater<GroupingState> = value | (old)=>new; onGroupingChange applies it
  // against the CURRENT grouping, then this funnel writes a FRESH ordered array to the
  // uncontrolled default + the two-way model + fires `group-change` REGARDLESS of binding. One
  // emit per change (the shared `programmatic` guard dedups the React multi-render re-entry, D-07).
  // STATIC key ($data.groupingDefault / $model.grouping). Event stem is `group-change`, NOT
  // `grouping-change`: the model:true `grouping` prop auto-generates an `onGroupingChange` callback
  // on the React/Solid flat Props interface, and a `grouping-change` event would camelCase to the
  // SAME identifier → duplicate-identifier TS2300 (the model-prop==emit-name collision class 50-02
  // hit with expanded/expanded-change → expand-change). Every sibling slice stems off a DISTINCT
  // name (sorting→sort-change, rowSelection→selection-change); grouping→group-change follows suit.
  function writeGrouping(next: any) {
    if (programmatic) return;
    programmatic++;
    setGroupingDefault(next); // fresh ordered array only (never in-place push)
    setGrouping(next); // two-way emit if bound (no-op-diff if not)
    _props.onGroupChange?.(next);
    programmatic--;
  }

  // ── globalFilter slice: STATIC-KEY fresh-value echo-guarded write funnel (A4) ──────
  // A fresh string (primitive) to the uncontrolled default + the two-way model + fires
  // `filter-change` REGARDLESS of binding.
  function writeGlobalFilter(next: any) {
    if (programmatic) return;
    programmatic++;
    setGlobalFilterDefault(next);
    setGlobalFilter(next);
    _props.onFilterChange?.({
      globalFilter: next
    });
    programmatic--;
  }

  // ── columnFilters slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ─────
  // table-core hands ColumnFiltersState = [{ id, value }]; write a FRESH array (never
  // in-place push) + fire `filter-change`. globalFilter + columnFilters both surface
  // through `filter-change` (per the plan: filter-change fires regardless of binding).
  function writeColumnFilters(next: any) {
    if (programmatic) return;
    programmatic++;
    setColumnFiltersDefault(next);
    setColumnFilters(next);
    _props.onFilterChange?.({
      columnFilters: next
    });
    programmatic--;
  }

  // ── pagination slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ───────
  // table-core hands { pageIndex, pageSize }; write a FRESH object + fire `page-change`.
  function writePagination(next: any) {
    if (programmatic) return;
    programmatic++;
    setPaginationDefault(next);
    setPagination(next);
    _props.onPageChange?.(next);
    programmatic--;
  }

  // ── rowSelection slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
  // table-core hands RowSelectionState = { [rowId]: true }; write a FRESH object (never
  // in-place key-set) + fire `selection-change` REGARDLESS of binding.
  function writeRowSelection(next: any) {
    if (programmatic) return;
    programmatic++;
    setRowSelectionDefault(next);
    setRowSelection(next);
    _props.onSelectionChange?.(next);
    programmatic--;
  }

  // ── columnVisibility slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──
  // table-core hands VisibilityState = { [colId]: boolean }; write a FRESH object (never
  // in-place key-set) + fire `visibility-change` REGARDLESS of binding.
  function writeColumnVisibility(next: any) {
    if (programmatic) return;
    programmatic++;
    setColumnVisibilityDefault(next);
    setColumnVisibility(next);
    _props.onVisibilityChange?.(next);
    programmatic--;
  }

  // ── columnSizing slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ──────
  // table-core hands ColumnSizingState = { [colId]: number }; the pointer-drag resize
  // handle funnels a FRESH sizing object + fires `resize-change` REGARDLESS of binding.
  function writeColumnSizing(next: any) {
    if (programmatic) return;
    programmatic++;
    setColumnSizingDefault(next);
    setColumnSizing(next);
    _props.onResizeChange?.(next);
    programmatic--;
  }

  // ── columnOrder slice: STATIC-KEY fresh-array echo-guarded write funnel (A4) ────────
  // table-core hands ColumnOrderState = string[]; write a FRESH order array (never an
  // in-place splice) + fire `reorder-change` REGARDLESS of binding.
  function writeColumnOrder(next: any) {
    if (programmatic) return;
    programmatic++;
    setColumnOrderDefault(next);
    setColumnOrder(next);
    _props.onReorderChange?.(next);
    programmatic--;
  }

  // ── columnPinning slice: STATIC-KEY fresh-object echo-guarded write funnel (A4) ─────
  // table-core hands ColumnPinningState = { left: string[], right: string[] }; write a
  // FRESH object (never in-place push into left/right) + fire `pin-change` REGARDLESS of
  // binding.
  function writeColumnPinning(next: any) {
    if (programmatic) return;
    // effectiveColumnPinning() forces the auto-injected chrome ids (select/expander) into the
    // table-core `left` rail, so table-core hands them back here on every pin change. Strip them
    // before persisting: the CONSUMER's columnPinning model + the pin-change event must reflect
    // only their own columns, never our internal rail ids (which re-inject each render anyway).
    const strip = (ids: any) => (ids || []).filter((id: any) => id !== SELECT_COL_ID && id !== EXPANDER_COL_ID);
    const clean = {
      ...next,
      left: strip(next && next.left),
      right: strip(next && next.right)
    };
    programmatic++;
    setColumnPinningDefault(clean);
    setColumnPinning(clean);
    _props.onPinChange?.(clean);
    programmatic--;
  }

  // ── data slice: STATIC-KEY fresh-array echo-guarded write funnel (Phase 51 req-4) ──
  // A committed cell/row edit (or paste/fill in a later wave) replaces ONE row object in
  // a FRESH array and funnels it here. Writes the uncontrolled default + the two-way
  // model so editing works controlled OR uncontrolled. CRITICAL: writeData does NOT emit —
  // unlike the 9 state slices (each has one change event fired inside its funnel), the
  // `data` slice's commit event (`cell-edit-commit`) carries a PER-CELL payload and fires
  // from the SINGLE commitEdit call site so the count stays exactly one per commit (React
  // multi-emit dedup, D-07). Echo-guarded by the shared `programmatic` counter so the
  // re-feed watch never re-enters mid-write.
  //
  // 260709-8ct (grid-wide undo/redo): record the PRE-mutation snapshot BEFORE writing, but
  // ONLY when `$props.undoable` is on AND we are not mid-replay (`!restoringHistory` — an
  // undo()/redo() call routes back through THIS SAME writeData to reuse the two-way model +
  // re-feed watch; without the guard the replay would re-record itself and corrupt the
  // stack). `emitHistoryChangeIfEdged` fires `history-change` only when canUndo/canRedo
  // availability actually flipped (a long streak of edits that doesn't change availability
  // must not spam consumers).
  //
  // External-swap origin marker: stamp EVERY array we write (undoable or not, incl. an undo/
  // redo replay) with the durable, non-enumerable marker under DATA_WRITE_TOKEN_KEY. The reset
  // (maybeClearHistoryOnExternalSwap in DataTable.rozie) clears history only when a new $props.data
  // lacks the marker → it did not come from us → a genuine external swap. This replaces the
  // `dataWriteSettling` settle-window flag that a slow re-feed on a large controlled table outran
  // (#8) — see DATA_WRITE_TOKEN_KEY's declaration in DataTable.rozie for the four flag/timer
  // variants it supersedes and why the marker is timing-independent. Stamped on the fresh `next`
  // array (never the consumer's original), non-enumerable so JSON.stringify / spread / Object.keys
  // never see it. We write a FRESH RAW shallow copy (`fresh`) so the marker lands on an UNWRAPPED
  // array: an undo/redo replay reuses a snapshot that, in controlled mode, is a framework reactive
  // PROXY (svelte `$state`, vue `reactive`) — and `Object.defineProperty` does NOT reliably stick
  // through a proxy's trap, so stamping the raw copy (never the possibly-proxied `next`) keeps the
  // marker readable. Normal edits already pass a fresh array; the copy shares row references (cheap).
  // `try` guards the (never-expected) frozen/sealed-array case.
  function writeData(next: any) {
    if (programmatic) return;
    if (local.undoable && !restoringHistory) {
      const prevU = canUndo();
      const prevR = canRedo();
      recordSnapshot(currentData());
      emitHistoryChangeIfEdged(prevU, prevR);
    }
    const fresh = Array.isArray(next) ? next.slice() : next;
    try {
      Object.defineProperty(fresh, DATA_WRITE_TOKEN_KEY, {
        value: true,
        enumerable: false,
        configurable: true,
        writable: true
      });
    } catch (_e: any) {/* a frozen/sealed array can't be stamped — our fresh arrays never are */}
    programmatic++;
    setDataDefault(fresh); // fresh raw array only (never in-place, never a proxy)
    setData(fresh); // two-way emit if bound (no-op-diff if not)
    programmatic--;
  }

  // Read the live columnFilters value for a given column id (string-safe; drives the
  // per-column filter input's bound value). Reads currentState() (NOT a $data re-read
  // of a just-written key → React stale-read safe).
  function columnFilterValue(colId: any) {
    const cf = currentState().columnFilters || [];
    for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
    return '';
  }

  // Apply a per-column filter value: build a FRESH ColumnFiltersState array (drop the
  // column's prior entry, append the new one unless empty) and funnel it. Never mutate
  // the existing array in place (silent on React/Solid/Angular/Lit).
  function setColumnFilter(colId: any, value: any) {
    const prev = currentState().columnFilters || [];
    const next = [];
    for (const f of prev as any) if (f && f.id !== colId) next.push(f);
    if (value != null && value !== '') next.push({
      id: colId,
      value
    });
    writeColumnFilters(next);
  }

  // ── grid-wide undo/redo (260709-8ct) — snapshot-stack history engine ──────────────────────
  // Per the approved design (docs/superpowers/specs/2026-07-09-data-table-undo-history-design.md,
  // decisions 1-6, LOCKED). A pure, unit-testable buffer over the FOUR history lets declared
  // top-level in DataTable.rozie beside `let programmatic = 0` (undoStack/redoStack/
  // restoringHistory/lastPropsData — NOT $data, so recording an edit causes no reactive
  // re-render churn on every keystroke). This module holds the FUNCTIONS only; it references
  // those component-scope lets + `$props`, `$emit`, `writeData`, `currentData` BARE (by name,
  // zero ES imports) — the SAME inlined-partial pattern writeFunnels.rzts uses for
  // `programmatic`/`$data`/`$model` (DataTable.rozie is the ONLY place that ES-imports across
  // .rzts partials; a cross-import between writeFunnels and undoHistory would create an ES
  // cycle and/or a TDZ on the inlined lets).
  //
  // Collision-safe (ROZ121/124/137): none of undo/redo/canUndo/canRedo/clearHistory are
  // HTMLElement methods, model props, or React auto-generated setters.

  // Push the PRE-mutation snapshot (a `data` array reference — never a deep copy; unchanged
  // rows are shared across every retained snapshot because every write funnel already builds a
  // fresh array reusing unchanged row references, per the design's Memory analysis). Evict the
  // oldest snapshot once the stack exceeds `undoLimit` (default 100 — DataTable.rozie prop).
  // Any NEW recording invalidates the redo stack (standard undo semantics).
  function recordSnapshot(current: any) {
    undoStack.push(current);
    const limit = local.undoLimit != null ? local.undoLimit : 100;
    while (undoStack.length > limit) undoStack.shift();
    redoStack = [];
  }
  function canUndo() {
    return undoStack.length > 0;
  }
  function canRedo() {
    return redoStack.length > 0;
  }

  // Both stacks empty — the external-swap latch (DataTable.rozie reFeed) and the
  // clearHistory() $expose verb share this single implementation.
  function clearHistory() {
    undoStack = [];
    redoStack = [];
  }

  // `$emit('history-change', { canUndo, canRedo })` — the imperative/keyboard $expose verb
  // contract. Unconditional (used by undo()/redo() themselves, which always fire exactly once
  // per call per the design — NOT edge-gated there; only the writeData-triggered recording path
  // below is edge-gated, since a routine sequence of edits would otherwise spam the event).
  function emitHistoryChange() {
    _props.onHistoryChange?.({
      canUndo: canUndo(),
      canRedo: canRedo()
    });
  }

  // Fire `history-change` ONLY when canUndo/canRedo availability flipped since `prevU`/`prevR`
  // were captured (BEFORE recordSnapshot ran). Called from writeData's recording hook so a
  // long streak of edits that doesn't change availability (canUndo already true, redo already
  // empty) does not spam consumers with a no-op event per keystroke.
  function emitHistoryChangeIfEdged(prevU: any, prevR: any) {
    const nextU = canUndo();
    const nextR = canRedo();
    if (nextU !== prevU || nextR !== prevR) emitHistoryChange();
  }

  // undo(): pop the most recent pre-mutation snapshot, push the CURRENT data onto the redo
  // stack (so redo can restore it), then replay the popped snapshot through the SAME writeData
  // seam — under `restoringHistory = true` so writeData's own recording hook does not
  // re-capture this replay (which would corrupt the stack). Replaying through writeData
  // (rather than writing $data/$model directly) is deliberate: the two-way $model.data
  // writeback, the re-feed $watch, and the echo guard all keep working with zero new code.
  function undo() {
    if (!canUndo()) return;
    const prev = undoStack.pop();
    redoStack.push(currentData());
    restoringHistory = true;
    writeData(prev);
    restoringHistory = false;
    emitHistoryChange();
  }

  // redo(): symmetric — pop the redo stack, push the CURRENT data back onto the undo stack,
  // replay through the same guarded writeData seam.
  function redo() {
    if (!canRedo()) return;
    const next = redoStack.pop();
    undoStack.push(currentData());
    restoringHistory = true;
    writeData(next);
    restoringHistory = false;
    emitHistoryChange();
  }

  // Re-read the row model + header groups into $data (fresh arrays → the template
  // re-renders). A plain fn (NOT a $computed — getRowModel() must be pulled AFTER a
  // setOptions re-feed, imperatively). Defined inside $onMount so it captures the live
  // `table`.
  let refreshRowModel: any = null;

  // PER-SLICE callbacks hoisted to top-level consts (NOT inlined in createTable) so the
  // re-feed $watch can re-pass them on every setOptions. On React the createTable
  // callbacks would otherwise capture the MOUNT-render's currentState() closure (table
  // instance is built once in $onMount); table-core's setOptions keeps the prior
  // callbacks unless new ones are supplied, so a stale callback applied each updater
  // against the mount-time empty slice → the sort cycle never advances + multi-row
  // selection collapses to the last row (React stale-closure, F6). Re-passing these
  // fresh (recreated each render on React, reading fresh currentState) in the re-feed
  // keeps the Updater base value current. No-op cost on the other five.
  function onSortingChangeCb(updater: any) {
    writeSorting(applyUpdater(updater, currentState().sorting));
  }
  function onExpandedChangeCb(updater: any) {
    writeExpanded(applyUpdater(updater, currentState().expanded));
  }
  function onGroupingChangeCb(updater: any) {
    writeGrouping(applyUpdater(updater, currentState().grouping));
  }
  function onGlobalFilterChangeCb(updater: any) {
    writeGlobalFilter(applyUpdater(updater, currentState().globalFilter));
  }
  function onColumnFiltersChangeCb(updater: any) {
    writeColumnFilters(applyUpdater(updater, currentState().columnFilters));
  }
  function onPaginationChangeCb(updater: any) {
    writePagination(applyUpdater(updater, currentState().pagination));
  }
  function onRowSelectionChangeCb(updater: any) {
    writeRowSelection(applyUpdater(updater, currentState().rowSelection));
  }
  function onColumnVisibilityChangeCb(updater: any) {
    writeColumnVisibility(applyUpdater(updater, currentState().columnVisibility));
  }
  function onColumnSizingChangeCb(updater: any) {
    writeColumnSizing(applyUpdater(updater, currentState().columnSizing));
  }
  function onColumnOrderChangeCb(updater: any) {
    writeColumnOrder(applyUpdater(updater, currentState().columnOrder));
  }
  function onColumnPinningChangeCb(updater: any) {
    writeColumnPinning(applyUpdater(updater, currentState().columnPinning));
  }
  function onColumnSizingInfoChangeCb(updater: any) {
    const next = applyUpdater(updater, columnSizingInfo());
    setColumnSizingInfo(next != null ? next : columnSizingInfo());
  }

  // ══ Vertical row windowing (phase 53, req-1/2/3/6/9/10) — the virtual-core bridge ════════
  // virtual-core is a pure state machine EXACTLY like table-core: constructed once in $onMount
  // (ONLY when $props.virtual), its imperative onChange push converted to per-target reactivity
  // via the SEPARATE $data.windowVer tick, re-fed via setOptions()+_willUpdate() in the
  // refreshRowModel path (NEVER a render helper — Pitfall 1). Every runtime reference is guarded
  // so the virtual=false emitted path is dead (req-1).
  //
  // Phase 64 (D-04): the PURE windowing math (windowedRows / padTop / padBottom / pmIndexInWindow /
  // rowIsOutsideWindow / virtualizerOptions / virtualItemKey) now lives in the shared, target-agnostic
  // `@rozie-ui/headless-core/windowing.rzts` partial and is re-exported below — this file is now the
  // thin DATA-TABLE HOST SHELL holding only the impure, per-consumer pieces (the table-bound row
  // source + the DOM/refs/virtualizer-instance machinery + the D-05 edit-pinning hook). The math
  // dissolves in via inlineScriptPartials() byte-identically; behavior is unchanged (the B13 specs +
  // dist-parity are the net). The host satisfies the windowing.rzts contract by convention:
  // windowSource() (the row source), pinnedEditIndex()/pinnedMeasurement() (the D-05 pin hook),
  // scheduleRemeasure(), and the gridScrollEl/virtualizer/virtual-core-fn references.

  // windowSource(): the rows fed to the virtualizer AND held in $data.rows — the windowing.rzts
  // host-contract source. When virtual, the FULL filtered+sorted PRE-PAGINATION model
  // (A2-verified table.getPrePaginationRowModel()) so windowing REPLACES client pagination (req-9);
  // else the normal (paginated) row model — the non-virtual path is byte-unchanged.
  function windowSource() {
    if (!table) return [];
    if (local.virtual) return table.getPrePaginationRowModel().rows;
    return table.getRowModel().rows;
  }

  // Defer remeasureWindow() until AFTER the framework commits the recycled window (onChange fires
  // BEFORE React/Solid commit), falling back to a microtask/timeout where rAF is unavailable (SSR /
  // test envs). DEDUPED via remeasurePending so a scroll burst queues at most one in-flight sweep
  // (piled-up rAF sweeps broke the Solid scroll-then-focus seam — and the focus seam itself now
  // polls for its target cell, so it no longer depends on remeasure timing).
  //
  // TWO deferred passes (microtask THEN rAF), both behind the single in-flight flag:
  //   - Solid's <For> / Svelte's {#each} commit the recycled <tr> set SYNCHRONOUSLY in the reactive
  //     tick that the windowVer bump triggers, so the recycled nodes already exist by the next
  //     microtask — measuring there observes them while they are still connected, BEFORE the next
  //     fast-scroll step recycles them away. A single rAF (a full frame later) was too late on the
  //     fine-grained targets under a 40ms-per-step scroll: many rows mounted-and-recycled within one
  //     frame, so the once-per-frame rAF sweep observed only a fraction of them and the measured
  //     total under-converged (the Solid ~23.5k-vs-≥24k residual). The microtask catches them.
  //   - React's setState→reconcile→commit is async (a microtask is too early — the new window is not
  //     committed yet), so the rAF pass is what observes React's recycled rows.
  // Each pass only OBSERVES + measures the live window; measureElement is idempotent on an
  // already-observed node, so running both is cheap and loop-free.
  function scheduleRemeasure() {
    if (remeasurePending) return;
    remeasurePending = true;
    let ranMicro = false;
    const microPass = () => {
      remeasureWindow();
    };
    const rafPass = () => {
      remeasurePending = false;
      remeasureWindow();
    };
    if (typeof queueMicrotask !== 'undefined') {
      ranMicro = true;
      queueMicrotask(microPass);
    }
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) remeasurePending = false;else setTimeout(rafPass, 0);
  }

  // pinnedEditIndex(): the FULL-MODEL row index of the row currently in edit (D-02 pin-row),
  // or -1 when no editor is open. Under virtualization `$data.rows` is the FULL pre-pagination
  // model, so editingRow (single-cell) / editingRowIndex (full-row) — both in that index space —
  // ARE the full-model index. The pinned row must never recycle while editing (req-9): it is
  // unioned into the windowed slice when it scrolls off-window and its height is subtracted from
  // the appropriate spacer so the total stays exactly getTotalSize() (the 51-01-proven mechanism).
  // This is the data-table half of the D-05 windowing.rzts pin-extension hook (listbox provides none).
  function pinnedEditIndex() {
    if (editingRow() >= 0) return editingRow();
    if (editingRowIndex() != null) return editingRowIndex();
    return -1;
  }
  // pinnedMeasurement(pin): the virtual-core measurement { index, start, size, end, key } for the
  // pinned full-model index — its measured (or estimated) height + offset, used to (a) decide
  // whether it sits above/below the rendered window and (b) subtract its height from the right
  // spacer. Null when out of range / not virtual.
  function pinnedMeasurement(pin: any) {
    if (!virtualizer || pin < 0) return null;
    const ms = virtualizer.getMeasurements();
    return ms && ms[pin] ? ms[pin] : null;
  }

  // measureElement sweep (D-10 / CR-01): refine estimated heights to MEASURED ones. The off-root
  // querySelector idiom (chartjs/cropper/embla precedent — no per-row callback ref). Each rendered
  // <tr> MUST be handed to virtualizer.measureElement on every window commit for it to be observed:
  // virtual-core does NOT auto-register rendered rows — measureElement is the SOLE caller of its
  // internal ResizeObserver's observe() (virtual-core@3.17.1 dist/esm/index.js:794-817), keyed by
  // getItemKey. So this sweep must run not just once at mount but on every onChange tick (via
  // scheduleRemeasure), or recycled rows keep the estimateRowHeight seed forever. measureElement is
  // idempotent on an already-observed node (the `prevNode !== node` guard), so re-sweeping the
  // visible window each commit is cheap and loop-free.
  function remeasureWindow() {
    if (!virtualizer || !gridRoot) return;
    // Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
    // exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
    // null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
    // scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
    // resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
    // regression); the next settled onChange re-measures the stable window. Manual-scroll
    // recycling (the CR-01 case) has scrollState === null, so it measures normally.
    if (virtualizer.scrollState) return;
    const trs = gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
    for (const tr of trs as any) virtualizer.measureElement(tr);
  }

  // D-04: this shell exports ONLY the impure, data-table-specific host pieces. The pure windowing
  // math (windowedRows / padTop / padBottom / pmIndexInWindow / rowIsOutsideWindow / virtualizerOptions
  // / virtualItemKey) is imported DIRECTLY by the host (DataTable.rozie) from
  // `@rozie-ui/headless-core/windowing.rzts` via bare specifier — the P0-proven cross-package inline
  // path that DISSOLVES the partial into the leaf (a re-export-from THROUGH this shell would survive as
  // a runtime import, not inline — verified). The math closes over these host symbols by convention.
  // ══ Generic vertical windowing math (Phase 64, D-04) — the target-agnostic virtual-core bridge ══
  // Lifted verbatim from the DataTable virtualization.rzts (the Phase 53/63 B13 baseline). This partial
  // holds ONLY the PURE windowing math; every DOM/refs/virtualizer-instance impurity stays per-consumer
  // in the host (ROZ123). It is a compile-time `.rzts` script-partial: it dissolves into each consumer's
  // compiled leaf via inlineScriptPartials() before IR lowering — leaving zero runtime dependency.
  //
  // HOST CONTRACT (symbols the consuming host MUST define before importing — the same implicit
  // by-convention mixin contract the DataTable host's other partials already use for `$data.windowVer`):
  //   - windowSource(): T[]   — the full list to window (the KEY generalization; the DataTable host
  //                             returns its pre-pagination row model, listbox/combobox return the
  //                             filtered options). This partial MUST NOT reach into the host data engine
  //                             directly — rows arrive ONLY through windowSource().
  //   - $props.estimateRowHeight — per-item size estimate (kept aliased for DataTable back-compat).
  //   - $data.windowVer / $data.editVer — window/edit-version reactivity bumps.
  //   - gridScrollEl              — the scroll-container element handle.
  //   - virtualizer               — the host virtual-core instance (built in $onMount from the ref).
  //   - observeElementRect / observeElementOffset / elementScroll / measureElement — virtual-core fns.
  //   - scheduleRemeasure()       — the host's rAF/microtask remeasure defer.
  //   - pinnedEditIndex() / pinnedMeasurement(pin) — the D-05 OPTIONAL pin-extension hook (host-provided,
  //                             defaulting to no-op): the DataTable host passes its edit-pinning hooks;
  //                             listbox passes nothing. Routing pinning through this host hook (NOT
  //                             inlining it) keeps DataTable's B13 edit-pinning behavior byte-identical.

  // getItemKey reads the LIVE source (never a frozen mount-render $data.rows closure — the F6
  // React stale-closure lesson) so virtual-core's measurement cache keys by stable full-model row
  // id across recycling, aligned with the windowed <tr> :key="row.id" (Pitfall 3 / req-10).
  function virtualItemKey(i: any) {
    const src = windowSource();
    return src && src[i] ? src[i].id : undefined;
  }

  // The FULL virtualizer options. virtual-core's setOptions REPLACES options with
  // `{ ...defaults, ...opts }` (it does NOT merge with prior options — verified in the 3.17.1
  // source), so the re-feed MUST pass the complete set, exactly like every TanStack adapter.
  // Returned `any` (the currentState() precedent) so the strict bundled-leaf tsc does not choke
  // on virtual-core's generic option inference. onChange uses the `$data.x = $data.x + 1`
  // increment the React emitter lowers to functional setState — correct even from a mount closure.
  function virtualizerOptions(): any {
    return {
      count: windowSource().length,
      getScrollElement: () => gridScrollEl,
      estimateSize: () => local.estimateRowHeight,
      observeElementRect,
      observeElementOffset,
      scrollToFn: elementScroll,
      measureElement,
      overscan: 8,
      getItemKey: virtualItemKey,
      onChange: () => {
        setWindowVer(windowVer() + 1);
        // CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
        // virtual-core only observe()s a node you explicitly hand to measureElement (it does
        // NOT auto-discover rendered rows — measureElement is the SOLE caller of
        // observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
        // into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
        // estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
        // so the new <tr> set is in the DOM before we measure. Safe from an infinite
        // measure→onChange→measure loop: measureElement is idempotent on an already-observed
        // node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
        // measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
        // re-measure is a no-op.
        scheduleRemeasure();
      }
    };
  }

  // pinMeasurement(pin): the D-05 pin-hook read, RE-TYPED at the windowing layer so the
  // shared math is strict-clean across every host. The host-provided pinnedMeasurement() has
  // two shapes: the DataTable host returns a real virtual-core measurement; the listbox/combobox
  // no-op host returns bare `null` (inferred `(pin) => null`). Calling it directly makes
  // `const pm = pinnedMeasurement(pin)` flow-narrow to `null`, so the downstream `pm && pm.start`
  // guard collapses the object branch to `never` (TS2339, Class 3). Reading the hook through this
  // thin wrapper with an EXPLICIT return type (a return-type annotation is NOT flow-narrowed)
  // gives the measurement a real object-or-null shape, so `pm && pm.start` keeps the object branch.
  // Typing-only: the runtime value (a measurement or null) is unchanged.
  function pinMeasurement(pin: number): {
    start: number;
    size: number;
    index: number;
    end: number;
  } | null {
    return pinnedMeasurement(pin);
  }

  // windowedRows(): the rendered slice. Off / pre-mount → the full $data.rows mapped to
  // { vi:null, row } (the r-else path never calls this, but the guard keeps it total). On → read
  // $data.windowVer to SUBSCRIBE (the rowIndexOf tick discipline) then map each VirtualItem to its
  // full-model row. NB the local is `rowList` (NOT `rows` — React lowers $data.rows to a bare
  // `rows` binding → TS2448 self-shadow, line ~1149 lesson).
  function windowedRows() {
    // SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
    // early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
    // which happens at initial render while `virtualizer` is still null (it is built in $onMount,
    // after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
    // BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
    // signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
    // blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
    // placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
    // first re-run that picks up the now-non-null virtualizer.
    // ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
    // pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
    void windowVer();
    void editVer();
    if (!virtualizer) {
      // Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
      // but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
      // the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
      // rows appear on the first onChange after _didMount.
      if (!local.virtual) {
        const rowList = rows() || [];
        return rowList.map((r: any) => ({
          vi: null,
          row: r
        }));
      }
      return [];
    }
    const items = virtualizer.getVirtualItems();
    const rowList = rows() || [];
    // WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
    // shrink window where the virtualizer count is stale relative to $data.rows on the async
    // onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
    // throw "Cannot read properties of undefined"; filter it here so the template never sees it.
    const out = items.map((vi: any) => ({
      vi,
      row: rowList[vi.index]
    })).filter((wr: any) => wr.row);
    // ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
    // window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
    // into another full-model row), LEADING the slice when it sits above the window and TRAILING
    // it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
    // padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
    // into the real windowing.
    const pin = pinnedEditIndex();
    if (pin >= 0 && rowList[pin]) {
      let inWindow = false;
      for (let i = 0; i < items.length; i++) {
        if (items[i].index === pin) {
          inWindow = true;
          break;
        }
      }
      if (!inWindow) {
        const pm = pinMeasurement(pin);
        const firstStart = items.length ? items[0].start : 0;
        const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
        const pinnedEntry = {
          vi: pm != null ? pm : {
            index: pin
          },
          row: rowList[pin],
          pinned: true
        };
        if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
      }
    }
    return out;
  }

  // Spacer-<tr> heights (D-03): the leading spacer occupies items[0].start; the trailing spacer
  // the gap between the last rendered item's end and getTotalSize(). Both windowVer-gated reads
  // (the `$data.windowVer` touch re-derives them as the window/measurements change). 0 when off.
  function padTop() {
    // SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
    // spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
    // and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
    void windowVer();
    void editVer();
    if (!local.virtual || !virtualizer) return 0;
    const items = virtualizer.getVirtualItems();
    let pad = items.length ? items[0].start : 0;
    // D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
    // in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
    // that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
    const pin = pinnedEditIndex();
    if (pin >= 0) {
      const pm = pinMeasurement(pin);
      const inWindow = pmIndexInWindow(items, pin);
      if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
    }
    return pad < 0 ? 0 : pad;
  }
  function padBottom() {
    // subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
    // return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
    // on pin/unpin.
    void windowVer();
    void editVer();
    if (!local.virtual || !virtualizer) return 0;
    const items = virtualizer.getVirtualItems();
    if (!items.length) return 0;
    let pad = virtualizer.getTotalSize() - items[items.length - 1].end;
    // D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
    // in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
    const pin = pinnedEditIndex();
    if (pin >= 0) {
      const pm = pinMeasurement(pin);
      const inWindow = pmIndexInWindow(items, pin);
      // WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
      // measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
      // index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
      // The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
      // the offset comparison only if the measurement lacks an index (defensive).
      const lastItemIdx = items[items.length - 1].index;
      const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
      if (pm && !inWindow && below) {
        // below the window → it trailed the slice; subtract its height from the trailing spacer.
        if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
      }
    }
    return pad < 0 ? 0 : pad;
  }
  // pmIndexInWindow: is full-model index `idx` present in the rendered virtual window?
  function pmIndexInWindow(items: any, idx: any) {
    for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
    return false;
  }
  // rowIsOutsideWindow(r): is the full-model row index r absent from the currently rendered
  // window? Used by the scroll-then-focus seam (req-5 — scroll a far row in before focusing).
  function rowIsOutsideWindow(r: any) {
    if (!local.virtual || !virtualizer) return false;
    const items = virtualizer.getVirtualItems();
    for (const it of items as any) if (it.index === r) return false;
    return true;
  }

  // ── Sort/filter live-announcement (#14) ─────────────────────────────────────────────
  // A polite aria-live announcement whenever the consumer changes sorting or filtering, so a
  // screen-reader user hears that the rows were reordered / narrowed (which is otherwise silent).
  // announceState holds the last-seen references so the lazy watch below can tell WHICH slice
  // changed (sort vs filter) and pick the message. It is a top-level mutable const → stabilized
  // once per instance on all six targets (React useMemo-wraps a mutable instance; the others run
  // setup once), so it PERSISTS across renders — unlike a top-level `let`, which React resets per
  // render. Seeded from the initial state in $onMount so the first (post-mount) change compares
  // against the true starting values, not a null sentinel.
  // Typed as `unknown` members: these hold opaque last-seen references compared only by
  // identity (!==) below, never read in a typed context — the annotation keeps the null seed
  // from narrowing the members to `null` (which would reject the real reassignments under
  // strictNullChecks in the emitted leaves).
  const announceState: {
    sorting: unknown;
    columnFilters: unknown;
    globalFilter: unknown;
  } = {
    sorting: null,
    columnFilters: null,
    globalFilter: null
  };
  // Effective (controlled-or-uncontrolled) reads of the sort/filter slices: the bound prop when
  // the consumer bound the matching r-model, else the uncontrolled $data default (mirrors currentState()).
  function effectiveSorting() {
    return sorting() != null ? sorting() : sortingDefault();
  }
  function effectiveColumnFilters() {
    return columnFilters() != null ? columnFilters() : columnFiltersDefault();
  }
  function effectiveGlobalFilter() {
    return globalFilter() != null ? globalFilter() : globalFilterDefault();
  }
  // Build the polite message for a sort/filter change and advance announceState. Sort takes
  // precedence when the sorting reference changed; otherwise a filter changed → the post-filter
  // result count (the FILTERED total via totalRowCount(), NOT the page slice). Returns '' when
  // neither actually changed (a no-op watch tick — do not re-announce).
  function buildSortFilterAnnounce() {
    const nextSorting = effectiveSorting();
    const nextColumnFilters = effectiveColumnFilters();
    const nextGlobalFilter = effectiveGlobalFilter();
    const sortChanged = nextSorting !== announceState.sorting;
    const filterChanged = nextColumnFilters !== announceState.columnFilters || nextGlobalFilter !== announceState.globalFilter;
    announceState.sorting = nextSorting;
    announceState.columnFilters = nextColumnFilters;
    announceState.globalFilter = nextGlobalFilter;
    if (sortChanged) {
      const active = nextSorting && nextSorting.length ? nextSorting[0] : null;
      if (!active) return 'Sorting cleared';
      const rawLabel = headerLabel(active.id);
      const label = typeof rawLabel === 'string' && rawLabel ? rawLabel : active.id;
      return 'Sorted by ' + label + ', ' + (active.desc ? 'descending' : 'ascending');
    }
    if (filterChanged) {
      return totalRowCount() + ' results';
    }
    return '';
  }
  // Push fresh options into table-core + re-pull the row model. Extracted so BOTH the
  // re-feed $watch (above) and the Lit data-change $onUpdate (below) call it.
  function reFeed() {
    if (!table) return;
    // NOTE: the external-swap history reset does NOT live here. reFeed() fires on EVERY watched
    // change — including our OWN synchronous internal `$data.dataDefault` write — so a clear keyed
    // on a `currentData()` read here would (on fine-grained targets) fire mid-round-trip against a
    // TRANSIENTLY-STALE `$props.data` and wrongly wipe a just-recorded edit's history. The reset is
    // keyed on the `$props.data` REFERENCE actually changing instead — see the $onUpdate backstop
    // below (`maybeClearHistoryOnExternalSwap`), which runs on all six targets.
    table.setOptions((prev: any) => ({
      ...prev,
      data: currentData(),
      columns: tableColumns(),
      state: currentState(),
      enableRowSelection: local.selectionMode !== 'none',
      enableMultiRowSelection: local.selectionMode === 'multiple',
      // Re-pass the server-side page-count sources (#2) so a RUNTIME rowCount/pageCount change
      // takes effect: setOptions REPLACES via `...prev`, which holds the value captured at
      // createTable time, so an omitted key would freeze the mount-time count. The re-feed
      // $watch keys on both props below.
      rowCount: local.rowCount ?? undefined,
      pageCount: local.pageCount ?? undefined,
      // Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
      // setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
      // onExpandedChange callback must re-capture fresh currentState each cycle, F6).
      getExpandedRowModel: getExpandedRowModel(),
      getSubRows: (local.getSubRows || undefined) as any,
      getRowCanExpand: local.expandable === true && local.getSubRows == null ? () => true : undefined,
      onExpandedChange: onExpandedChangeCb,
      // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
      // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
      // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
      // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
      // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
      // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
      // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
      // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
      // are unaffected (they fire regardless of this flag).
      autoResetExpanded: false,
      // Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
      // omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
      // fresh currentState each cycle, F6).
      getGroupedRowModel: getGroupedRowModel(),
      onGroupingChange: onGroupingChangeCb,
      // Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
      // drop the model on re-feed; on React the faceted closures must re-capture so exposed
      // unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
      getFacetedRowModel: getFacetedRowModel(),
      getFacetedUniqueValues: makeFacetedUniqueValues(),
      getFacetedMinMaxValues: makeFacetedMinMaxValues(),
      // Re-pass the per-slice callbacks so React captures fresh currentState each cycle
      // (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
      onSortingChange: onSortingChangeCb,
      onGlobalFilterChange: onGlobalFilterChangeCb,
      onColumnFiltersChange: onColumnFiltersChangeCb,
      onPaginationChange: onPaginationChangeCb,
      onRowSelectionChange: onRowSelectionChangeCb,
      onColumnVisibilityChange: onColumnVisibilityChangeCb,
      onColumnSizingChange: onColumnSizingChangeCb,
      onColumnOrderChange: onColumnOrderChangeCb,
      onColumnPinningChange: onColumnPinningChangeCb,
      onColumnSizingInfoChange: onColumnSizingInfoChangeCb
    }));
    if (refreshRowModel) refreshRowModel();
  }

  // LIT (+ any fine-grained target whose effect-tracked watch does NOT observe the plain
  // `data` PROPERTY): the re-feed $watch reads `(this.data||[]).length` inside a
  // preact-signals effect, but `data` is a Lit @property (not a signal) so the effect
  // never re-runs when the consumer pushes new rows post-mount (the sticky demo seeds 20
  // rows in its own $onMount AFTER the child mounted empty → the body stayed at 0). The
  // slice models DO re-pull (their $data.<slice>Default signals are effect-tracked), so
  // only a raw `data` reference/length change slips through. $onUpdate (Lit updated())
  // fires on ANY property change incl `data`; guard with a stored last-seen data ref +
  // length so it re-feeds ONLY on a real data change (no churn). On the coarse-render
  // targets the watch already covers it; this is a cheap idempotent backstop.
  // External-swap history reset (grid-wide undo/redo, 260709-8ct; #8 fix). Keyed on the CONTROLLED
  // `$props.data` REFERENCE changing — deliberately NOT on `currentData()` inside reFeed. An internal
  // writeback changes `$data.dataDefault` SYNCHRONOUSLY and only LATER round-trips into `$props.data`;
  // keying on `$props.data`'s OWN change means we never observe the transient window where a fine-
  // grained target's reFeed reads a stale, unstamped `$props.data` mid-write and wrongly wipes a
  // just-recorded edit's history (the stale-read false-clear — the SAME failure that broke the
  // content-signature variant — that regressed Solid/Lit when this clear lived in reFeed). When
  // `$props.data` genuinely changes: a new array carrying DATA_WRITE_TOKEN round-tripped from one of
  // OUR writes → keep; one without it is a dataset the consumer handed us → external swap → clear. A
  // non-data tick (sort/filter/pagination) never touches `$props.data` → never clears. Called from
  // BOTH the coarse re-feed watch AND the $onUpdate backstop (Lit's @property `data` the effect-
  // tracked watch can't observe); both are ref-gated so the redundant call is an idempotent no-op.
  let lastPropsData: unknown = null;
  function maybeClearHistoryOnExternalSwap() {
    const pd = data();
    if (pd === lastPropsData) return; // $props.data did not change → not an external swap
    lastPropsData = pd;
    if (!local.undoable) return;
    if (pd != null && (pd as any)[DATA_WRITE_TOKEN_KEY] != null) return; // descends from our write → keep
    clearHistory();
  }
  let lastData: any = null;
  let lastDataLen = -1;
  // Header click → toggle sort. Shift-click → ADD a secondary sort (multi-sort). Driven
  // through table-core's column API so the onSortingChange funnel emits the fresh state.
  function onHeaderSort(colId: any, evt: any) {
    if (!table) return;
    const col = table.getColumn(colId);
    if (!col || !col.getCanSort()) return;
    const multi = !!(evt && evt.shiftKey);
    // toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
    col.toggleSorting(undefined, multi);
  }

  // aria-sort string for a column header: 'ascending' | 'descending' | 'none'. Reads
  // Reactive tick: read $data.rowModelVer (bumped by every refreshRowModel) so a
  // template binding that calls a table-READING chrome helper (pagination/sort/pin/
  // visibility predicates below) re-evaluates when the row model changes. On the
  // coarse-render targets (Vue/React/Angular) the whole template re-runs anyway so this
  // is a no-op; on the FINE-GRAINED targets (Solid/Lit) a helper that only reads the
  // non-reactive `table` let would be computed ONCE (when table is still null → the
  // default branch) and never update — pagination would read "Page 1 of 1" forever,
  // aria-sort never flips, the pin position never sticks. Touching rowModelVer puts each
  // helper in the reactive scope. The chrome helpers prefix `tick()` in their guard.
  function tick() {
    return rowModelVer();
  }
  // the live sort direction off the table-core column (string-safe — never a bound
  // boolean, the listbox aria lesson).
  function ariaSortFor(colId: any) {
    if (tick() < 0 || !table) return 'none';
    const col = table.getColumn(colId);
    if (!col) return 'none';
    const dir = col.getIsSorted();
    if (dir === 'asc') return 'ascending';
    if (dir === 'desc') return 'descending';
    return 'none';
  }

  // A small sort-direction glyph for the header (▲/▼/empty). Decorative — aria-hidden.
  function sortIndicator(colId: any) {
    if (tick() < 0 || !table) return '';
    const col = table.getColumn(colId);
    if (!col) return '';
    const dir = col.getIsSorted();
    if (dir === 'asc') return '▲';
    if (dir === 'desc') return '▼';
    return '';
  }

  // Template helpers reading the resolved column-def metadata by id (plain fns — used
  // in template predicates + interpolation; uniform on all 6, no $computed alias trap).
  function defFor(colId: any) {
    const defs = columnDefs();
    for (const d of defs as any) if (d.id === colId) return d;
    return null;
  }
  // Per-row visible cells for the body loop. table-core memoizes row objects by id,
  // so a re-pull after a column change (visibility/reorder/pin, or the late <Column>
  // registry on first mount) returns the SAME row references with a different cell
  // set. On Solid the row loop keeps the existing <tr> across that pull (`:key="row.id"`
  // is stable, so the emitter's `<Key>` reconciler holds the node), and Solid will NOT
  // re-run a child loop whose `each` reads no signal — so a bare `row.getVisibleCells()`
  // goes stale (header reorders, cells don't). Reading `$data.rowModelVer` (bumped by every
  // refreshRowModel) inside the `each` puts the inner loop in the reactive scope, so it
  // re-derives the cells on every row-model change. No-op on the coarse-render targets.
  function visibleCellsFor(row: any) {
    return rowModelVer() >= 0 ? row.getVisibleCells() : [];
  }

  // ── Editable-cell column-meta accessors (phase 51 req-1/2/5) ───────────────────────
  // editMetaOf: the resolved ColumnDef.meta for a column id (the editable config carried
  // from <Column>/`:columns` via columnDefs). Null-safe — an unknown/non-editable column
  // returns null and every predicate below short-circuits to the read-only path.
  function editMetaOf(colId: any) {
    const d = defFor(colId);
    return d && d.meta ? d.meta : null;
  }
  // columnEditable: whether this column opted into editing (req-1). Drives every editor
  // gate; false → the cell stays the read-only #cell display (byte-identical-off).
  function columnEditable(colId: any) {
    const m = editMetaOf(colId);
    return !!(m && m.editable === true);
  }
  // editorTypeOf: the built-in editor kind ('text'|'number'|'select'|'checkbox') OR
  // 'custom' (the #editor scoped-slot escape hatch, req-2). Defaults to 'text'.
  function editorTypeOf(colId: any) {
    const m = editMetaOf(colId);
    return m && m.editor != null ? m.editor : 'text';
  }
  // editorOptionsOf: the select-editor options ([{ value, label }]) for editor='select'.
  function editorOptionsOf(colId: any) {
    const m = editMetaOf(colId);
    return m && m.editorOptions != null ? m.editorOptions : [];
  }
  // hasEditorSlot: this column routes through the consumer's #editor scoped slot (req-2)
  // — true only when the column declared editor='custom' AND the consumer actually
  // provided an #editor slot. Falls through to the built-in editor otherwise (e.g. a
  // column marked 'custom' with no slot supplied degrades to the text editor, never blank).
  function hasEditorSlot(colId: any) {
    return editorTypeOf(colId) === 'custom' && !!(_props.editorSlot ?? _props.slots?.["editor"]);
  }

  // hasFilterSlot: the consumer supplied a #filter scoped slot, so it OWNS the per-column
  // filter UI (re-added in 72-05 alongside the dedicated filter row's `<slot name="filter">`
  // host — see the 72-03 removal note in that plan's SUMMARY for why this was briefly gone).
  function hasFilterSlot() {
    return !!(_props.filterSlot ?? _props.slots?.["filter"]);
  }
  function columnIsFilterable(colId: any) {
    const d = defFor(colId);
    return !!(d && d.filterable);
  }
  function headerLabel(colId: any) {
    const d = defFor(colId);
    return d ? d.header : colId;
  }

  // ── Column-management chrome (req-8/9/10/11) ────────────────────────────────────────
  // Live header width (px) for a column — drives the <th> :style width binding. Reads the
  // table-core column size (post-mount) with a fallback to undefined (auto width).
  function headerWidth(colId: any) {
    if (tick() < 0 || !table) return null;
    const col = table.getColumn(colId);
    if (!col) return null;
    const w = col.getSize();
    return w != null && w > 0 ? w + 'px' : null;
  }

  // Pointer-drag resize handler for a resizable header — table-core's getResizeHandler()
  // returns a function bound to a pointerdown/touchstart event that drives the column
  // size through onColumnSizingChange (our writeColumnSizing funnel) under
  // columnResizeMode:'onChange'. Pure delegation; no scratch gesture state held in a
  // top-level const (the React fragile-binding rule — table-core owns the gesture state).
  function onResizeStart(colId: any, evt: any) {
    // stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
    if (evt && evt.stopPropagation) evt.stopPropagation();
    if (!table) return;
    const header = findHeader(colId);
    if (!header || !header.getResizeHandler) return;
    const handler = header.getResizeHandler();
    if (handler) handler(evt);
  }
  // Find the live header object for a column id across the rendered header groups.
  function findHeader(colId: any) {
    const groups = headerGroups() || [];
    for (const hg of groups as any) {
      const hs = hg.headers || [];
      for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
    }
    return null;
  }
  function columnIsResizing(colId: any) {
    if (tick() < 0 || !table) return false;
    const header = findHeader(colId);
    return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
  }

  // Visibility toggle (req-8) — drive table-core's column.toggleVisibility so the
  // onColumnVisibilityChange funnel emits the fresh state.
  function columnIsVisible(colId: any) {
    if (tick() < 0 || !table) return true;
    const col = table.getColumn(colId);
    return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
  }
  function onToggleVisibility(colId: any) {
    if (!table) return;
    const col = table.getColumn(colId);
    if (col && col.toggleVisibility) col.toggleVisibility();
  }
  // The full set of leaf columns (for the visibility-toggle menu) — id + header label +
  // current visibility. Excludes the auto-injected CHROME columns (select + expander) —
  // neither is a data column: they carry no header label (so they'd surface their raw
  // internal id, e.g. '__rdt_expander') and their presence is governed by the
  // selectionMode/expandable props, not user-toggleable visibility.
  function allLeafColumns() {
    if (tick() < 0 || !table) return [];
    const cols = table.getAllLeafColumns ? table.getAllLeafColumns() : [];
    const out = [];
    for (const c of cols as any) {
      if (!c || c.id === SELECT_COL_ID || c.id === EXPANDER_COL_ID) continue;
      out.push({
        id: c.id,
        label: headerLabel(c.id),
        visible: !!(c.getIsVisible && c.getIsVisible())
      });
    }
    return out;
  }

  // Pinning (req-11) — drive table-core's column.pin('left'|'right'|false) so the
  // onColumnPinningChange funnel emits a fresh state. Sticky offsets read the live column
  // start/after positions (table-core computes them from the pinned column sizes).
  function columnPinSide(colId: any) {
    if (tick() < 0 || !table) return false;
    const col = table.getColumn(colId);
    if (!col || !col.getIsPinned) return false;
    return col.getIsPinned();
  }
  // NOTE: the event is stopped HERE (evt.stopPropagation()) rather than via a `.stop`
  // template modifier. The Angular emitter, hoisting a `.stop`-modified handler that
  // lives INSIDE an `@for` loop into a class-field wrapper, drops the component `this.`
  // qualifier (→ `onPinColumn(...)` bare ReferenceError) and fails to capture the loop
  // var — so a `@click.stop="onPinColumn(...)"` inside the header `@for` breaks on
  // Angular (F5). Stopping inside the handler sidesteps the broken hoist on all six.
  function onPinColumn(colId: any, side: any, evt: any) {
    if (evt && evt.stopPropagation) evt.stopPropagation();
    if (!table) return;
    const col = table.getColumn(colId);
    if (col && col.pin) col.pin(side);
  }
  // Sticky inline style for a pinned header/cell — position:sticky + the computed left or
  // right offset. Returns '' (no sticky) for unpinned columns. Returned as a STRING (the
  // :style binding is value-driven — never an eval'd attr).
  //
  // `zIndex` (phase 72 fix, default 1 — body <td> / filter-row <th> layer): an INLINE style
  // ALWAYS wins over the stylesheet's `.rozie-data-table.rdt-sticky .rdt-thead .rdt-th
  // { z-index: var(--rdt-sticky-z, 2) }` rule, so a pinned header cell that unconditionally
  // got `z-index:1` here (same as the pinned body/filter-row cells) silently DOWNGRADED the
  // intended sticky-header stacking level from 2 to 1 — tying it with the dedicated filter
  // row's own pinned <th> (72-05), which sits LATER in DOM order (a sibling <tr> beneath the
  // header row) and therefore visually/interactively covers the header's ⋯ menu (phase 72,
  // z-index:1000 relative to Popover's OWN local stacking context — capped by the pinned
  // header <th>'s z-index, since a `position:fixed` descendant does not escape an ancestor's
  // stacking context, only its layout containing block) whenever that SAME column is both
  // pinned and filterable. thStyle() (the header caller) passes zIndex=2 so the header layer
  // always wins ties against the filter-row/body layers, which keep the default of 1.
  function pinStyle(colId: any, zIndex = 1) {
    if (tick() < 0 || !table) return '';
    const col = table.getColumn(colId);
    if (!col || !col.getIsPinned) return '';
    const side = col.getIsPinned();
    if (side === 'left') {
      const left = col.getStart ? col.getStart('left') : 0;
      return 'position:sticky;left:' + left + 'px;z-index:' + zIndex + ';';
    }
    if (side === 'right') {
      const right = col.getAfter ? col.getAfter('right') : 0;
      return 'position:sticky;right:' + right + 'px;z-index:' + zIndex + ';';
    }
    return '';
  }
  // Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
  // uniform on all 6, no bound-object trap. zIndex=2 (see pinStyle) so a pinned header cell
  // — which hosts the ⋯ menu's floating content — always stacks above the pinned filter-row
  // cell for the same column (zIndex=1, its own default).
  function thStyle(colId: any) {
    let s = '';
    const w = headerWidth(colId);
    if (w) s += 'width:' + w + ';';
    s += pinStyle(colId, 2);
    return s;
  }

  // ── Filter chrome handlers ─────────────────────────────────────────────────────────
  // Global search input → funnel through table-core's setGlobalFilter so the
  // onGlobalFilterChange callback fires the echo-guarded writer. Capture the fresh local
  // value (never re-read a just-written $data key — React stale-read).
  function onGlobalFilterInput(evt: any) {
    const value = evt && evt.target ? evt.target.value : '';
    if (table) {
      table.setGlobalFilter(value);
      return;
    }
    writeGlobalFilter(value);
  }
  // Per-column filter input → setColumnFilter (fresh-array funnel).
  function onColumnFilterInput(colId: any, evt: any) {
    const value = evt && evt.target ? evt.target.value : '';
    setColumnFilter(colId, value);
  }
  // The live global filter value (bound to the search <input>, value-driven NOT eval'd).
  function globalFilterValue() {
    const v = currentState().globalFilter;
    return v != null ? v : '';
  }

  // ── Pagination chrome ────────────────────────────────────────────────────────────
  // Read the live pagination state off table-core (post-mount) with a currentState()
  // fallback (pre-mount / SSR). All string-safe (no bound booleans).
  function pageIndex() {
    if (tick() >= 0 && table) return table.getState().pagination.pageIndex;
    const p = currentState().pagination;
    return p && p.pageIndex != null ? p.pageIndex : 0;
  }
  function pageSize() {
    if (tick() >= 0 && table) return table.getState().pagination.pageSize;
    const p = currentState().pagination;
    return p && p.pageSize != null ? p.pageSize : 10;
  }
  // Renamed from `pageCount` → `displayPageCount`: `pageCount` is now a public prop
  // (server-side manual pagination), and a same-named top-level helper collides with the
  // destructured prop on Svelte and the @Input/@property class field on Angular/Lit. This
  // reader is internal (drives the "Page X of Y" chrome) and reads table-core's live
  // getPageCount(), which now reflects rowCount/pageCount when manual.
  function displayPageCount() {
    if (tick() < 0 || !table) return 1;
    const c = table.getPageCount();
    return c != null && c > 0 ? c : 1;
  }
  function canPrevPage() {
    return !!(tick() >= 0 && table && table.getCanPreviousPage());
  }
  function canNextPage() {
    return !!(tick() >= 0 && table && table.getCanNextPage());
  }
  function onPrevPage() {
    if (table) table.previousPage();
  }
  function onNextPage() {
    if (table) table.nextPage();
  }
  function onPageSizeChange(evt: any) {
    if (!table) return;
    const v = evt && evt.target ? evt.target.value : '';
    const n = parseInt(v, 10);
    table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
  }

  // ── Row-selection chrome (req-7) ───────────────────────────────────────────────────
  // Detect the auto-injected leading checkbox column by its constant id (template uses
  // this to render checkbox chrome instead of an accessor value).
  function isSelectColumn(colId: any) {
    return colId === SELECT_COL_ID;
  }
  // ── Expandable-rows template helpers (phase 50, D-04) ──────────────────────────────
  // isExpanderColumn: the auto-injected leading chevron column predicate (mirrors
  // isSelectColumn). rowIsExpanded / rowCanExpand read table-core row handles THROUGH the
  // reactive tick (rowModelVer) so the chevron glyph + aria-expanded + the #detail r-if
  // re-derive on a re-pull on the fine-grained targets (Solid/Lit) — same discipline as
  // visibleCellsFor. `!!`-coerced so a bound aria-expanded emits an UNWRAPPED boolean (the
  // listbox aria lesson — never a rozieAttr string → TS2322 on React/Solid).
  function isExpanderColumn(colId: any) {
    return colId === EXPANDER_COL_ID;
  }
  // rowCanExpand gates ONLY the leading expander-column detail chevron. Group-header rows
  // are excluded (`!getIsGrouped`): with `expandable` + grouping, getRowCanExpand returns
  // `() => true` for EVERY flattened row, so without this a group header rendered TWO
  // chevrons — the group-toggle in its grouped cell AND a redundant detail chevron in the
  // leading column (both fire onToggleExpand on the shared expanded state). A group row's
  // expand affordance is the group-toggle; the leading-column chevron is detail-only.
  function rowCanExpand(row: any) {
    return !!(tick() >= 0 && row && row.getCanExpand && row.getCanExpand() && !(row.getIsGrouped && row.getIsGrouped()));
  }
  function rowIsExpanded(row: any) {
    return !!(tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
  }
  // rowShowsDetail: the #detail <tr> renders ONLY in #detail mode (no getSubRows) when the
  // row is expanded AND is NOT a group-header row. With getSubRows the children arrive as
  // ordinary depth-indented rows in $data.rows (table-core flattens) — NO additive detail
  // row, NO nested r-for (Pitfall 1). The `!rowIsGrouped` guard is load-bearing: grouping
  // and detail-expand share table-core's SINGLE `expanded` state, so a group-header row is
  // `getIsExpanded()===true` the moment its group opens; without this guard that expanded
  // group row also satisfied `getSubRows==null && rowIsExpanded`, painting a spurious
  // #detail panel under every opened group (the group-toggle looked "linked" to detail).
  function rowShowsDetail(row: any) {
    return local.getSubRows == null && !rowIsGrouped(row) && rowIsExpanded(row);
  }
  // Toggle a row's expanded state through table-core so onExpandedChange → writeExpanded
  // fires exactly one expanded-change. Used by the chevron @click (native <button> handles
  // Enter/Space → click, so NO explicit @keydown.enter/.space — that would DOUBLE-toggle on
  // a real button; the grid @keydown is inert in 'table' mode, isGrid()-gated).
  function onToggleExpand(row: any, evt: any) {
    if (!row || !row.toggleExpanded) return;
    // Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
    // expanded-state re-render. This guards a focus-drop that USED to happen on Solid: when the
    // cell loop reconciled by reference (bare <For>), table-core's fresh cell instances each
    // pull rebuilt the expander <td>/<button> (the <tr> persisted but its cells were rebuilt),
    // dropping DOM focus to <body> and breaking keyboard activation (Enter/Space on the focused
    // expander left nothing focused). Since the emitter now emits `<Key>` for the
    // `:key="cellCtx.id"` cell loop, Solid keeps the cell node on a stable key too — so the
    // expander is no longer recreated and this re-focus is now a defensive no-op on ALL six
    // targets (re-focusing the SAME kept element — the focusActiveCell imperative-refocus
    // precedent). Kept for safety; it costs nothing when the node is unchanged. The rAF defers
    // past the synchronous reactive flush so any (re)created node exists first.
    const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
    row.toggleExpanded();
    if (ownerRow && typeof requestAnimationFrame === 'function') {
      requestAnimationFrame(() => {
        const btn = ownerRow.querySelector('[data-expander]');
        if (btn) btn.focus();
      });
    }
  }
  // bodyCellStyle: the non-virtual <td> inline style — pinStyle PLUS a depth-proportional
  // left pad on the EXPANDER cell so nested getSubRows children visibly indent (row.depth).
  // Only the expander column indents (the tree affordance lives in its dedicated column);
  // data columns stay grid-aligned. depth 0 → unchanged (byte-identical-off).
  function bodyCellStyle(row: any, colId: any) {
    const base = pinStyle(colId);
    if (isExpanderColumn(colId) && row && row.depth) {
      // Only the expander column indents (the tree affordance lives here).
      const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
      return base ? base + pad : pad;
    }
    return base;
  }
  // ── Grouping template helpers (phase 50 reqs 4-7, D-04/D-05) ───────────────────────────
  // Group-header rows ARE expandable rows: table-core's getGroupedRowModel FLATTENS them into
  // $data.rows carrying getIsGrouped()/subRows, so they ride the SAME D-04 <template r-for> seam
  // (no parallel render path, no nested r-for). These predicates read through the reactive tick
  // (rowModelVer) so the group chrome + collapse state re-derive on a re-pull on the fine-grained
  // targets (Solid/Lit) — same discipline as rowIsExpanded/visibleCellsFor. `!!`-coerced (the
  // listbox aria lesson — a bound boolean must be UNWRAPPED, never a rozieAttr string → TS2322).
  // rowIsGrouped: this flattened row is a group-header row.
  function rowIsGrouped(row: any) {
    return !!(tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());
  }
  // groupingActive: grouping is currently engaged (a non-empty ordered key list). Drives the
  // data-group-leaf marker so it is ABSENT when ungrouped (byte-identical-off, req-10).
  function groupingActive() {
    return tick() >= 0 && (currentState().grouping || []).length > 0;
  }
  // cellIsGrouped / cellIsAggregated: per-CELL roles on a group-header row. The grouped cell shows
  // the group key + toggle + count; an aggregated cell shows the rolled-up value through the
  // EXISTING #cell slot (cell.getValue()) — NO new aggregatedCell template (RESEARCH State of the
  // Art). A placeholder cell (neither) falls through to the #cell r-else and renders its empty value.
  function cellIsGrouped(cellCtx: any) {
    return !!(tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());
  }
  function cellIsAggregated(cellCtx: any) {
    return !!(tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());
  }
  // cellIsPlaceholder: a PLACEHOLDER cell on a group-header row — a non-grouped, non-aggregated
  // cell that table-core fills with the FIRST leaf row's value (cell.getValue() leaks e.g.
  // "Services"/"Edsger Dijkstra" onto the group line). Renders BLANK via a dedicated empty
  // template branch so the leaked leaf value never paints. Tick-gated exactly like cellIsGrouped
  // so the group chrome re-derives on a re-pull on the fine-grained targets (Solid/Lit).
  function cellIsPlaceholder(cellCtx: any) {
    return !!(tick() >= 0 && cellCtx && cellCtx.getIsPlaceholder && cellCtx.getIsPlaceholder());
  }
  // groupSubRowCount: the number of underlying LEAF RECORDS under a group-header row (the count
  // shown in the header, e.g. "North (40)"). row.subRows is the IMMEDIATE members — for MULTI-LEVEL
  // grouping those are sub-GROUPS, not records, so "North" with 2 categories / 40 records would show
  // "North (2)". getLeafRows() returns all leaf descendants (the actual record count); keep the
  // subRows fallback for safety. Single-level grouping is unchanged (getLeafRows == subRows when the
  // children are already leaves).
  function groupSubRowCount(row: any) {
    return row && row.getLeafRows ? row.getLeafRows().length : row && row.subRows ? row.subRows.length : 0;
  }
  // groupingKeys: the live ordered grouping array — slot prop for the headless #groupBar + the
  // default styled-token reflection. Reads currentState() ($props.grouping ?? $data.groupingDefault),
  // both reactive sources, so the bar re-renders on a grouping change across all six targets.
  function groupingKeys() {
    return currentState().grouping || [];
  }
  // groupableColumns: the data columns OFFERED to the headless #groupBar (those whose Column/config
  // `groupable` is not false) — `[{ id, label }]`. Excludes the chrome columns (select/expander are
  // not in columnDefs()). The consumer builds any bar/drag UI from this; the component ships none.
  function groupableColumns() {
    const out = [];
    const defs = columnDefs();
    for (const d of defs as any) {
      if (!d || d.groupable === false) continue;
      out.push({
        id: d.id,
        label: d.header != null ? d.header : d.id
      });
    }
    return out;
  }
  // Plain stop-propagation handler (used in place of the `@click.stop` bare modifier —
  // a bare `.stop` with no handler hoists to `_guardedUndefined` → `this.undefined($event)`
  // on Angular inside an `@for`, F5). Calling an explicit handler is uniform on all six.
  function stopEvent(evt: any) {
    if (evt && evt.stopPropagation) evt.stopPropagation();
  }
  // select-all header state (D-06: scopes to all filtered rows = TanStack default).
  // `!!`-coerced booleans (the listbox aria lesson — never a bound rozieAttr string).
  function isAllRowsSelected() {
    return !!(tick() >= 0 && table && table.getIsAllRowsSelected());
  }
  function isSomeRowsSelected() {
    return !!(tick() >= 0 && table && table.getIsSomeRowsSelected());
  }
  function onToggleAllRows(evt: any) {
    if (!table) return;
    table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
  }
  // per-row checkbox state + toggle (checkbox-only, D-05 — row body does NOT select).
  // Read selection from the LIVE controlled state (currentState().rowSelection keyed by
  // row.id) — NOT row.getIsSelected(). The latter reads table-core's row model, which
  // only reflects a selection AFTER the re-feed watch pushes the new `state` + re-pulls
  // (two reactive cycles on React). The controlled-state read updates in the SAME cycle
  // as the write funnel, so the controlled <input :checked> reflects the toggle without
  // the row-model-re-pull latency — the React controlled-checkbox revert that left
  // `.check()` seeing no state change (F6). row.getIsSelected() is the fallback.
  function rowIsSelected(row: any) {
    if (!row) return false;
    const id = row.id;
    const sel = currentState().rowSelection || {};
    if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
    return !!(row.getIsSelected && row.getIsSelected());
  }
  function onToggleRow(row: any, evt: any) {
    if (!row || !row.toggleSelected) return;
    row.toggleSelected(!!(evt && evt.target && evt.target.checked));
  }

  // ── Header ⋯ menu chrome (phase 72, D-06) ──────────────────────────────────────────
  // onHideColumn: the ⋯ menu's "Hide column" item. Reuses the SAME columnVisibility write
  // funnel as the existing colvis toggle (onToggleVisibility in columnChrome.rzts) — just
  // forced to `false` rather than toggled, since hide is a one-directional action from the
  // menu (the colvis panel is the re-show path). Event stopped HERE (not a `.stop`
  // modifier) — same Angular @for-hoist hazard as onPinColumn/onResizeStart (F5).
  function onHideColumn(colId: any, evt: any) {
    if (evt && evt.stopPropagation) evt.stopPropagation();
    if (!table) return;
    const col = table.getColumn(colId);
    if (col && col.toggleVisibility) col.toggleVisibility(false);
  }
  // hasAnyFilterableColumn: gates the dedicated filter row (72-05) — true when at least one
  // leaf column (excluding the select/expander chrome columns, already excluded by
  // allLeafColumns) is filterable. Reactive via allLeafColumns()'s own tick() gate.
  function hasAnyFilterableColumn() {
    const cols = allLeafColumns();
    for (const c of cols as any) {
      if (c && columnIsFilterable(c.id)) return true;
    }
    return false;
  }
  // `indeterminate` is a DOM PROPERTY, not an HTML attribute — a `:indeterminate="…"`
  // binding only takes effect on Vue (which binds known DOM props); on
  // React/Solid/Angular/Lit/Svelte it lands as an inert attribute and `el.indeterminate`
  // stays false. So set it IMPERATIVELY: query the select-all checkbox off the component
  // root ($el — post-mount safe) and assign the property. Called from refreshRowModel
  // (every selection change re-pulls the row model) so it stays in lockstep with the
  // table-core selection state. The select-all box is NOT re-created by a selection
  // change (only its checked attr flips), so the live element persists.
  // `box` is aliased through a module-scope null-let (typeNeutralize → `any`) so the
  // strict bundled-leaf tsc accepts `.indeterminate` (querySelector returns `Element`,
  // which has no `indeterminate` — it is an HTMLInputElement DOM property). Same idiom
  // as Column's `let reg = null; reg = $inject(...)`.
  let selectAllBox: any = null;
  function syncIndeterminate() {
    if (!__rozieRootRef! || !__rozieRootRef!.querySelector) return;
    selectAllBox = __rozieRootRef!.querySelector('.rdt-select-all');
    if (selectAllBox) selectAllBox.indeterminate = isSomeRowsSelected() && !isAllRowsSelected();
  }

  // The registry API handed to <Column> children (whole-object-replace — T-48-PP guard).

  // Imperative handle (consumer-callable). Each verb is a PRE-DECLARED top-level
  // `const` (the canonical $expose contract — `$expose({ name })` references a
  // binding ALREADY in scope; an INLINE-defined verb `$expose({ name: () => {} })`
  // is dropped on ALL SIX targets, only the by-reference key survives → a
  // runtime ReferenceError at `defineExpose`/`useImperativeHandle`). Sorting verbs +
  // a fresh column-def readout, selection, pagination, and column-management verbs.
  function sortColumn(colId: any, desc: any) {
    if (table) table.getColumn(colId) && table.getColumn(colId).toggleSorting(desc, false);
  }
  function clearSorting() {
    if (table) table.resetSorting(true);
  }
  function getColumnDefs() {
    return columnDefs();
  }
  // selection verbs (req-7) — drive table-core so the onRowSelectionChange funnel
  // emits the fresh state + selection-change.
  function toggleAllRows(value: any) {
    if (table) table.toggleAllRowsSelected(value);
  }
  function clearSelection() {
    if (table) table.resetRowSelection(true);
  }
  function getSelectedRows() {
    return table ? table.getSelectedRowModel().rows.map((r: any) => r.original) : [];
  }
  // pagination verbs.
  function setPage(idx: any) {
    if (table) table.setPageIndex(idx);
  }
  function setRowsPerPage(size: any) {
    if (table) table.setPageSize(size);
  }
  // column-management verbs (req-8/9/10/11) — drive table-core so the funnels fire.
  function toggleColumnVisibility(colId: any) {
    if (table) {
      const c = table.getColumn(colId);
      if (c && c.toggleVisibility) c.toggleVisibility();
    }
  }
  // NOT `setColumnOrder`: a verb named `set<ModelProp>` collides with React's
  // auto-generated `setColumnOrder` useState setter for the `columnOrder` model
  // prop, and an $expose verb is PUBLIC-CONTRACT-PROTECTED from the React
  // deconfliction rename (ROZ524 — the rename target is the verb, which is
  // off-limits). So the public verb is `applyColumnOrder` (semantically: apply a
  // new column order). The other set* verbs (setPage/setRowsPerPage) do NOT match
  // any model prop's setter, so they are collision-free.
  function applyColumnOrder(order: any) {
    if (table) table.setColumnOrder(order);
  }
  function resetColumnSizing() {
    if (table) table.resetColumnSizing(true);
  }
  // pinColumn: the verb that drives column.pin; distinct from the template handler
  // onPinColumn (no shadow — the deferred-items finding #4 collision check).
  function pinColumn(colId: any, side: any) {
    if (table) {
      const c = table.getColumn(colId);
      if (c && c.pin) c.pin(side);
    }
  }
  // getRowIndexRelativeToPage(absRow?) — C1 (phase 63 wave-6) converter: an ABSOLUTE display-order
  // index (the focusCell/getActiveCell/activecell-change space) → the PAGE-RELATIVE index. Mirrors
  // MUI getRowIndexRelativeToVisibleRows. With NO argument it converts the CURRENT active cell
  // (toAbsRow($data.activeRow) - pageRowOffset() collapses to $data.activeRow). In virtual mode
  // there is no page (windowing replaces pagination) → the windowed model IS the full model, so it
  // returns the absolute index unchanged. Collision-safe: no *-change event, prop, React auto-setter,
  // or inherited Lit DOM method named getRowIndexRelativeToPage (ROZ121/124/137 clear).
  function getRowIndexRelativeToPage(absRow: any) {
    const abs = absRow == null ? toAbsRow(activeRow()) : Math.trunc(Number(absRow)) || 0;
    if (local.virtual) return abs;
    return abs - pageRowOffset();
  }

  // C3 (phase 63 wave-9) — the PUBLIC Cut verb: copy the current cell range to the clipboard then
  // clear the source cells through the write-funnel (one writeData), delegating to cutRange (the
  // clipboardFill funnel that also backs the Ctrl+X shortcut). Reads the persisted $data range /
  // active cell, so it cuts the current selection even when the call arrives off a control that
  // moved DOM focus off the grid. Collision-safe: no `cut` event / model prop / React auto-setter /
  // inherited Lit DOM method named `cut` (ROZ121/124/137 clear) — `cut` is not on HTMLElement.
  function cut() {
    return cutRange();
  }

  // 260709-8ct (grid-wide undo/redo): NO pass-through wrapper lands here for
  // undo/redo/canUndo/canRedo/clearHistory. Unlike `cut` above (which delegates to a
  // differently-named clipboardFill export, `cutRange`), the undoHistory.rzts exports already
  // use the exact public verb names and are already component-scope (imported directly into
  // DataTable.rozie) — so DataTable.rozie's $expose references them BY NAME with zero
  // indirection. This file stays the seam for verbs that need a rename/adapter, not a mandatory
  // stop for every $expose entry.
  // ══ Grid interaction mode (phase 49) — STATE + STRUCTURE only ═══════════════════════════
  // This plan (02) establishes the gated ARIA roles, the roving single-tab-stop tabindex,
  // the active-cell index-pair state, the data-* cell markers, and the SINGLE
  // focusActiveCell() seam. Plan 03 adds the keydown navigation math, the $expose verbs
  // (focusCell/getActiveCell/clearActiveCell), and the activecell-change event ON TOP.

  // interactionMode gate. 'grid' lights up roving nav; 'table' (default) is byte-behaviorally
  // identical to phase 48 (roles fall back to the literals, tabindex drops).
  function isGrid() {
    return local.interactionMode === 'grid';
  }

  // Role computeds (RESEARCH Pattern 4). The 'table' branch returns the EXACT phase-48
  // literal so 'table'-mode DOM is unchanged. Header cells keep 'columnheader' and rows keep
  // 'row'/'rowgroup' in BOTH modes (APG grid) — those stay static literals in the template.
  function tableRole() {
    return isGrid() ? 'grid' : 'table';
  }
  function cellRole() {
    return isGrid() ? 'gridcell' : 'cell';
  }

  // ── Cell addressing helpers (plain fns — no $computed alias trap; safe in template) ────
  // rowIndexOf: a body row's index over the visible model ($data.rows). tick() puts the read
  // in the fine-grained reactive scope (Solid/Lit) so the data-row marker re-derives on a
  // re-pull (reorder/filter) — matching visibleCellsFor's discipline.
  function rowIndexOf(row: any) {
    return tick() >= 0 ? (rows() || []).indexOf(row) : -1;
  }
  // colIndexOf: a body cell's position in its row's visible cell list.
  function colIndexOf(row: any, cellCtx: any) {
    return tick() >= 0 ? visibleCellsFor(row).indexOf(cellCtx) : -1;
  }
  // headerColIndexOf: a header cell's position in its header group's leaf headers.
  function headerColIndexOf(hg: any, header: any) {
    return (hg && hg.headers ? hg.headers : []).indexOf(header);
  }

  // ── C1 (phase 63 wave-6) absolute-index bridge ─────────────────────────────────────────
  // The PUBLIC active-cell rowIndex (focusCell/getActiveCell/activecell-change) is the ABSOLUTE
  // display-order position in getPrePaginationRowModel().rows (filter+sort+expand applied, BEFORE
  // pagination/windowing), in BOTH paginated and virtual modes — reversing the old page-relative
  // paginated meaning. INTERNALLY $data.activeRow stays PAGE-RELATIVE in the non-virtual paginated
  // body (the data-row markers + the nav math index the page slice) and FULL-MODEL in virtual mode
  // (the wr.vi.index space). pageRowOffset() bridges the two so the API speaks one absolute language.
  //   - virtual mode: activeRow is already the full pre-pagination index → offset 0.
  //   - non-virtual:  activeRow is page-relative → offset = pageIndex * pageSize.
  // isGrid()-gated (the active-cell API is grid-only); pageIndex()/pageSize() read live table-core
  // state through the reactive tick (filterPaginationRowChrome), so this re-derives on a page change.
  function pageRowOffset() {
    if (!isGrid() || local.virtual) return 0;
    return pageIndex() * pageSize();
  }
  // page-relative active row → absolute (display-order) index.
  function toAbsRow(localRow: any) {
    return localRow + pageRowOffset();
  }
  // A body row's ABSOLUTE display-order index = its page-relative index + the page offset. Drives
  // aria-rowindex on the non-virtual paginated body (B27); the virtual path uses wr.vi.index
  // directly (already absolute). Reactive via rowIndexOf's tick().

  // Total filtered+sorted PRE-pagination row count — the clamp bound for an absolute focusCell.
  // In virtual mode $data.rows IS the full pre-pagination model (bodyRowCount suffices); in the
  // non-virtual paginated body $data.rows is only the page slice, so read the live model.
  function prePaginationRowCount() {
    if (!table || local.virtual) return bodyRowCount();
    const pm = table.getPrePaginationRowModel();
    return pm && pm.rows ? pm.rows.length : bodyRowCount();
  }

  // Roving tabindex (RESEARCH Code Examples). Reads ONLY reactive $data (ROZ123-safe,
  // fine-grained-reactive). Returns null in 'table' mode → the bound numeric attribute
  // DROPS entirely (IN-01: on React via the `cellTabindex(...) ?? undefined` numeric-attr
  // emitter path landed in 4bec3b8e — NOT rozieAttr, which would string-widen tabIndex and
  // TS2322; the other five targets drop it via their own nullish-attr handling), keeping
  // 'table'-mode DOM clean. rowKey is the literal
  // '__header' for header cells or the String(bodyRowIndex) for body cells, so the active
  // header state (activeIsHeader) is addressable through the same computed.
  function cellTabindex(rowKey: any, colIndex: any, level = null) {
    if (!isGrid()) return null;
    // B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
    // the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
    // tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
    if (bodyRowCount() === 0) {
      return rowKey === '__header' && colIndex === 0 && level === headerLeafLevel() ? 0 : -1;
    }
    // B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
    // grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
    // lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
    // tab-stops (the roving invariant broke under grouped headers).
    if (activeIsHeader()) {
      if (rowKey !== '__header') return -1;
      return colIndex === activeColIndex() && level === activeHeaderLevel() ? 0 : -1;
    }
    const isActive = rowKey === String(activeRow()) && colIndex === activeColIndex();
    return isActive ? 0 : -1;
  }

  // ── Active-cell ring predicate (grid pointer §1, 260708-ni6) ───────────────────────────
  // isActiveCell mirrors cellTabindex's ACTIVE branch (the same (rowKey, colIndex, level)
  // address tuples the roving tabindex uses) but returns a BOOLEAN for the `.rdt-cell-active`
  // :class binding, and is STATE-DRIVEN — so the ring shows identically on click AND keyboard
  // (independent of :focus-visible, which browsers gate off for a mouse-focused non-text <td>).
  // It DELIBERATELY omits cellTabindex's B6 empty-grid / header-fallback branch: the ring must
  // NOT light on an empty grid's synthetic tab-stop (there is no real active cell there). Reads
  // ONLY reactive $data (ROZ123-safe, fine-grained). Returns false in 'table' mode so table-mode
  // markup is byte-behaviorally unchanged. Header cells are active only while activeIsHeader is
  // true (addressed by BOTH colIndex and level — a grouped multi-level header carries exactly one
  // ring); body cells only while activeIsHeader is false.
  function isActiveCell(rowKey: any, colIndex: any, level = null) {
    if (!isGrid()) return false;
    if (activeIsHeader()) {
      if (rowKey !== '__header') return false;
      return colIndex === activeColIndex() && level === activeHeaderLevel();
    }
    if (rowKey === '__header') return false;
    return rowKey === String(activeRow()) && colIndex === activeColIndex();
  }

  // ── The focus SEAM (RESEARCH Pattern 1 + 3, req-6) ─────────────────────────────────────
  // resolveCellEl: index pair → DOM element, via a data-* attribute query off the stable
  // post-mount root. Uniform on all six, shadow-safe (the query runs from inside the
  // component's own scope). rowKey is the literal '__header' or a String(integer index) and
  // colIndex is an integer — NO consumer string is interpolated into the selector (T-49-01).
  function resolveCellEl(rowKey: any, colIndex: any, level = null) {
    if (!gridRoot) return null;
    // B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
    // same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
    // integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
    // match (the parent) when the leaf is meant. level is an integer (NO consumer string is
    // interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
    // byte-unchanged.
    let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
    if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
    return gridRoot.querySelector(sel);
  }

  // focusActiveCell: THE single DOM-focus-resolution path (req-6). Every focus change —
  // the D-04 entry cell here, and (plan 03) arrow nav / focusCell() / the data-change clamp —
  // routes through this one function, so a verifier can point to it and phase 53 windowing
  // hooks it without a rewrite. Accepts OPTIONAL explicit (nextRow,nextCol) so callers can
  // pass FRESH post-write locals (React ROZ138 / Angular signal async — pinned by plan 01);
  // falls back to $data when none passed. NEVER stores a DOM node (index-only state).
  // 260618-ao9 — params carry explicit `= null` defaults so the cross-target
  // emitters type them OPTIONAL (untyped params lower to REQUIRED `any`, making the
  // 2-arg `focusActiveCell(r, c)` call sites a TS2554 on React/Solid/Lit — a
  // pre-existing regression from the d7166c5e header-crossing `nextIsHeader` add).
  // The `= null` default reproduces the documented "falls back to $data when
  // omitted" contract: an omitted arg arrives as `null`, and the body's `== null`
  // checks already route those to the live `$data` value — behavior-identical.
  function focusActiveCell(nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) {
    if (!isGrid() || !gridRoot) return;
    // #9 focus-intent epoch: focusActiveCell is THE single seam every keyboard nav re-asserts
    // focus through, so it establishes a fresh "where focus should be" on every call — bump the
    // epoch here (BEFORE arming the virtual-scroll focusWhenReady poll below). A SUBSEQUENT
    // focusActiveCell (the next user nav) bumps again → any pending focusWhenReady captured the
    // OLD value → aborts instead of yanking focus back. The poll captures the POST-bump value so
    // a lone scroll-to-focus with no later nav still lands (epoch stable across its own frames).
    focusIntentEpoch = focusIntentEpoch + 1;
    const r = nextRow == null ? activeRow() : nextRow;
    const c = nextCol == null ? activeColIndex() : nextCol;
    // B12: thread the FRESH post-write header level (the grouped-header analog of the
    // nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
    // level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
    const lvl = nextLevel == null ? activeHeaderLevel() : nextLevel;
    // Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
    // crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
    // Angular's signal write are async within one handler — re-reading $data.activeIsHeader
    // here returns the PRE-write value, resolving focus to the BODY cell instead of the
    // header. Callers pass the fresh isHeader local; falls back to $data when omitted.
    const header = nextIsHeader == null ? activeIsHeader() : nextIsHeader;
    // ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
    // rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
    // double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
    // in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
    // The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
    // while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
    if (local.virtual && virtualizer && !header && rowIsOutsideWindow(r)) {
      virtualizer.scrollToIndex(r, {
        align: 'center'
      });
      // Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
      // bump → the framework commits the scrolled-in row. On React that commit is async (setState →
      // reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
      // fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
      // React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
      // fast-committing targets resolve on the first attempt (behavior unchanged), React retries
      // across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
      // cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
      let focusAttempts = 0;
      // #9: capture the epoch AFTER this call's own bump (above) so the poll never aborts itself
      // (its captured value equals the current epoch). A LATER focusActiveCell / focusCell /
      // active-cell-moving focusin bumps the epoch → the check below aborts this stale poll.
      const myEpoch = focusIntentEpoch;
      const focusWhenReady = () => {
        // A newer focus intent superseded this poll — abort WITHOUT focusing (the user has since
        // navigated / clicked elsewhere; re-focusing this off-window target would yank focus back).
        if (focusIntentEpoch !== myEpoch) return;
        const el = resolveCellEl(String(r), c);
        if (el) {
          el.focus();
          return;
        }
        focusAttempts = focusAttempts + 1;
        if (focusAttempts >= 30) return;
        if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
      };
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
      return;
    }
    const rowKey = header ? '__header' : String(r);
    const el = resolveCellEl(rowKey, c, header ? lvl : null);
    if (el) el.focus();
  }

  // ══ Grid keyboard navigation (phase 49 plan 03 — RESEARCH Pattern 5 + the delegated handler) ═══
  // The nav model is plain ARRAY-INDEX MATH over the VISIBLE model. table-core has already
  // done the hard part: $data.rows (body) and $data.headerGroups (header) hold the visible,
  // reordered, pinned cell set (row.getVisibleCells() / getHeaderGroups()) — hidden columns
  // are ALREADY ABSENT, reorder/pinning is ALREADY REFLECTED (REQ-7). There is NO separate
  // "compute visible order" step. Every index is clamped to [0,max] so an out-of-range key
  // never throws or builds an injection-shaped selector (Security V5 / T-49-03).

  // IN-01: aria-rowcount for the NON-VIRTUAL table. The virtual table binds $data.rows.length
  // (the full pre-pagination model). For the non-virtual path $data.rows is the PAGINATED slice,
  // so report the FILTERED (pre-pagination) total instead — the count AT users need to know "row N
  // of TOTAL". Falls back to $data.rows.length pre-mount (table is null until $onMount).
  // NB the helper is named `totalRowCount`, NOT `ariaRowCount`: `ariaRowCount` is an inherited
  // HTMLElement ARIA-reflected property (`Element.ariaRowCount: string`), so a same-named method
  // becomes a class field that shadows it on Lit → TS2416 cascades to EVERY @property decorator
  // (the `valueOf`/`nodeType` inherited-DOM-member collision class, authoring playbook §6).
  function totalRowCount() {
    if (!table) return (rows() || []).length;
    const fm = table.getFilteredRowModel();
    return fm && fm.rows ? fm.rows.length : (rows() || []).length;
  }

  // ── A11y row bookkeeping (#13): consistent aria-rowindex / aria-rowcount ──────────────
  // WAI-ARIA: when aria-rowcount is set on the grid/table, EVERY row (header rows + body rows)
  // must carry an aria-rowindex, and aria-rowcount must equal the total number of rows INCLUDING
  // the header rows. Before this fix aria-rowcount was set unconditionally to totalRowCount() but
  // aria-rowindex was grid-only — so a paginated 'table'-mode grid advertised e.g. rowcount=100
  // while its 10 visible rows carried NO index (SR announced "row 1..10 of 100" on the LAST page).
  //   headerRowCount = the columnheader rows ($data.headerGroups — a grouped/multi-level header is
  //     >1; the role="presentation" filter row is NOT a row and is excluded).
  //   gridAriaRowCount = header rows + the FILTERED pre-pagination data total → equals the largest
  //     aria-rowindex any body row carries, so count and indices are always mutually consistent.
  // NB the helpers are gridAriaRowCount / bodyAriaRowIndex, NOT ariaRowCount / ariaRowIndex: the
  // latter collide with the inherited HTMLElement.ariaRowCount / .ariaRowIndex reflected properties
  // on Lit (TS2416 — the same inherited-DOM-member collision class as totalRowCount's rename note).
  function headerRowCount() {
    return (headerGroups() || []).length;
  }
  function gridAriaRowCount() {
    return headerRowCount() + totalRowCount();
  }
  // Page offset that is MODE-INDEPENDENT (works in BOTH 'table' and 'grid' mode), unlike
  // pageRowOffset() which is isGrid()-gated for the active-cell API. In the non-virtual body
  // $data.rows is only the page slice, so a data row's ABSOLUTE index = its page-relative
  // rowIndexOf + this offset. Virtual mode never reaches here (that body uses wr.vi.index).
  function ariaPageOffset() {
    return table ? pageIndex() * pageSize() : 0;
  }
  // A non-virtual body row's 1-based aria-rowindex: the header rows come first (headerRowCount),
  // then the absolute (page-aware) 0-based data index, +1 to 1-base it. Present in BOTH modes so
  // it is always consistent with gridAriaRowCount. The virtual body binds
  // `headerRowCount() + wr.vi.index + 1` inline (wr.vi.index is already the absolute full-model index).
  function bodyAriaRowIndex(row: any) {
    return headerRowCount() + rowIndexOf(row) + ariaPageOffset() + 1;
  }

  // Column count = the visible cell list length (uniform header+body in a flat grid). Reads
  // $data.rows (reactive) so it is fine-grained-correct on Solid/Lit; falls back to the
  // header leaf count when there are no body rows.
  function visibleColCount() {
    // NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
    // state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
    // self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
    const rowList = rows() || [];
    if (rowList.length) return rowList[0].getVisibleCells().length;
    const hg = headerGroups() || [];
    return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
  }
  function bodyRowCount() {
    return (rows() || []).length;
  }
  function clamp(v: any, lo: any, hi: any) {
    return v < lo ? lo : v > hi ? hi : v;
  }

  // ── Multi-level (grouped) header addressing (B12) ──────────────────────────────────────
  // $data.headerGroups is ordered top→bottom; the LEAF header row (the one adjacent to the
  // body) is the LAST group. The roving active-header state carries activeHeaderLevel (the
  // group index) alongside activeColIndex (the index within THAT level's headers) so the
  // single-tab-stop invariant + ArrowUp parent-resolution span every header level — a flat
  // grid has one level (leafLevel 0), so the table-mode/flat path is unchanged.
  function headerLeafLevel() {
    const hg = headerGroups() || [];
    return hg.length ? hg.length - 1 : 0;
  }
  // #10: the number of header cells AT a given level. A grouped PARENT level may have FEWER
  // headers than there are leaf columns (one parent spans several leaves), so horizontal nav on a
  // non-leaf header must clamp against THIS count — not visibleColCount() (the leaf-column count),
  // which would let ArrowRight/End overrun into a phantom (null) cell → focus dropped to <body>.
  // Degenerate cases (no headerGroups, level out of range) fall back to visibleColCount() so the
  // clamp is never negative or NaN. The LEAF level's count equals visibleColCount() (one header per
  // visible leaf column), so leaf-header + body horizontal nav is byte-behaviorally unchanged.
  function headerCountAtLevel(level: any) {
    const hg = headerGroups() || [];
    if (!hg.length) return visibleColCount();
    const grp = level >= 0 && level < hg.length ? hg[level] : null;
    if (!grp || !grp.headers) return visibleColCount();
    return grp.headers.length;
  }
  function headerAt(level: any, colIndex: any) {
    const hg = headerGroups() || [];
    const grp = hg[level];
    if (!grp || !grp.headers) return null;
    return grp.headers[colIndex] || null;
  }
  // ArrowUp from a (level, colIndex) leaf/child header → the index of its PARENT header in the
  // level above (the parent column that spans it, via table-core header.column.parent). -1 when
  // there is no real parent (already at the top, or a placeholder with no group) → the caller
  // keeps the active header where it is.
  function parentHeaderColIndex(level: any, colIndex: any) {
    if (level <= 0) return -1;
    const h = headerAt(level, colIndex);
    if (!h || !h.column || !h.column.parent) return -1;
    const parentId = h.column.parent.id;
    const hg = headerGroups() || [];
    const pg = hg[level - 1];
    if (!pg || !pg.headers) return -1;
    for (let i = 0; i < pg.headers.length; i++) {
      const ph = pg.headers[i];
      if (ph && ph.column && ph.column.id === parentId) return i;
    }
    return -1;
  }
  // ArrowDown from a (level, colIndex) GROUP header → the index of its FIRST child header in the
  // level below (via table-core column.columns). -1 when the header has no child columns (a leaf)
  // → the caller drops into the body instead.
  function firstChildHeaderColIndex(level: any, colIndex: any) {
    const h = headerAt(level, colIndex);
    if (!h || !h.column) return -1;
    const kids = h.column.columns || [];
    if (!kids.length) return -1;
    const childId = kids[0].id;
    const hg = headerGroups() || [];
    const cg = hg[level + 1];
    if (!cg || !cg.headers) return -1;
    for (let i = 0; i < cg.headers.length; i++) {
      const ch = cg.headers[i];
      if (ch && ch.column && ch.column.id === childId) return i;
    }
    return -1;
  }

  // ── Nav helpers: compute the NEXT indices into LOCAL consts, write $data from them, and
  // RETURN the fresh locals so the caller threads the SAME values into BOTH focusActiveCell
  // AND the activecell-change emit. NEVER re-read $data.activeRow/activeColIndex after the
  // write (React setState is async — ROZ138 — the re-read binds the PRE-write value; Angular
  // signal writes are async too — both proven live by plan 01's probe). ──────────────────────

  // ArrowRight/Left — clamp colIndex over [0, visibleColCount()-1] (no wrap; hidden cols
  // already excluded from the visible list per REQ-7).
  function moveCol(delta: any) {
    // #10: when a grouped PARENT header is active, clamp against the header count AT THE ACTIVE
    // LEVEL (which may be fewer than the leaf-column count) so ArrowRight never overruns onto a
    // phantom cell past that level's headers. Body cells + the leaf header level keep visibleColCount().
    const count = activeIsHeader() ? headerCountAtLevel(activeHeaderLevel()) : visibleColCount();
    const max = count - 1;
    const nextCol = clamp(activeColIndex() + delta, 0, max < 0 ? 0 : max);
    setActiveColIndex(nextCol);
    return nextCol;
  }

  // ArrowUp/Down + PageUp/Down — cross the header boundary and clamp at body edges (no
  // page-cross per D-06/REQ-7). Returns { row, isHeader } fresh locals.
  //  - From the header, ArrowDown (delta>0) drops into body row 0 (activeIsHeader=false).
  //  - From body row 0, ArrowUp (delta<0) crosses into the header (activeIsHeader=true).
  //  - PageUp/Down jump by ±GRID_PAGE_STEP, clamped to the current page bounds (no cross).
  function moveRow(delta: any) {
    const lastRow = bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const leafLevel = headerLeafLevel();
    if (activeIsHeader()) {
      if (delta > 0) {
        // B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
        // level down); from the LEAF header level, drop into the body (row 0). A header-level
        // move re-targets activeColIndex (parent↔child column indices differ), so the fresh
        // col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
        if (activeHeaderLevel() < leafLevel) {
          const childCol = firstChildHeaderColIndex(activeHeaderLevel(), activeColIndex());
          if (childCol >= 0) {
            const nextLevel = activeHeaderLevel() + 1;
            setActiveHeaderLevel(nextLevel);
            setActiveColIndex(childCol);
            return {
              row: activeRow(),
              col: childCol,
              isHeader: true,
              level: nextLevel
            };
          }
        }
        // At the leaf header: an empty grid has no body to drop into → stay put.
        if (bodyRowCount() === 0) return {
          row: activeRow(),
          col: activeColIndex(),
          isHeader: true,
          level: activeHeaderLevel()
        };
        // B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
        // (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
        // page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
        // (delta-1 = 0); clamped to the page-last body row.
        const landRow = clamp(delta - 1, 0, maxRow);
        setActiveIsHeader(false);
        setActiveRow(landRow);
        return {
          row: landRow,
          col: activeColIndex(),
          isHeader: false,
          level: 0
        };
      }
      // B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
      // spans the active column; at the top level (or no real parent) stay put. The parent col
      // index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
      const parentCol = parentHeaderColIndex(activeHeaderLevel(), activeColIndex());
      if (parentCol >= 0) {
        const nextLevel = activeHeaderLevel() - 1;
        setActiveHeaderLevel(nextLevel);
        setActiveColIndex(parentCol);
        return {
          row: activeRow(),
          col: parentCol,
          isHeader: true,
          level: nextLevel
        };
      }
      return {
        row: activeRow(),
        col: activeColIndex(),
        isHeader: true,
        level: activeHeaderLevel()
      };
    }
    // In the body: an upward move from row 0 crosses into the LEAF header level (the header row
    // adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
    // activeColIndex carries over unchanged.
    if (delta < 0 && activeRow() === 0) {
      setActiveIsHeader(true);
      setActiveHeaderLevel(leafLevel);
      return {
        row: activeRow(),
        col: activeColIndex(),
        isHeader: true,
        level: leafLevel
      };
    }
    const nextRow = clamp(activeRow() + delta, 0, maxRow);
    setActiveRow(nextRow);
    setActiveIsHeader(false);
    return {
      row: nextRow,
      col: activeColIndex(),
      isHeader: false,
      level: 0
    };
  }

  // Home/End within the current row → col 0 / max. Returns the fresh colIndex.
  function gotoColEdge(toEnd: any) {
    // #10: End on a grouped PARENT header lands on that level's LAST header (headerCountAtLevel-1),
    // not the leaf-column max — otherwise the ring strands on a phantom cell past the level's
    // headers. Home is index 0 either way. Body cells + the leaf header level keep visibleColCount().
    const count = activeIsHeader() ? headerCountAtLevel(activeHeaderLevel()) : visibleColCount();
    const max = count - 1;
    const nextCol = toEnd ? max < 0 ? 0 : max : 0;
    setActiveColIndex(nextCol);
    return nextCol;
  }

  // gotoRowEdge(toEnd): the §8 (260709-3qt) Ctrl+ArrowUp/Down vertical region-edge jump — move the
  // active cell to the data-region row edge (row 0 / last body row) in the CURRENT column, mirroring
  // gotoColEdge's horizontal edge jump. Body cells only (the caller gates on !activeIsHeader); always
  // lands in the body (activeIsHeader=false). Returns the fresh row index for the shared focus seam.
  function gotoRowEdge(toEnd: any) {
    const lastRow = bodyRowCount() - 1;
    const nextRow = toEnd ? lastRow < 0 ? 0 : lastRow : 0;
    setActiveRow(nextRow);
    setActiveIsHeader(false);
    return nextRow;
  }

  // Ctrl+Home → first body cell (0,0); Ctrl+End → last body cell (lastRow,max). Returns the
  // fresh { row, col } locals. Both land in the body (activeIsHeader=false).
  function gotoStart() {
    setActiveIsHeader(false);
    setActiveRow(0);
    setActiveColIndex(0);
    return {
      row: 0,
      col: 0
    };
  }
  function gotoEnd() {
    const lastRow = bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const max = visibleColCount() - 1;
    const maxCol = max < 0 ? 0 : max;
    setActiveIsHeader(false);
    setActiveRow(maxRow);
    setActiveColIndex(maxCol);
    return {
      row: maxRow,
      col: maxCol
    };
  }

  // Resolve the active cell element (for the in-cell trap) — uses the same data-* query as
  // the focus seam. rowKey is the literal '__header' or String(integer) — no consumer string.
  function currentCellEl() {
    const rowKey = activeIsHeader() ? '__header' : String(activeRow());
    return resolveCellEl(rowKey, activeColIndex(), activeIsHeader() ? activeHeaderLevel() : null);
  }

  // The focusable descendants of a cell (non-disabled), in DOM order. Pure DOM — uniform ×6.
  function focusables(cellEl: any) {
    if (!cellEl || !cellEl.querySelectorAll) return [];
    const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
    return list.filter((n: any) => !n.disabled);
  }

  // Enter/F2 → enter interaction mode: focus the active cell's FIRST interactive control
  // (D-07 — uniform for header sort buttons and body controls; Enter does NOT sort directly).
  // No-op (stay in navigation mode) if the cell has no focusable control.
  function enterControl() {
    const cellEl = currentCellEl();
    const list = focusables(cellEl);
    if (!list.length) return;
    setActiveInControl(true);
    list[0].focus();
  }

  // Cycle focus among the controls WITHIN the active cell (D-08 focus containment) — Tab
  // forward / Shift+Tab backward, wrapping at the ends. Uses the plan-01-PROVEN per-target
  // activeElement read: gridRoot.getRootNode().activeElement is the UNIFORM correct read on
  // ALL SIX (document in light DOM; the shadow root on Lit). Reuse verbatim — do NOT re-derive.
  function cycleWithinCell(cellEl: any, forward: any) {
    const list = focusables(cellEl);
    if (!list.length) return;
    const active = gridRoot ? gridRoot.getRootNode().activeElement : null;
    const cur = list.indexOf(active);
    let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
    if (i >= list.length) i = 0;
    if (i < 0) i = list.length - 1;
    list[i].focus();
  }

  // THE single delegated keydown handler (RESEARCH "Single delegated keydown handler"). Wired
  // as ONE keydown listener on the <table> root — NOT per-cell, NOT with .stop/.prevent modifiers (the
  // Angular .stop-in-@for hoist bug, F5/ROZ723). e.preventDefault() is called IMPERATIVELY for
  // handled keys. Each nav helper writes $data and RETURNS the fresh post-write locals; those
  // SAME locals feed BOTH focusActiveCell AND the activecell-change emit (no $data re-read).
  function onGridKeyDown(e: any) {
    if (!isGrid() || !e) return;
    const key = e.key;
    // Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
    // via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
    // early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
    // modes (editing / in-control / navigation) stay mutually exclusive and ordered.
    if (editingRow() >= 0) return;
    // Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
    // editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
    // keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
    if (editingRowIndex() != null) return;
    // Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
    if (activeInControl()) {
      if (key === 'Escape') {
        e.preventDefault();
        setActiveInControl(false);
        // Return focus to the OWNING cell (no move happened) — pass the current indices
        // explicitly (the React-emitted seam types both params as required; a zero-arg call
        // is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
        focusActiveCell(activeRow(), activeColIndex());
      } else if (key === 'Tab') {
        e.preventDefault();
        cycleWithinCell(currentCellEl(), !e.shiftKey);
      }
      return;
    }
    // WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
    // grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
    // user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
    // NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
    // focused node; if it is not itself a [data-grid-cell], let the event pass through.
    const tgt = e.target;
    if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
    // Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
    // nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
    // async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
    // plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
    // in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
    // fires ONLY on a real move (a clamped no-op edge move leaves them identical).
    const prevRow = activeRow();
    const prevCol = activeColIndex();
    const prevIsHeader = activeIsHeader();
    const prevLevel = activeHeaderLevel();
    let nextRow = prevRow;
    let nextCol = prevCol;
    let nextIsHeader = prevIsHeader;
    // B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
    // threaded into the focus seam so a leaf↔parent header move lands focus at the correct
    // level. moveRow returns it; the non-vertical branches keep the pre-move level.
    let nextLevel = prevLevel;
    // ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
    // the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
    // fall through to a plain navigation move). Body cells only (no range from a header). The
    // extendRange call owns focus + the range-change emit, so return immediately. ──────────
    // ── §8 (260709-3qt) Ctrl/Cmd+Arrow — jump the active cell to the data-region edge (plain
    // Ctrl) or EXTEND the range to that edge (Ctrl+Shift). Body cells only (a header-active
    // Ctrl+Arrow falls through to the plain-arrow branches unchanged). Tested BEFORE the
    // Shift+Arrow / plain-arrow cascade so the modifier combo is matched first. preventDefault
    // suppresses the browser's native Ctrl+Arrow scroll/word-jump. The Ctrl+Shift branch owns
    // extendRange's focus + range-change emit (returns); the plain-Ctrl branch sets the fresh
    // nextRow/nextCol locals and FALLS THROUGH to the shared focus seam (like Ctrl+Home/End). ──
    if ((e.ctrlKey || e.metaKey) && e.shiftKey && !activeIsHeader() && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
      e.preventDefault();
      if (key === 'ArrowUp') extendRange(-activeRow(), 0);else if (key === 'ArrowDown') extendRange(bodyRowCount() - 1 - activeRow(), 0);else if (key === 'ArrowLeft') extendRange(0, -activeColIndex());else extendRange(0, visibleColCount() - 1 - activeColIndex());
      return;
    } else if ((e.ctrlKey || e.metaKey) && !activeIsHeader() && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
      e.preventDefault();
      clearRange();
      if (key === 'ArrowUp') {
        nextRow = gotoRowEdge(false);
        nextIsHeader = false;
      } else if (key === 'ArrowDown') {
        nextRow = gotoRowEdge(true);
        nextIsHeader = false;
      } else if (key === 'ArrowLeft') {
        nextCol = gotoColEdge(false);
      } else {
        nextCol = gotoColEdge(true);
      }
    } else if (key === 'ArrowRight' && e.shiftKey && !activeIsHeader()) {
      e.preventDefault();
      extendRange(0, 1);
      return;
    } else if (key === 'ArrowLeft' && e.shiftKey && !activeIsHeader()) {
      e.preventDefault();
      extendRange(0, -1);
      return;
    } else if (key === 'ArrowDown' && e.shiftKey && !activeIsHeader()) {
      e.preventDefault();
      extendRange(1, 0);
      return;
    } else if (key === 'ArrowUp' && e.shiftKey && !activeIsHeader()) {
      e.preventDefault();
      extendRange(-1, 0);
      return;
    } else if (key === 'ArrowRight') {
      e.preventDefault();
      clearRange();
      nextCol = moveCol(1);
    } else if (key === 'ArrowLeft') {
      e.preventDefault();
      clearRange();
      nextCol = moveCol(-1);
    } else if (key === 'ArrowDown') {
      e.preventDefault();
      clearRange();
      const m = moveRow(1);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'ArrowUp') {
      e.preventDefault();
      clearRange();
      const m = moveRow(-1);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'PageDown') {
      e.preventDefault();
      const m = moveRow(GRID_PAGE_STEP);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'PageUp') {
      e.preventDefault();
      const m = moveRow(-GRID_PAGE_STEP);
      nextRow = m.row;
      nextCol = m.col;
      nextIsHeader = m.isHeader;
      nextLevel = m.level;
    } else if (key === 'Home') {
      e.preventDefault();
      if (e.ctrlKey || e.metaKey) {
        const s = gotoStart();
        nextRow = s.row;
        nextCol = s.col;
        nextIsHeader = false;
      } else {
        nextCol = gotoColEdge(false);
      }
    } else if (key === 'End') {
      e.preventDefault();
      if (e.ctrlKey || e.metaKey) {
        const en = gotoEnd();
        nextRow = en.row;
        nextCol = en.col;
        nextIsHeader = false;
      } else {
        nextCol = gotoColEdge(true);
      }
    }
    // ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
    // pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
    // edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
    // type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
    // gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
    // falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
    // (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
    else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
      e.preventDefault();
      copyRange();
      return;
    } else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
      e.preventDefault();
      pasteRange();
      return;
    }
    // ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
    // source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
    // Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
    // and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
    // shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
    else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && clipboardActiveAllowed()) {
      e.preventDefault();
      cutRange();
      return;
    }
    // ── 260709-8ct (grid-wide undo/redo) — Ctrl/Cmd+Z undoes; Ctrl/Cmd+Y OR Ctrl/Cmd+Shift+Z
    // redoes. Undoable-gated (`$props.undoable`) — when off, neither preventDefault nor
    // undo()/redo() runs, so a shipped grid with undoable unset is byte-behaviorally unchanged
    // (the browser's own native undo/redo, if any, still fires). NOT clipboardActiveAllowed-
    // gated (unlike Ctrl+C/V/X/Delete above): undo/redo is GRID-WIDE and must work regardless of
    // whether a header or body cell is active. Tested the Ctrl+Shift+Z (redo) combo BEFORE the
    // plain Ctrl+Z (undo) branch so a Shift+Z never falls into undo.
    else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey) && e.shiftKey) {
      if (local.undoable) {
        e.preventDefault();
        redo();
        return;
      }
    } else if ((key === 'y' || key === 'Y') && (e.ctrlKey || e.metaKey)) {
      if (local.undoable) {
        e.preventDefault();
        redo();
        return;
      }
    } else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey)) {
      if (local.undoable) {
        e.preventDefault();
        undo();
        return;
      }
    }
    // ── §7 (260709-3qt) — Delete/Backspace CLEARS the active cell / range through the SAME
    // write-funnel as Cut (applyGridToRange of an empty grid), MINUS the clipboard copy. B11-gated
    // by clipboardActiveAllowed so a header-active Delete/Backspace falls through to NATIVE behavior
    // (never a silent body mutation). The top-of-handler editing early-returns + the line-39
    // data-grid-cell guard keep this to navigation mode; applyGridToRange skips read-only/non-editable
    // cells. Reversible via Ctrl+Z when `undoable` is on (260709-8ct) — clearActiveRange funnels
    // through the SAME writeData seam undo/redo replay through, so no separate inverse machinery
    // is needed here.
    else if ((key === 'Delete' || key === 'Backspace') && clipboardActiveAllowed()) {
      e.preventDefault();
      clearActiveRange();
      return;
    }
    // ── §8 (260709-3qt) — Ctrl/Cmd+A selects the WHOLE BODY range (drives the same range corners
    // shift+arrow uses). preventDefault ALWAYS so the page is never selected in grid mode; only a
    // body-active Ctrl+A builds the range (a header-active Ctrl+A is a no-op — selects nothing). ──
    else if ((key === 'a' || key === 'A') && (e.ctrlKey || e.metaKey)) {
      e.preventDefault();
      if (!activeIsHeader()) selectAllBody();
      return;
    }
    // ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
    // EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
    // (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
    // collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
    // at least the active editable column); a non-editable active cell falls through unchanged.
    else if (key === 'F2' && e.shiftKey && isActiveCellEditable()) {
      e.preventDefault();
      beginRowEdit((rows() || [])[activeRow()]);
      return;
    }
    // ── Boolean in-place toggle (design doc 2026-07-05, Change 1) — a built-in
    // editor:'checkbox' cell toggles + commits INSTANTLY on Space/Enter/F2, no editor opens
    // (the spreadsheet-standard shape for a two-state value). Tested BEFORE the generic
    // Enter/F2 edit-entry branch below (a checkbox cell must never fall into the open-an-
    // editor ceremony) and gated the SAME way (isActiveCellEditable) plus editorTypeOf ===
    // 'checkbox'. Full-row edit mode is unaffected — the editingRowIndex early return at the
    // top of onGridKeyDown already excludes it.
    else if ((key === 'Enter' || key === 'F2' || key === ' ') && isActiveCellEditable() && editorTypeOf(activeCellColumnId()) === 'checkbox') {
      e.preventDefault();
      toggleActiveBooleanCell();
      return;
    }
    // ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
    // Gated by isActiveCellEditable(): a non-editable active cell falls through to
    // enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
    // (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
    else if ((key === 'Enter' || key === 'F2') && isActiveCellEditable()) {
      e.preventDefault();
      beginEdit(activeRow(), activeColIndex(), null);
      return;
    } else if (isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && editorTypeOf(activeCellColumnId()) !== 'checkbox') {
      // B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
      // checkbox/select/date editor must NOT take the typed char as its value (it would
      // force-check the checkbox, seed a garbage select option, or corrupt the date) — open
      // those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
      // Checkbox is excluded entirely (type-to-edit disabled — the branch above already
      // handles Space/Enter/F2; any OTHER printable key on a checkbox cell is a no-op).
      e.preventDefault();
      const editType = editorTypeOf(activeCellColumnId());
      const seed = editType === 'text' || editType === 'number' ? key : null;
      beginEdit(activeRow(), activeColIndex(), seed);
      return;
    }
    // ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
    // expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
    // verified invariant) so it never hits the edit branches above and would otherwise fall to
    // enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
    // Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
    // model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
    // ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
    // full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
    else if (key === 'Enter' && !activeIsHeader() && rowIsGrouped((rows() || [])[activeRow()])) {
      e.preventDefault();
      // C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
      // active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
      // the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
      // row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
      // fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
      // DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
      // while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
      // settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
      // onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
      // SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
      // until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
      // already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
      const grpRow = activeRow();
      const grpCol = activeColIndex();
      onToggleExpand((rows() || [])[activeRow()], e);
      // guardMoved=true: the group header row is UNCHANGED by its own collapse, so a stale late
      // rAF poll must not steal focus back after the user has already ArrowDown'd to another row.
      recoverGridFocus(String(grpRow), grpCol, null, true);
      return;
    } else if (key === 'Enter' || key === 'F2') {
      e.preventDefault();
      enterControl();
      return;
    } else return;
    // THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
    // focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
    focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
    // WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
    // changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
    // row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
    // B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
    // C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
    // keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
    // toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
    // The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
    if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
      // Mirror getActiveCell's shape (this payload + getActiveCell are documented to speak the
      // SAME language): a header cell has no body-row index, so emit rowIndex:null + isHeader:true
      // rather than a bogus toAbsRow(nextRow) — which would compute a real body-row absolute index
      // for a HEADER move, misleading a consumer into thinking that body row is the active cell.
      _props.onActivecellChange?.(nextIsHeader ? {
        rowIndex: null,
        colIndex: nextCol,
        isHeader: true
      } : {
        rowIndex: toAbsRow(nextRow),
        colIndex: nextCol,
        isHeader: false
      });
    }
  }

  // WR-03: integrate mouse-click + programmatic focus with the roving model. A click on a
  // tabindex="-1" cell (or focus arriving any way other than the keyboard nav path) moves
  // DOM focus there but does NOT run onGridKeyDown — so activeRow/activeColIndex would stay
  // on the OLD cell and the NEXT arrow key would jump from the stale active cell. Wired as
  // ONE @focusin on the <table> root (focusin bubbles): resolve the focused element's owning
  // [data-grid-cell], parse its data-row/data-col-index, and write them into the active-cell
  // state (mirroring the keyboard path). Clears activeInControl ONLY when the cell ITSELF
  // (not an inner control) received focus — focusing a control via Enter keeps the in-control
  // flag. NEVER emits activecell-change (a focus sync is not a keyboard navigation event).
  function syncActiveFromEvent(e: any) {
    if (!isGrid() || !e) return;
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null) return;
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(col)) return;
    // #9: snapshot the PRE-write active position so we can bump the focus-intent epoch ONLY when
    // this focusin genuinely MOVES the active cell (a click landing on a NEW cell). A no-op focusin
    // — focus arriving on the ALREADY-active cell, e.g. a scroll/page-switch poll's own el.focus()
    // or focusActiveCell's synchronous re-seat — must NOT bump, or it would abort a legitimate
    // in-flight recovery on its own settling frames (the poll would see a changed epoch and quit).
    const prevIsHeader = activeIsHeader();
    const prevRow = activeRow();
    const prevCol = activeColIndex();
    const prevLevel = activeHeaderLevel();
    const isHeader = rowAttr === '__header';
    setActiveIsHeader(isHeader);
    let movedRow = prevRow;
    let movedLevel = prevLevel;
    if (isHeader) {
      // B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
      // roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
      // one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
      const lvlAttr = cellEl.getAttribute('data-header-level');
      const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : headerLeafLevel();
      movedLevel = Number.isFinite(lvl) ? lvl : headerLeafLevel();
      setActiveHeaderLevel(movedLevel);
    } else {
      const row = parseInt(rowAttr, 10);
      if (Number.isFinite(row)) {
        movedRow = row;
        setActiveRow(row);
      }
    }
    setActiveColIndex(col);
    // #9: a genuine active-cell MOVE is a fresh focus intent — supersede any pending async focus
    // poll (scroll-to / page-switch). Compare against the PRE-write snapshot: bump only when the
    // header-flag, column, or (per mode) the header LEVEL / body ROW actually changed.
    if (isHeader !== prevIsHeader || col !== prevCol || (isHeader ? movedLevel !== prevLevel : movedRow !== prevRow)) {
      focusIntentEpoch = focusIntentEpoch + 1;
    }
    // A plain focus collapses any range back to the single active cell — EXCEPT (a) the
    // programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
    // ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
    // focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
    // BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
    // path owns the shift case and flags it here so the collapse is skipped.
    if (rangeTransition) {
      rangeTransition = false;
    } else if (rangeClickPending) {
      rangeClickPending = false;
    } else {
      clearRange();
    }
    // The cell box (not an inner control) receiving focus = navigation mode.
    if (tgt === cellEl) setActiveInControl(false);
  }

  // onGridMouseDown: the pointer range seam (phase 51 req-7 / D-07 Shift+Click; §6 260709-3qt
  // plain drag-to-select). A focusin event carries no reliable `shiftKey`, so the modifier MUST
  // be read off the pointer event — @mousedown fires BEFORE the cell's focusin and DOES carry
  // shiftKey. A shift-held mousedown on a BODY cell sets the range's moving corner to that cell
  // (keeping the anchor), then flags rangeClickPending so the follow-up focusin does not collapse
  // the range. A PLAIN (non-shift) mousedown BEGINS a drag-select anchored at that cell (§6): the
  // document pointermove/up listeners paint the range as the pointer moves. The fill handle owns
  // its own @pointerdown drag (it stops propagation), so a plain mousedown originating inside it is
  // skipped. Do NOT preventDefault — native focus must still land (focusin sync + roving tabindex).
  function onGridMouseDown(e: any) {
    if (!isGrid() || !e) return;
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    // §6: a plain mousedown inside the fill handle is owned by the handle's own pointerdown drag —
    // never begin a range paint from it (the shift path never lands on the 8px handle).
    if (!e.shiftKey && tgt.closest('[data-fill-handle]')) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
    const row = parseInt(rowAttr, 10);
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(row) || !Number.isFinite(col)) return;
    if (e.shiftKey) {
      // Shift+Click: set the moving corner (keeping the anchor) and flag rangeClickPending so the
      // follow-up focusin does not collapse the range (a focusin carries no reliable shiftKey).
      setRangeFocus$local(row, col);
      setActiveIsHeader(false);
      setActiveRow(row);
      setActiveColIndex(col);
      rangeClickPending = true;
      return;
    }
    // §6 plain mousedown → begin a document-level drag-select anchored at this cell. The mousedown's
    // native focusin commits the ACTIVE cell to (row,col); beginRangeDrag's first cross-cell
    // pointermove paints the range via setRangeFocus (anchored at the active cell). A mousedown with
    // no move collapses to a single active cell (no range).
    beginRangeDrag(row, col);
  }

  // onGridDblClick: the double-click-into-edit seam (grid pointer §3+§5, 260708-ni6). Wired as
  // ONE @dblclick on the <table> root (mirroring the already-delegated @mousedown/@focusin). A
  // double-click on a BODY cell either toggles a group (group-header cell) or opens the editor
  // (editable cell); a non-editable body cell is a no-op (the cell stays active — its focusin
  // already set the active state + the §1 ring). Header cells return early so they keep their
  // native sort/menu/resize semantics. Reuses the SAME closest/parse/finite guards as
  // syncActiveFromEvent and the SAME beginEdit / onToggleExpand funnels the keyboard path uses —
  // no new edit or expand machinery. isGrid()-gated so 'table' mode never runs it.
  function onGridDblClick(e: any) {
    if (!isGrid() || !e) return;
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
    const row = parseInt(rowAttr, 10);
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(row) || !Number.isFinite(col)) return;
    // NB the local is `rowObj` (NOT `activeRow`): $data.activeRow lowers to the bare React state
    // binding `activeRow`, so a `const activeRow = …` local self-shadows it (TS2448 TDZ — the
    // visibleColCount `rowList` self-shadow class). ($data.rows || [])[row] is the active flattened
    // row (page-relative non-virtual / full-model virtual — both index $data.rows, matching the C2
    // Enter-on-group path + syncActiveFromEvent's row parse).
    const rowObj = (rows() || [])[row];
    if (rowIsGrouped(rowObj)) {
      // Group-header cell → toggle its collapse/expand through the SAME onToggleExpand funnel the
      // chevron uses (mirrors the C2 Enter-on-group path verbatim), then re-seat focus after the
      // re-render (guardMoved=true — the group-header row is unchanged by its own collapse, so a
      // stale late rAF must not steal focus back after a subsequent nav).
      e.preventDefault();
      onToggleExpand(rowObj, e);
      recoverGridFocus(String(row), col, null, true);
      return;
    }
    // Editable body cell → open its editor (seed=null → seed the EXISTING value, the in-place F2/
    // Enter entry). A non-editable body cell is a no-op: the cell stays active (focusin already set
    // it + the §1 ring), matching the spreadsheet display-vs-edit convention.
    const colId = columnIdAt(row, col);
    if (colId != null && columnEditable(colId)) {
      e.preventDefault();
      beginEdit(row, col, null);
    }
  }

  // onGridClick: the opt-in single-click-to-edit seam (grid pointer §4, 260708-ni6). Only active
  // when the `singleClickEdit` prop is true (default false, negative-opt-out). Wired as ONE @click
  // on the <table> root — @click fires on a genuine mouseup-no-drag click (NOT @mousedown), which
  // honors the deferred §6 drag guard (a mousedown that begins a drag-select must not open an
  // editor). A plain click on an EDITABLE body cell opens its editor via the SAME beginEdit funnel;
  // shift+click (range extend) and non-editable cells are unaffected. Same closest/parse/header-skip
  // /finite guards as onGridDblClick. isGrid()-gated so 'table' mode never runs it.
  function onGridClick(e: any) {
    if (!isGrid() || !e) return;
    if (!local.singleClickEdit) return;
    if (e.shiftKey) return;
    // §6 (260709-3qt): a drag-select that MOVED must never open the editor — the editor opens only
    // on a genuine mouseup-no-drag click. beginRangeDrag resets rangeDragMoved=false per gesture, so
    // the flag is always fresh; consume it here so a subsequent plain click still edits.
    if (rangeDragMoved) {
      rangeDragMoved = false;
      return;
    }
    const tgt = e.target;
    if (!tgt || !tgt.closest) return;
    const cellEl = tgt.closest('[data-grid-cell]');
    if (!cellEl) return;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
    const row = parseInt(rowAttr, 10);
    const col = parseInt(colAttr, 10);
    if (!Number.isFinite(row) || !Number.isFinite(col)) return;
    // Already editing THIS exact cell → no-op (a click inside an open editor must not re-open it).
    if (editingRow() === row && editingCol() === col) return;
    const colId = columnIdAt(row, col);
    if (colId != null && columnEditable(colId)) beginEdit(row, col, null);
  }

  // WR-02: reset the interaction-mode flag when focus leaves the active cell's subtree.
  // Without this, activeInControl could stick `true` — a mouse click OUTSIDE the cell, or
  // the focused inner control being removed from the DOM — leaving onGridKeyDown wedged in
  // the in-cell-trap branch so arrow nav is dead until Escape. Wired as ONE @focusout on
  // the <table> root (focusout bubbles, unlike blur). relatedTarget is the element RECEIVING
  // focus (null when focus leaves the document / is retargeted across a shadow boundary). If
  // focus is NOT moving to a descendant of the active cell, drop the flag. A Tab-cycle WITHIN
  // the cell (interaction mode) keeps relatedTarget inside cellEl → no reset.
  function onGridFocusOut(e: any) {
    if (!isGrid() || !activeInControl()) return;
    const next = e ? e.relatedTarget : null;
    const cellEl = currentCellEl();
    if (!cellEl || !next || !cellEl.contains(next)) setActiveInControl(false);
  }

  // B25: re-focus a resolved valid cell AFTER a programmatic shrink re-renders. The clamp
  // runs synchronously BEFORE the framework commits the new tbody, so a deferred rAF-poll
  // resolves the [data-row][data-col-index] cell off gridRoot once it has rendered (the fast
  // targets land on attempt 1; React/Solid retry across the async commit). Mirrors
  // focusCellWhenReady (B23) — DOM-only (reads gridRoot), so it is React-stale-safe.
  // guardMoved (default false): when true, the poll does NOT stomp focus that a later nav has
  // already moved to a DIFFERENT, STILL-VALID row — used only by the group-collapse re-seat (the
  // target group-header row is unchanged, so a stale late rAF must not steal focus back after the
  // user ArrowDown'd away → the non-deterministic treegrid collapsed-nav focus-theft). It is left
  // OFF for the B25 shrink-recovery site, whose target is a CLAMPED index of a now-REMOVED cell:
  // there focus legitimately sits on the doomed old cell (a different row) mid-async-render on
  // React and MUST be recovered onto the clamped survivor, not preserved. Compare data-row (NOT
  // node identity) so a stale SAME-row cell on Solid's node-replacing re-render still resolves as
  // the target — a genuinely dropped focus is always recovered on both sites.
  function recoverGridFocus(rowKey: any, col: any, level: any, guardMoved = false) {
    if (!gridRoot) return;
    let attempts = 0;
    const tryFocus = () => {
      if (guardMoved) {
        const ae = gridRoot && gridRoot.getRootNode ? gridRoot.getRootNode().activeElement : null;
        const aeCell = ae && ae.closest ? ae.closest('[data-grid-cell]') : null;
        if (aeCell && gridRoot.contains(aeCell)) {
          const aeRow = aeCell.getAttribute('data-row');
          if (aeRow != null && aeRow !== rowKey) return;
        }
      }
      const el = resolveCellEl(rowKey, col, level);
      if (el) {
        el.focus();
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  }

  // D-05: clamp the active cell to bounds on every underlying-data change (re-sort, filter,
  // pagination, page-size). KEEP the same indices; clamp ONLY when the grid shrank — NO
  // row-id following, NO bounce-to-top on a filter keystroke. Gated by isGrid() so 'table'
  // mode is entirely untouched. Invoked at the rowModelVer bump path (refreshRowModel).
  function clampActiveCell(rowCount: any, colCount: any) {
    if (!isGrid()) return;
    // B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
    // just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
    // async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
    // and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
    // out of bounds on React only). Falls back to the live helpers when called without bounds.
    const colN = colCount != null ? colCount : visibleColCount();
    const rowN = rowCount != null ? rowCount : bodyRowCount();
    // B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
    // shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
    // framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
    // — true on all six, incl React's async setState), so the doomed cell + its focus are still
    // observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
    // programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
    // Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
    // doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
    // The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
    // NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
    // shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
    // DOM coords are always fresh.
    let recoverFocus = false;
    let doomedRow = -1;
    let doomedCol = 0;
    if (gridRoot) {
      const rootNode = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
      const focusedEl = rootNode ? rootNode.activeElement : null;
      const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
      if (focusedCell && gridRoot.contains(focusedCell)) {
        const fRowAttr = focusedCell.getAttribute('data-row');
        const fColAttr = focusedCell.getAttribute('data-col-index');
        if (fRowAttr != null && fRowAttr !== '__header') {
          const fr = parseInt(fRowAttr, 10);
          const fc = parseInt(fColAttr, 10);
          if (Number.isFinite(fr) && fr > rowN - 1) {
            recoverFocus = true;
            doomedRow = fr;
            doomedCol = Number.isFinite(fc) ? fc : 0;
          }
        }
      }
    }
    const maxCol = colN - 1;
    const col = clamp(activeColIndex(), 0, maxCol < 0 ? 0 : maxCol);
    if (col !== activeColIndex()) setActiveColIndex(col);
    // B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
    // cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
    // an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
    // re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
    if (rowN <= 0) {
      setActiveIsHeader(true);
      setActiveHeaderLevel(headerLeafLevel());
      setActiveColIndex(0);
      // B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
      // reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
      // async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
      // class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
      // recovery branch below actually runs on React too.
      gridEmptyFallback = true;
      clampRange(rowN - 1, colN - 1);
      // B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
      // reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
      // Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
      // (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
      // the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
      return;
    }
    // B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
    // re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
    // cell. A user-driven header position (not the empty fallback) is left untouched.
    if (gridEmptyFallback) {
      gridEmptyFallback = false;
      setActiveIsHeader(false);
      setActiveRow(0);
    }
    if (!activeIsHeader()) {
      const lastRow = rowN - 1;
      const maxRow = lastRow < 0 ? 0 : lastRow;
      const row = clamp(activeRow(), 0, maxRow);
      if (row !== activeRow()) setActiveRow(row);
    }
    // B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
    // shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
    // out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
    clampRange(rowN - 1, colN - 1);
    // B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
    // when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
    // into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
    if (recoverFocus) {
      const recRow = clamp(doomedRow, 0, rowN - 1);
      const recCol = clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
      recoverGridFocus(String(recRow), recCol, null);
    }
  }

  // B6 (phase 63 wave-11) — "the active cell is parked on the empty-grid header fallback" control
  // flag, written + read ONLY inside clampActiveCell (never bound in the template). It MUST be a
  // plain component-scope `let` (React hoists to useRef), NOT a $data reactive field: clampActiveCell
  // is reached through the mount-time refreshRowModel closure, so a `$data.gridEmptyFallback` READ
  // there binds the async-stale mount-time value on React (setState is async — the rangeActive /
  // pendingEditFollow / B23-nextRows stale-read class). With the body re-populated after a filter
  // CLEAR, that stale read skipped the recovery branch on React → the roving tab-stop stayed on the
  // header fallback (columnheader) instead of re-seating a body cell (the B6 recovery gap). A
  // synchronously-written plain `let` is read fresh on all six → the empty→non-empty recovery
  // re-seats activeRow 0 on React too. The other 5 targets are byte-behaviorally identical (they
  // already read reactive $data synchronously). A top-level reassigned `let` referenced from the
  // refreshRowModel/clampActiveCell chain → React hoists to useRef → persists per-instance.
  let gridEmptyFallback = false;

  // ══ Cell-range selection (phase 51 plan 04 / req-7 / D-07) ═══════════════════════════════
  // A rectangular cell range over the FULL visible model, addressed BY INDEX PAIRS
  // (rangeAnchor/rangeFocus = { rowIndex, colIndex }) — NEVER a stored DOM node, so the
  // highlight reattaches to the correct cells across virtualization recycling (the
  // activeRow/activeColIndex invariant). ONE-WAY (D-07): exposed via getSelectedRange +
  // range-change, NOT a model:true slice. Coexists with — and is visually distinct from —
  // the row-selection slice (the two never touch each other's state).

  // inRange(rIdx, cIdx): is the cell at the visible-model index pair inside the current
  // rectangle? Pure index math (the min/max box of anchor+focus). False when no range —
  // the byte-identical-off guard for the range markup (no anchor/focus → no :data-in-range).
  // rangeTransition: set true while extendRange/setRangeFocus moves DOM focus to the new
  // range-focus corner. That focus move fires @focusin → syncActiveFromEvent with NO shiftKey
  // (a programmatic focus carries no modifier), which would otherwise clearRange() and wipe the
  // range we just set. The flag suppresses that collapse for the in-flight focus settle (the
  // editTransition blur-guard precedent). A top-level let → React hoists to useRef.
  let rangeTransition = false;
  // rangeClickPending: set by onGridMouseDown on a Shift+Click (the range is set off the
  // pointer event's shiftKey BEFORE the cell's focusin fires); the follow-up focusin reads it
  // to SKIP the range-collapse (a focusin carries no reliable shiftKey). Reset on consumption.
  let rangeClickPending = false;
  // B19: a SYNCHRONOUS mirror of "a range currently exists" — extendRange/setRangeFocus set it
  // true, clearRange/clampRange-to-empty set it false. clearRange is invoked TWICE in one plain-
  // arrow keydown (the explicit collapse + the focusin that follows the programmatic focus move);
  // on React `$data.rangeAnchor = null` is an async setState, so the SECOND clearRange's
  // `$data.rangeAnchor == null` guard reads the STALE (pre-write) range and fires a duplicate
  // range-change. This module-let is written synchronously (no setState async), so the second
  // clearRange sees `rangeActive === false` and returns → exactly ONE range-change per real drop
  // across all six targets. A top-level let → React hoists to useRef.
  let rangeActive = false;
  function inRange(rIdx: any, cIdx: any) {
    const a = rangeAnchor();
    const f = rangeFocus();
    if (!a || !f) return false;
    const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
    const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
    const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
    const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
    return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
  }

  // getSelectedRange(): the current range as plain integers — { anchor, focus } each a
  // { rowIndex, colIndex } pair (or null when no range). T-49-02: positions only, no row
  // data, no DOM node. Used by the getSelectedRange $expose verb AND every range-change emit
  // (the single payload source) AND copyRange/fillRange (the rectangle they operate over).
  function getSelectedRange() {
    // B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
    // payload) never reports a corner past a shrunken model — React-stale-safe (the eager
    // refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
    const a = rangeAnchor();
    const f = rangeFocus();
    if (!a && !f) return {
      anchor: null,
      focus: null
    };
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return {
      anchor: null,
      focus: null
    };
    const clampCorner = (c: any) => c == null ? null : {
      rowIndex: clamp(c.rowIndex, 0, maxRow),
      colIndex: clamp(c.colIndex, 0, maxCol)
    };
    return {
      anchor: clampCorner(a),
      focus: clampCorner(f)
    };
  }

  // isFillHandleCell(rIdx, cIdx): is this cell the BOTTOM-RIGHT corner of the current range?
  // That corner hosts the fill-handle affordance (req-8 / D-04). False without a range — the
  // byte-identical-off guard for the handle markup (no range → no handle).
  function isFillHandleCell(rIdx: any, cIdx: any) {
    const a = rangeAnchor();
    const f = rangeFocus();
    if (!a || !f) return false;
    const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
    const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
    return rIdx === r1 && cIdx === c1;
  }

  // emitRangeChange(anchor, focus): fire range-change with the FRESH range corners passed by
  // the caller — NOT a re-read of $data.rangeAnchor/rangeFocus. The range corners are <data>
  // (useState on React), so re-reading right after the same-tick setState returns the STALE
  // pre-write value (ROZ138). extendRange/setRangeFocus thread the just-computed locals through
  // here so the emitted payload matches the write. The single call site keeps the count
  // predictable (React multi-emit dedup, D-07). One-way notification.
  function emitRangeChange(anchor: any, focus: any) {
    _props.onRangeChange?.({
      anchor,
      focus
    });
  }

  // extendRange(dRow, dCol): move rangeFocus by the (row,col) delta, clamped to the grid
  // bounds, seeding rangeAnchor from the active cell when no range exists yet (Shift+Arrow
  // from a bare active cell starts a 1×N / N×1 rectangle anchored at that cell). Body cells
  // only (header rows are not range-selectable). Emits range-change from this single site.
  function extendRange(dRow: any, dCol: any) {
    if (activeIsHeader()) return;
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return;
    // Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
    let anchor = rangeAnchor();
    let focus = rangeFocus();
    const hadRange = !!(anchor && focus);
    if (!anchor || !focus) {
      anchor = {
        rowIndex: activeRow(),
        colIndex: activeColIndex()
      };
      focus = {
        rowIndex: activeRow(),
        colIndex: activeColIndex()
      };
    }
    const nextRow = clamp(focus.rowIndex + dRow, 0, maxRow);
    const nextCol = clamp(focus.colIndex + dCol, 0, maxCol);
    const nextFocus = {
      rowIndex: nextRow,
      colIndex: nextCol
    };
    setRangeAnchor(anchor);
    setRangeFocus(nextFocus);
    rangeActive = true;
    // Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
    // from the range's leading edge, the spreadsheet convention).
    setActiveRow(nextRow);
    setActiveColIndex(nextCol);
    // Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
    // settle on the new focus corner is part of THIS range extension, not a fresh navigation.
    rangeTransition = true;
    focusActiveCell(nextRow, nextCol, false);
    // B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
    // and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
    // change → no emit. Seeding a brand-new range (no prior range) is always a change (the
    // rectangle came into existence) even if its first corner is a degenerate 1×1.
    if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
      emitRangeChange(anchor, nextFocus);
    }
  }

  // setRangeFocus(rIdx, cIdx): set the moving corner to an explicit cell (Shift+Click),
  // seeding the anchor from the active cell when no range exists yet. Clamped to bounds.
  // Emits range-change from this single site.
  function setRangeFocus$local(rIdx: any, cIdx: any) {
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return;
    let anchor = rangeAnchor();
    if (!anchor) anchor = {
      rowIndex: activeRow(),
      colIndex: activeColIndex()
    };
    const r = clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
    const c = clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
    const nextFocus = {
      rowIndex: r,
      colIndex: c
    };
    setRangeAnchor(anchor);
    setRangeFocus(nextFocus);
    rangeActive = true;
    emitRangeChange(anchor, nextFocus);
  }

  // selectAllBody(): the §8 (260709-3qt) Ctrl+A whole-body select — set the range to span EVERY
  // body cell (anchor at the first body cell (0,0), moving corner at the last (maxRow, maxCol)),
  // driving the SAME range corners shift+arrow / setRangeFocus use. Emits range-change from a single
  // site (the emitRangeChange contract — pass the FRESH corners, never a $data re-read). No-op on an
  // empty grid. Body cells only — a header-active Ctrl+A is gated OUT by the caller (never builds a
  // range from a header). rangeActive is set synchronously so a follow-up clearRange collapses it.
  function selectAllBody() {
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return;
    const anchor = {
      rowIndex: 0,
      colIndex: 0
    };
    const focus = {
      rowIndex: maxRow,
      colIndex: maxCol
    };
    setRangeAnchor(anchor);
    setRangeFocus(focus);
    rangeActive = true;
    emitRangeChange(anchor, focus);
  }

  // clearRange(): drop the rectangle (a non-shift navigation / edit-entry collapses any
  // range back to a single active cell). Cheap no-op when no range is set (the guard keeps a
  // plain navigation with no active range from emitting). B19: when a range DID exist, emit
  // range-change with null corners so a consumer mirroring the selection through the event sees
  // the drop — without this they hold a STALE rectangle after every non-shift navigation /
  // edit-entry collapse (getSelectedRange already reports null, but the event never fired).
  function clearRange() {
    // B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
    // in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
    // move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
    // would let the SECOND call through and emit a duplicate range-change. rangeActive flips
    // synchronously → the second call returns here.
    if (!rangeActive) return;
    rangeActive = false;
    setRangeAnchor(null);
    setRangeFocus(null);
    emitRangeChange(null, null);
  }

  // B8: clamp the range corners to the current grid bounds after an underlying-data change
  // (sort/filter/paginate/page-size all re-derive the row model). A range whose rows now exceed
  // the shrunken model would otherwise leave STALE/phantom corners → a copy serializes empty
  // rows past the model's end (and getSelectedRange reports out-of-bounds corners). We CLAMP each
  // corner into [0,maxRow]×[0,maxCol] (preserving a valid rectangle — a corner that clamps onto
  // another keeps the range non-empty); when no selectable body cell remains the rectangle is
  // dropped. Does NOT emit range-change here — the clamp is a reconcile, not a user selection
  // move (the emit-on-change work, B18/B19, lands in plan 63-05). Called from clampActiveCell.
  function clampRange(maxRowArg: any, maxColArg: any) {
    const a = rangeAnchor();
    const f = rangeFocus();
    if (!a && !f) return;
    // Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
    // shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
    const maxRow = maxRowArg != null ? maxRowArg : bodyRowCount() - 1;
    const maxCol = maxColArg != null ? maxColArg : visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) {
      setRangeAnchor(null);
      setRangeFocus(null);
      rangeActive = false;
      return;
    }
    if (a) {
      const ar = clamp(a.rowIndex, 0, maxRow);
      const ac = clamp(a.colIndex, 0, maxCol);
      if (ar !== a.rowIndex || ac !== a.colIndex) setRangeAnchor({
        rowIndex: ar,
        colIndex: ac
      });
    }
    if (f) {
      const fr = clamp(f.rowIndex, 0, maxRow);
      const fc = clamp(f.colIndex, 0, maxCol);
      if (fr !== f.rowIndex || fc !== f.colIndex) setRangeFocus({
        rowIndex: fr,
        colIndex: fc
      });
    }
  }

  // ══ Clipboard (TSV copy/paste) + drag-fill (phase 51 plan 04 / req-8 / D-03 / D-04) ══════
  // The async Clipboard API (grantPermissions confirmed in 51-01). Copy = range→TSV; paste =
  // TSV→cells under the D-03 skip rule (editable AND validator-passing cells only) with an
  // N-of-M aria-live announce + one cell-edit-commit per committed cell; drag-fill = value-copy
  // ONLY (D-04, NO series detection). T-51-01 (BLOCKING-high): pasted TSV is UNTRUSTED — every
  // cell is written as plain string DATA through the per-column validator and rendered via the
  // SAME {{ }}/rozieDisplay text path as #cell (never innerHTML / a template / a selector); the
  // cell-resolution query interpolates integer indices only (resolveCellEl, T-49-01).

  // announce(msg): write the polite aria-live PASTE-announce region (D-03 — "N of M cells
  // pasted"). SEPARATE from the validation invalidMsg region (different semantics). '' clears it.
  function announce(msg: any) {
    setPasteAnnounce(msg != null ? msg : '');
  }

  // B11: copy / paste (and the Cut verb plan 63-09 adds) are NO-OPS while a HEADER cell is
  // active. A header has no body value to copy, and a paste anchored at a header would silently
  // write body row 0 at the header's column (a silent body mutation, borderline P0). This is the
  // SINGLE reusable guard every clipboard entry path checks — copyRange/pasteRange self-guard
  // with it AND the onGridKeyDown Ctrl+C/Ctrl+V branches gate on it (so the native shortcut is
  // left untouched on a header). Plan 63-09's Cut reuses this exact predicate.
  function clipboardActiveAllowed() {
    return !activeIsHeader();
  }

  // fieldOfColId: the row-object key (accessorKey) to write for a column id — the same
  // accessorKey-or-id rule the edit funnels use. Used by paste/fill to apply values by field.
  function fieldOfColId(colId: any) {
    const d = defFor(colId);
    return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
  }

  // normalizedRange(): the current rectangle as { r0, r1, c0, c1 } (min/max of anchor+focus),
  // or null when no range. The shared rectangle source for copy/paste/fill. B8: the corners are
  // CLAMPED to the CURRENT grid bounds ON READ (read at call time → React-stale-safe), so a copy
  // after a filter-to-fewer can never serialize phantom rows past the shrunken model even when
  // the stored corners were not eagerly re-clamped (refreshRowModel's clamp is async-defeated on
  // React; this read-time clamp is the cross-target guarantee). Returns null when no body cell
  // remains.
  function normalizedRange() {
    const a = rangeAnchor();
    const f = rangeFocus();
    if (!a || !f) return null;
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return null;
    const ar = clamp(a.rowIndex, 0, maxRow);
    const ac = clamp(a.colIndex, 0, maxCol);
    const fr = clamp(f.rowIndex, 0, maxRow);
    const fc = clamp(f.colIndex, 0, maxCol);
    return {
      r0: ar < fr ? ar : fr,
      r1: ar > fr ? ar : fr,
      c0: ac < fc ? ac : fc,
      c1: ac > fc ? ac : fc
    };
  }

  // B10: escape a TSV field per the spreadsheet convention — a field containing a tab, a CR/LF,
  // or a double-quote is wrapped in double-quotes with internal quotes DOUBLED; an ordinary
  // field is emitted verbatim. parseTsv() unescapes symmetrically, so a cell carrying a tab /
  // newline / quote round-trips without smearing into adjacent cells (T-63-03-02).
  function escapeTsvField(s: any) {
    if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
      return '"' + s.replace(/"/g, '""') + '"';
    }
    return s;
  }

  // rangeToTsv(): serialize the current range to TSV — rows joined by '\n', cells by '\t',
  // reading each cell's value off the visible model by index (cellValueAt). A single active
  // cell (no range) serializes that one cell. Each field is B10-escaped. Pure read — never writes.
  function rangeToTsv() {
    const box = normalizedRange();
    const r0 = box ? box.r0 : activeRow();
    const r1 = box ? box.r1 : activeRow();
    const c0 = box ? box.c0 : activeColIndex();
    const c1 = box ? box.c1 : activeColIndex();
    const lines = [];
    for (let r = r0; r <= r1; r++) {
      const cells = [];
      for (let c = c0; c <= c1; c++) {
        const v = cellValueAt(r, c);
        cells.push(escapeTsvField(v == null ? '' : String(v)));
      }
      lines.push(cells.join('\t'));
    }
    return lines.join('\n');
  }

  // parseTsv(text): a TSV string → string[][] (rows of cells). Tolerates \r\n; a trailing
  // newline does not add a phantom empty row. Pure — produces plain string DATA only (T-51-01:
  // the cells are NEVER eval'd / interpolated into a selector / rendered as markup).
  function parseTsv(text: any) {
    const str = text != null ? String(text) : '';
    // CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
    // large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
    // single-pass scan allocates a cell-per-character grid.
    if (str === '' || str.length > 2000000) return [];
    // B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
    // cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
    // newlines, and doubled quotes ("") inside it are literal content until the closing quote;
    // an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
    const rows = [];
    let row = [];
    let field = '';
    let inQuotes = false;
    let i = 0;
    const n = str.length;
    while (i < n) {
      const ch = str[i];
      if (inQuotes) {
        if (ch === '"') {
          if (i + 1 < n && str[i + 1] === '"') {
            field = field + '"';
            i = i + 2;
            continue;
          }
          inQuotes = false;
          i = i + 1;
          continue;
        }
        field = field + ch;
        i = i + 1;
        continue;
      }
      if (ch === '"' && field === '') {
        inQuotes = true;
        i = i + 1;
        continue;
      }
      if (ch === '\t') {
        row.push(field);
        field = '';
        i = i + 1;
        continue;
      }
      if (ch === '\r') {
        if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
        row.push(field);
        field = '';
        rows.push(row);
        row = [];
        i = i + 1;
        continue;
      }
      if (ch === '\n') {
        row.push(field);
        field = '';
        rows.push(row);
        row = [];
        i = i + 1;
        continue;
      }
      field = field + ch;
      i = i + 1;
    }
    // Flush the trailing field + row.
    row.push(field);
    rows.push(row);
    // Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
    if (rows.length > 1) {
      const last = rows[rows.length - 1];
      if (last.length === 1 && last[0] === '') rows.pop();
    }
    return rows;
  }

  // copyRange(): write the current range as TSV to the clipboard (async). No-op when the
  // async Clipboard API is unavailable (older/insecure contexts) — a copy is best-effort.
  function copyRange() {
    // B11: never copy from a header-active state (the reusable clipboard guard).
    if (!clipboardActiveAllowed()) return;
    if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
    try {
      const p = navigator.clipboard.writeText(rangeToTsv());
      if (p && p.catch) p.catch(() => {});
    } catch (err: any) {/* best-effort copy */}
  }

  // applyGridToRange(grid, originRow, originCol): the SHARED write path for paste + fill. Walks
  // the grid (string[][]) anchored at (originRow, originCol), CLAMPED to the grid bounds (no
  // unbounded loop — T-51-02). For each target cell: count it (total); SKIP if the column is
  // non-editable (D-03) or the per-column validator rejects the value (D-03, T-51-01 — the
  // value passes runValidator as plain string DATA before any write); else stage it into ONE
  // running fresh array (replaceRowValue) and record the committed cell. After the walk: ONE
  // writeData (the single r-model:data write), ONE cell-edit-commit per COMMITTED cell, and the
  // N-of-M aria-live announce. Returns { wrote, total }.
  function applyGridToRange(grid: any, originRow: any, originCol: any) {
    const maxRow = bodyRowCount() - 1;
    const maxCol = visibleColCount() - 1;
    if (maxRow < 0 || maxCol < 0) return {
      wrote: 0,
      total: 0
    };
    let total = 0;
    let wrote = 0;
    const committed = [];
    // Build the fresh data array incrementally so the whole paste is ONE writeData.
    let next = currentData();
    for (let gr = 0; gr < grid.length; gr++) {
      const r = originRow + gr;
      if (r > maxRow) break;
      const cols = grid[gr] || [];
      for (let gc = 0; gc < cols.length; gc++) {
        const c = originCol + gc;
        if (c > maxCol) break;
        total = total + 1;
        const colId = columnIdAt(r, c);
        if (colId == null || !columnEditable(colId)) continue;
        const rowObj = rowOriginalAt(r);
        // B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
        // single-cell commit coercion) — a numeric column commits a real Number, an empty cell
        // commits null; every other editor type passes through verbatim. No mixed/garbage types
        // ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
        const value = coerceCellValue(colId, cols[gc]);
        // T-51-01: validate the pasted value as plain DATA before any write.
        if (runValidator(colId, value, rowObj) !== true) continue;
        const field = fieldOfColId(colId);
        const srcIndex = sourceIndexOfRow(r);
        const oldValue = rowObj ? rowObj[field] : null;
        next = replaceRowValue(next, srcIndex, field, value);
        committed.push({
          rowId: rowIdAt(r),
          columnId: colId,
          oldValue,
          newValue: value
        });
        wrote = wrote + 1;
      }
    }
    if (wrote > 0) {
      editTransition = true;
      writeData(next);
      editTransition = false;
      // One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
      for (let i = 0; i < committed.length; i++) _props.onCellEditCommit?.(committed[i]);
    }
    // WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
    // targeted real cells but every one was skipped (validation-failed / non-editable), announce a
    // distinct validation-failed message instead of a misleading "0 of M cells pasted".
    if (wrote > 0) announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) announce('No cells pasted — ' + total + ' cells were invalid or read-only');
    return {
      wrote,
      total
    };
  }

  // rowOriginalAt / rowIdAt: the underlying row object / id at a visible-model body index.
  function rowOriginalAt(rowIndex: any) {
    const rowList = rows() || [];
    const row = rowList[rowIndex];
    return row ? row.original : null;
  }
  function rowIdAt(rowIndex: any) {
    const rowList = rows() || [];
    const row = rowList[rowIndex];
    return row ? row.id : null;
  }

  // C3: tile a parsed clipboard `grid` (string[][]) to fill a destination `box` — the spreadsheet
  // paste-into-range semantics. The target rectangle is the MAX of the box dims and the source
  // dims per axis, so a SMALLER clipboard TILES across a LARGER selection (a single 1×1 cell fills
  // the whole range; a 2×2 block repeats — tiled[dr][dc] = src[dr % srcRows][dc % srcCols]), while a
  // clipboard LARGER than the selection pastes its full block from the top-left (preserving the
  // no-range "clipboard-sized block at the active cell" behavior — a 1×1 destBox + a 1×N clipboard
  // yields the full 1×N block, byte-for-byte the prior path). Pure — returns a fresh grid; applies
  // nothing. A ragged/short source row defaults the missing cell to '' (coerced per column on write).
  function tileGridToBox(grid: any, box: any) {
    const srcRows = grid.length;
    // srcCols is the MAX row width across ALL rows (not grid[0].length): a RAGGED clipboard
    // (a later row WIDER than the first, e.g. TSV "a\tb\nc\td\te") would otherwise never read
    // the extra column and silently drop those cells. A row SHORTER than srcCols tiles its
    // missing cells as '' (the `v != null ? v : ''` coercion below), never undefined.
    let srcCols = 0;
    for (let i = 0; i < srcRows; i++) {
      const w = grid[i] && grid[i].length ? grid[i].length : 0;
      if (w > srcCols) srcCols = w;
    }
    if (srcRows <= 0 || srcCols <= 0) return grid;
    const boxRows = box.r1 - box.r0 + 1;
    const boxCols = box.c1 - box.c0 + 1;
    const rows = boxRows > srcRows ? boxRows : srcRows;
    const cols = boxCols > srcCols ? boxCols : srcCols;
    const out = [];
    for (let r = 0; r < rows; r++) {
      const srcLine = grid[r % srcRows] || [];
      const line = [];
      for (let c = 0; c < cols; c++) {
        const v = srcLine[c % srcCols];
        line.push(v != null ? v : '');
      }
      out.push(line);
    }
    return out;
  }

  // pasteRange(): read TSV from the clipboard (async), parse it, TILE it over the destination
  // (C3), and apply it anchored at the destination top-left under the D-03 skip rule. The grid is
  // clamped to the grid bounds (T-51-02). A failed/empty read is a silent no-op.
  function pasteRange() {
    // B11: never paste into a header-active state (the reusable clipboard guard) — a header
    // anchor would silently write body row 0 at the header's column.
    if (!clipboardActiveAllowed()) return;
    if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
    // CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
    // C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
    // single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
    // on React; re-reading them inside the async .then() returns the mount-render stale value, so a
    // selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
    // the box + anchor now and pass them into tileGridToBox / applyGridToRange.
    const box = normalizedRange();
    const anchorRow = box ? box.r0 : activeRow();
    const anchorCol = box ? box.c0 : activeColIndex();
    const destBox = box || {
      r0: anchorRow,
      r1: anchorRow,
      c0: anchorCol,
      c1: anchorCol
    };
    let p: any = null;
    try {
      p = navigator.clipboard.readText();
    } catch (err: any) {
      return;
    }
    if (!p || !p.then) return;
    p.then((text: any) => {
      const grid = parseTsv(text);
      if (!grid.length) return;
      // C3: tile the clipboard block to fill the destination range (single→range fill,
      // smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
      const tiled = tileGridToBox(grid, destBox);
      applyGridToRange(tiled, anchorRow, anchorCol);
    }).catch(() => {});
  }

  // cutRange(): C3 Cut — copy the current range to the clipboard (rangeToTsv — the SAME escaped
  // serialization copyRange uses) THEN CLEAR the source cells through the SAME write-funnel as
  // paste/fill: applyGridToRange of an empty-string grid sized to the range → coerceCellValue('')
  // per column (null on a numeric column, '' on text) + the D-03 editable/validator skip rule +
  // ONE writeData + one cell-edit-commit per cleared cell + the N-of-M announce. A read-only /
  // required cell is left intact (the funnel skips it). B11: a no-op while a header cell is active
  // (reuses clipboardActiveAllowed — Cut can never silently clear a body cell from a header anchor).
  // The clear is SYNCHRONOUS and runs AFTER rangeToTsv has already serialized, so the copy reads the
  // pre-clear values; the clipboard write is best-effort/async and never blocks the clear.
  function cutRange() {
    if (!clipboardActiveAllowed()) return;
    // Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
    const box = normalizedRange();
    const r0 = box ? box.r0 : activeRow();
    const r1 = box ? box.r1 : activeRow();
    const c0 = box ? box.c0 : activeColIndex();
    const c1 = box ? box.c1 : activeColIndex();
    // Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
    if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
      try {
        const cp = navigator.clipboard.writeText(rangeToTsv());
        if (cp && cp.catch) cp.catch(() => {});
      } catch (err: any) {/* best-effort copy */}
    }
    // Clear the source: a grid of empty strings sized to the range, applied at the top-left.
    const grid = [];
    for (let r = r0; r <= r1; r++) {
      const cols = [];
      for (let c = c0; c <= c1; c++) cols.push('');
      grid.push(cols);
    }
    applyGridToRange(grid, r0, c0);
  }

  // clearActiveRange(): the §7 (260709-3qt) Delete/Backspace clear — cutRange() MINUS the clipboard
  // copy. Clears the active cell / selected range through the SAME write-funnel as Cut/paste/fill:
  // applyGridToRange of an empty-string grid sized to the range → coerceCellValue('') per column
  // (null on a numeric column, '' on text) + the D-03 editable/validator/read-only skip rule + ONE
  // writeData + one cell-edit-commit per cleared cell + the N-of-M announce. B11: a no-op while a
  // header cell is active (reuses clipboardActiveAllowed — Delete can never silently clear a body
  // cell from a header anchor). NO undo — the grid is controlled (writeData → $model.data; every
  // clear fires cell-edit-commit), so undo is the consumer's responsibility, the SAME contract
  // Cut/Paste/Fill already carry (design §7, approved 2026-07-09).
  function clearActiveRange() {
    if (!clipboardActiveAllowed()) return;
    // Snapshot the source rectangle synchronously (the ROZ138 concern cutRange/pasteRange share).
    const box = normalizedRange();
    const r0 = box ? box.r0 : activeRow();
    const r1 = box ? box.r1 : activeRow();
    const c0 = box ? box.c0 : activeColIndex();
    const c1 = box ? box.c1 : activeColIndex();
    const grid = [];
    for (let r = r0; r <= r1; r++) {
      const cols = [];
      for (let c = c0; c <= c1; c++) cols.push('');
      grid.push(cols);
    }
    applyGridToRange(grid, r0, c0);
  }

  // tileIndex(i, lo, hi): map an index into the inclusive [lo,hi] source span by TILING (repeat
  // the source block), handling indices below lo (negative offset) correctly. A 1-wide source
  // (lo===hi) always returns lo. Used by fillRange to resolve, per target cell, WHICH source
  // cell it copies — so each column copies its OWN source value down its OWN column.
  function tileIndex(i: any, lo: any, hi: any) {
    const span = hi - lo + 1;
    if (span <= 1) return lo;
    let k = (i - lo) % span;
    if (k < 0) k = k + span;
    return lo + k;
  }

  // fillRange(sourceBox): drag-fill (D-04 — VALUE-COPY ONLY, no series detection). B7: the fill
  // SOURCE is the PRE-DRAG rectangle (`sourceBox`, captured at pointerdown before the drag grew
  // the range); each target cell copies the source cell in its OWN column (and row, when the
  // source spans rows), TILED across the source dimensions. This fixes two data-loss bugs: (1) a
  // single-scalar broadcast clobbered the other columns' data, and (2) reading box.r0/box.c0
  // flipped to the WRONG corner on an up/left drag (the box top-left is a TARGET cell there, not
  // the source). `sourceBox` falls back to the box's top-left 1×1 for a no-source fill. Honors the
  // SAME editable + validation + type-coercion skip rule as paste (via applyGridToRange): one
  // writeData + one cell-edit-commit per committed cell + the N-of-M announce. No-op without a range.
  function fillRange(sourceBox: any, endCell: any) {
    // B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
    // the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
    // React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
    // grows), so deriving the box from the threaded endpoints is what makes the fill cover the
    // dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
    let box;
    if (sourceBox && sourceBox.r0 != null && endCell) {
      let r0 = sourceBox.r0;
      let r1 = sourceBox.r1;
      let c0 = sourceBox.c0;
      let c1 = sourceBox.c1;
      if (endCell.r < r0) r0 = endCell.r;
      if (endCell.r > r1) r1 = endCell.r;
      if (endCell.c < c0) c0 = endCell.c;
      if (endCell.c > c1) c1 = endCell.c;
      box = {
        r0,
        r1,
        c0,
        c1
      };
    } else {
      box = normalizedRange();
    }
    if (!box) return;
    const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
      r0: box.r0,
      r1: box.r0,
      c0: box.c0,
      c1: box.c0
    };
    const grid = [];
    for (let r = box.r0; r <= box.r1; r++) {
      const cols = [];
      for (let c = box.c0; c <= box.c1; c++) {
        const sr = tileIndex(r, src.r0, src.r1);
        const sc = tileIndex(c, src.c0, src.c1);
        const v = cellValueAt(sr, sc);
        cols.push(v == null ? '' : String(v));
      }
      grid.push(cols);
    }
    applyGridToRange(grid, box.r0, box.c0);
  }

  // onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
  // range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
  // cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
  // pointermove extends the range to the cell under the pointer; pointerup commits the fill.
  let fillDragging = false;
  // CR-04: track the live fill-drag document listeners in module-lets so $onUnmount can remove
  // them if the component unmounts MID-DRAG (the `up` handler clears them on a normal release,
  // but a mid-drag unmount would otherwise leak a pointermove/pointerup listener on document).
  let fillDragMove: any = null;
  let fillDragUp: any = null;
  function teardownFillDrag() {
    if (typeof document !== 'undefined') {
      if (fillDragMove) document.removeEventListener('pointermove', fillDragMove);
      if (fillDragUp) document.removeEventListener('pointerup', fillDragUp);
    }
    fillDragMove = null;
    fillDragUp = null;
    fillDragging = false;
  }
  function cellIndexFromPoint(clientX: any, clientY: any) {
    if (typeof document === 'undefined' || !document.elementFromPoint) return null;
    let el = document.elementFromPoint(clientX, clientY);
    // Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
    // a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
    // and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
    // deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
    while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
      const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
      if (!inner || inner === el) break;
      el = inner;
    }
    if (!el || !el.closest) return null;
    const cellEl = el.closest('[data-grid-cell]');
    if (!cellEl) return null;
    const rowAttr = cellEl.getAttribute('data-row');
    const colAttr = cellEl.getAttribute('data-col-index');
    if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
    const r = parseInt(rowAttr, 10);
    const c = parseInt(colAttr, 10);
    if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
    return {
      r,
      c
    };
  }
  function onFillHandlePointerDown(e: any) {
    if (!e) return;
    if (e.preventDefault) e.preventDefault();
    if (e.stopPropagation) e.stopPropagation();
    // #leak: tear down any orphaned PRIOR gesture BEFORE reassigning the module-let handlers. If a
    // pointerup was missed (pointer released off-window, context menu, alt-tab), the prior fillDrag's
    // document pointermove/pointerup stay attached; overwriting fillDragMove/fillDragUp below would
    // strand them (removeEventListener could never reach the old refs) → a permanent global
    // pointermove leak. teardownFillDrag is idempotent (no-op when nothing is attached).
    teardownFillDrag();
    fillDragging = true;
    // B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
    // range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
    // up/left drag copies from the real origin (not the post-drag corner that would flip to a
    // target cell). Captured per-gesture in the closure (no module-let needed).
    const sourceBox = normalizedRange();
    // B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
    // the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
    let lastCell = sourceBox ? {
      r: sourceBox.r1,
      c: sourceBox.c1
    } : null;
    const move = (ev: any) => {
      if (!fillDragging) return;
      const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
      // B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
      // pointermove (the pointer fires many per cell) spams the event with identical payloads.
      // Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
      // seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
      // or re-enters the same cell is suppressed (the range is unchanged).
      if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
        lastCell = cell;
        setRangeFocus$local(cell.r, cell.c);
      }
    };
    const up = () => {
      // teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
      teardownFillDrag();
      // A plain click on the fill handle (pointerdown+up with NO intervening drag) leaves lastCell
      // at the source box's own origin corner (r1,c1), so fillRange(sourceBox, corner) would
      // recommit the source range onto ITSELF — a no-op write that pollutes undo history and fires
      // spurious per-cell cell-edit-commit events (oldValue === newValue). Only fill when the drag
      // actually reached a cell past the source origin.
      if (lastCell && sourceBox && (lastCell.r !== sourceBox.r1 || lastCell.c !== sourceBox.c1)) {
        fillRange(sourceBox, lastCell);
      }
    };
    // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
    fillDragMove = move;
    fillDragUp = up;
    if (typeof document !== 'undefined') {
      document.addEventListener('pointermove', move);
      document.addEventListener('pointerup', up);
    }
  }

  // §6 (260709-3qt) drag-to-select — mirror the fill-drag listener discipline. rangeDragging gates
  // the live gesture; rangeDragMove/rangeDragUp track the document pointermove/pointerup handlers so
  // a mid-drag unmount ($onUnmount → teardownRangeDrag) can remove them (CR-04). rangeDragMoved flips
  // true once the drag enters a DIFFERENT cell than its mousedown anchor; onGridClick reads it to
  // suppress a singleClickEdit editor-open after a drag (reset per-gesture in beginRangeDrag). Each
  // top-level let → React hoists to useRef.
  let rangeDragging = false;
  let rangeDragMove: any = null;
  let rangeDragUp: any = null;
  let rangeDragMoved = false;
  // ══ Mouse drag-to-select (grid cell-interaction §6, 260709-3qt) ═════════════════════════
  // A plain (non-shift) mousedown on a body cell begins a document-level drag: the FIRST
  // pointermove that reaches a DIFFERENT body cell paints the range moving corner via the
  // SHARED-scope setRangeFocus (the SAME range model shift+click / shift+arrow drive), pointerup
  // ends it. Mirrors fillDrag.rzts's listener discipline VERBATIM (document pointermove/pointerup
  // tracked in module-lets so a mid-drag unmount can remove them — CR-04), and REUSES fillDrag's
  // shadow-piercing cellIndexFromPoint (shared scope) so the Lit shadow target is covered uniformly.

  // teardownRangeDrag(): remove the live drag listeners, null them, clear the dragging flag. The
  // `up` handler calls it on a normal release; $onUnmount calls it if we unmount MID-DRAG (mirrors
  // teardownFillDrag). rangeDragMoved is NOT reset here — it is read by onGridClick AFTER pointerup
  // (to suppress a singleClickEdit editor-open) and reset per-gesture in beginRangeDrag.
  function teardownRangeDrag() {
    if (typeof document !== 'undefined') {
      if (rangeDragMove) document.removeEventListener('pointermove', rangeDragMove);
      if (rangeDragUp) document.removeEventListener('pointerup', rangeDragUp);
    }
    rangeDragMove = null;
    rangeDragUp = null;
    rangeDragging = false;
  }

  // beginRangeDrag(anchorR, anchorC): start a drag-select anchored at the mousedown cell. The
  // mousedown's native focus/focusin already committed the ACTIVE cell to (anchorR, anchorC), so
  // setRangeFocus (which seeds the anchor from the ACTIVE cell) spans mousedown-cell→pointer-cell —
  // we NEVER write $data.rangeAnchor directly (it is React-stale, ROZ138). rangeDragMoved starts
  // false and flips true only once the pointer reaches a DIFFERENT cell, so a mousedown-with-no-move
  // leaves a single active cell + no range (a normal click). lastCell dedups the many pointermove
  // events per cell (setRangeFocus emits range-change — only extend on a NEW cell, mirroring fillDrag's
  // B20 dedup). Captured per-gesture in the closure (no module-let needed for lastCell).
  function beginRangeDrag(anchorR: any, anchorC: any) {
    // #leak: tear down any orphaned PRIOR range gesture BEFORE reassigning the module-let handlers.
    // A missed pointerup (off-window release, context menu, alt-tab) leaves the prior drag's document
    // pointermove/pointerup attached; overwriting rangeDragMove/rangeDragUp below would strand them
    // (removeEventListener could never reach the old refs) → a permanent global pointermove leak.
    // teardownRangeDrag is idempotent (no-op when nothing is attached) and does NOT touch
    // rangeDragMoved, which is reset per-gesture immediately below.
    teardownRangeDrag();
    rangeDragging = true;
    rangeDragMoved = false;
    let lastCell = {
      r: anchorR,
      c: anchorC
    };
    const move = (ev: any) => {
      if (!rangeDragging) return;
      const cell = cellIndexFromPoint(ev.clientX, ev.clientY);
      if (cell && (cell.r !== lastCell.r || cell.c !== lastCell.c)) {
        lastCell = cell;
        rangeDragMoved = true;
        setRangeFocus$local(cell.r, cell.c);
      }
    };
    const up = () => {
      // teardownRangeDrag clears rangeDragging + removes both listeners (the fill-drag CR-04 path).
      teardownRangeDrag();
    };
    // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
    rangeDragMove = move;
    rangeDragUp = up;
    if (typeof document !== 'undefined') {
      document.addEventListener('pointermove', move);
      document.addEventListener('pointerup', up);
    }
  }

  // ══ Editable-cell lifecycle (phase 51 plan 02 — RESEARCH Pattern 1/3/4/5) ════════════════
  // Single-cell, non-virtual. Index-based state (editingRow/editingCol over the visible model),
  // the display↔editor branch in the keyed <td>, F2/Enter/printable entry off the reserved
  // onGridKeyDown seam, commit on Enter/Tab/blur, cancel+revert on Escape, sync validation with
  // D-01 keep-open. All gated by columnEditable() / the editing index pair so a table with no
  // editable columns lowers byte-identical (the editor branch r-if is always false).

  // The column id at the active cell (the active row's visible cell list @ activeColIndex).
  // Null when out of range (no body rows, or active cell is a header / select column).
  function activeCellColumnId() {
    if (activeIsHeader()) return null;
    const rowList = rows() || [];
    const row = rowList[activeRow()];
    if (!row) return null;
    const cells = visibleCellsFor(row);
    const cell = cells[activeColIndex()];
    return cell && cell.column ? cell.column.id : null;
  }

  // isActiveCellEditable: the active cell sits in an editable column AND is a body cell
  // (req-1). Gates the F2/Enter/printable edit-entry branches in onGridKeyDown; a
  // non-editable active cell falls through to the reserved enterControl path.
  function isActiveCellEditable() {
    const colId = activeCellColumnId();
    return colId != null && columnEditable(colId);
  }

  // isEditing: is the cell at (rowIndex, colIndex) over the visible model in edit? ONE
  // predicate covers BOTH modes (RESEARCH Pattern 6):
  //  - row mode (req-6): editingRowIndex === rowIndex AND the column at colIndex is editable —
  //    so EVERY editable cell in the row enters edit simultaneously (the editor template branch
  //    re-uses this gate verbatim, no template fork);
  //  - single-cell mode (req-1/3): the editingRow/editingCol pair matches exactly.
  // Pure index compare (editingRowIndex null + editingRow -1 = none) → the byte-identical-off
  // guard for the editor template branch. $data.editVer is read first so the per-cell branch
  // re-derives on Svelte/Solid when editing state mutates from a foreign slot-callback scope.
  // Called per-cell in both <td> bodies with the body-specific row index (rowIndexOf(row)
  // non-virtual, wr.vi.index virtual).
  function isEditing(rowIndex: any, colIndex: any) {
    if (editVer() < 0) return false;
    if (editingRowIndex() != null && editingRowIndex() === rowIndex) {
      const colId = columnIdAt(rowIndex, colIndex);
      return colId != null && columnEditable(colId);
    }
    return editingRow() === rowIndex && editingCol() === colIndex;
  }

  // cellAriaInvalid (req-5/D-01): the STRING 'true' ONLY for the editing cell while it holds
  // an invalid value — drives :aria-invalid on the <td>. Returns null otherwise so the bound
  // attribute DROPS (the rozieAttr nullish-attr path), keeping non-editing cells byte-clean.
  // Returns the literal 'true' (NOT boolean true) so rozieAttr's string-literal-union preserve
  // keeps React's aria-invalid (Booleanish incl. 'true') happy instead of widening to string.
  function cellAriaInvalid(rowIndex: any, colIndex: any): 'true' | null {
    return isEditing(rowIndex, colIndex) && !!invalidMsg() ? 'true' : null;
  }

  // runValidator: the sync per-column validator (req-5). Reads col.meta.validate; not a
  // function → valid (true). Calls it (defensively wrapped — a thrown/non-true/non-string
  // return coerces to a generic message so a misbehaving validator can never wedge the
  // keymap, Security V5 DoS). A string return is the error message (commit rejected, D-01).
  function runValidator(colId: any, value: any, row: any) {
    const m = editMetaOf(colId);
    const v = m ? m.validate : null;
    if (typeof v !== 'function') return true;
    let r: any = null;
    try {
      r = v(value, row);
    } catch (err: any) {
      return 'Invalid value';
    }
    if (r === true) return true;
    if (typeof r === 'string') return r;
    return 'Invalid value';
  }

  // setInvalid: record the current validation error (drives the aria-live region +
  // :aria-invalid wired in Task 3). Empty string clears it.
  function setInvalid(msg: any) {
    setInvalidMsg(msg != null ? msg : '');
  }

  // replaceRowValue: build a FRESH array with ONE row object replaced (the column's field
  // set to the new value); the rest share by reference (the family immutable whole-array
  // replace — in-place mutation is silently dropped on React/Solid/Angular/Lit). rowIndex
  // is over currentData() (== the visible model order for the non-virtual, unsorted/
  // unfiltered single-cell case; the row id is carried for the commit payload).
  function replaceRowValue(rows: any, rowIndex: any, field: any, value: any) {
    const src = rows || [];
    const out = [];
    for (let i = 0; i < src.length; i++) {
      if (i === rowIndex) {
        // WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
        // and would copy inherited enumerable props of typed/class-instance row objects.
        out.push({
          ...(src[i] || {}),
          [field]: value
        });
      } else {
        out.push(src[i]);
      }
    }
    return out;
  }

  // Map a visible-model body-row index ($data.rows index) to its underlying currentData()
  // index via the row's original object identity (sorting/filtering/pagination may reorder
  // the visible model away from the source array order). Falls back to the same index.
  function sourceIndexOfRow(visibleRowIndex: any) {
    const rowList = rows() || [];
    const row = rowList[visibleRowIndex];
    if (!row) return visibleRowIndex;
    const orig = row.original;
    const data = currentData() || [];
    const idx = data.indexOf(orig);
    return idx >= 0 ? idx : visibleRowIndex;
  }

  // The column id / field (accessorKey) / current value / row object / row id for the cell
  // in EDIT — keyed off the authoritative editing pair ($data.editingRow/editingCol), NOT
  // the active-cell indices (which can drift from the editing cell on a Tab-advance, and are
  // async-stale right after a setState on React — ROZ138). Called only from commitEdit.
  function editingColumnId() {
    const rowList = rows() || [];
    const row = rowList[editingRow()];
    if (!row) return null;
    const cells = visibleCellsFor(row);
    const cell = cells[editingCol()];
    return cell && cell.column ? cell.column.id : null;
  }
  function editingColumnField() {
    const colId = editingColumnId();
    if (colId == null) return null;
    const d = defFor(colId);
    return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
  }
  function editingCellValue() {
    const rowList = rows() || [];
    const row = rowList[editingRow()];
    if (!row) return null;
    const cells = visibleCellsFor(row);
    const cell = cells[editingCol()];
    return cell ? cell.getValue() : null;
  }
  function editingRowOriginal() {
    const rowList = rows() || [];
    const row = rowList[editingRow()];
    return row ? row.original : null;
  }
  function editingRowId() {
    const rowList = rows() || [];
    const row = rowList[editingRow()];
    return row ? row.id : null;
  }

  // Focus the freshly-mounted editor (Pitfall 1, ROZ123): after beginEdit flips the editing
  // state, the editor <input> does not exist until the framework commits the r-if branch
  // (React setState async; Solid/Lit/Svelte next reactive tick). Poll for the
  // [data-editing-cell] element off gridRoot for ~30 frames — the five fast targets resolve
  // on attempt 1, React retries across its async commit. NEVER read $refs eagerly.
  // B2: selectAll gates the post-focus el.select(). Select-all is right when entering
  // edit IN PLACE (F2/Enter/click/row-edit/validation-reject — no seeded char, the user
  // retypes), but WRONG on a type-to-edit entry where a printable key already seeded the
  // draft (selecting the seeded char makes the next keystroke replace it: Zeta → eta).
  // beginEdit threads `seed == null` so a seeded entry skips the select and the caret sits
  // AFTER the seeded char; every other caller keeps the default select-all.
  // Editor-owns-focus contract (quick 260711-i5m): REVERTS the g52 shadow-piercing helper
  // (commit 5fa30045) that recursed into descendant shadow roots. Built-in editors are
  // host-DOM — the plain direct query resolves them on all 6 targets (no shadow to cross). A
  // #editor DROP-IN now owns its OWN focus via the reactive `autofocus` prop (EditorText's
  // $onMount + lazy $watch), so the host never needs to reach across a Lit drop-in's nested
  // shadow root at all — see the !hasEditorSlot gate below, which skips the host focus call
  // entirely for a drop-in target.
  function focusEditorWhenReady(selectAll = true) {
    if (!gridRoot) return;
    // Editor-owns-focus contract: when the CURRENT focus target is a #editor drop-in, the host
    // does NOT reach into its DOM — the drop-in self-focuses via its own autofocus prop.
    if (editFocusColId() != null && hasEditorSlot(editFocusColId())) return;
    let attempts = 0;
    const tryFocus = () => {
      const el = gridRoot ? gridRoot.querySelector('[data-editing-cell]') : null;
      // Do NOT stomp focus a later interaction already placed in a DIFFERENT column's editor of
      // this row: focusEditorWhenReady only needs to get focus INTO the (first) freshly-mounted
      // editor; if focus already sits in another editable cell, a late rAF re-focus would steal it
      // back to the first editor and break row-mode Tab containment (the non-deterministic B21
      // focus-theft). Compare the OWNING cell's data-col-index (NOT node identity) so a stale
      // SAME-column editor node on Solid's node-replacing re-render still resolves as the target —
      // a genuinely dropped focus is still recovered.
      const ae = gridRoot && gridRoot.getRootNode ? gridRoot.getRootNode().activeElement : null;
      if (ae && el && ae !== el && ae.closest && gridRoot.contains(ae) && ae.hasAttribute && ae.hasAttribute('data-editing-cell')) {
        const aeCell = ae.closest('[data-grid-cell]');
        const elCell = el.closest ? el.closest('[data-grid-cell]') : null;
        const aeCol = aeCell ? aeCell.getAttribute('data-col-index') : null;
        const elCol = elCell ? elCell.getAttribute('data-col-index') : null;
        if (aeCol != null && aeCol !== elCol) return;
      }
      if (el) {
        el.focus();
        if (selectAll && el.select) {
          try {
            el.select();
          } catch (e: any) {}
        }
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  }

  // Column id + current value at an EXPLICIT (rowIndex, colIndex) over the visible model —
  // used by beginEdit so it never re-reads $data.activeRow/activeColIndex (which are async-
  // stale right after a Tab-advance sets them on React — ROZ138).
  function columnIdAt(rowIndex: any, colIndex: any) {
    const rowList = rows() || [];
    const row = rowList[rowIndex];
    if (!row) return null;
    const cells = visibleCellsFor(row);
    const cell = cells[colIndex];
    return cell && cell.column ? cell.column.id : null;
  }
  function cellValueAt(rowIndex: any, colIndex: any) {
    const rowList = rows() || [];
    const row = rowList[rowIndex];
    if (!row) return null;
    const cells = visibleCellsFor(row);
    const cell = cells[colIndex];
    return cell ? cell.getValue() : null;
  }

  // beginEdit: open the editor on the (rowIndex, colIndex) cell (req-1/3, D-05). seed===null
  // → seed the EXISTING value (F2/Enter in-place edit); a printable char → REPLACE (the
  // editor opens holding just that char). Resolves the column from the PASSED indices (not
  // $data) so a Tab-advance that just setState'd activeRow/Col works on React. Clears any
  // prior invalid state. Focus moves into the editor.
  function beginEdit(rowIndex: any, colIndex: any, seed: any) {
    const colId = columnIdAt(rowIndex, colIndex);
    if (colId == null || !columnEditable(colId)) return;
    // A new edit session starts — reset the sync idempotency latch so THIS session's eventual
    // commit is not silently no-op'd by a PRIOR session's already-set latch.
    committedThisSession = false;
    setInvalid('');
    // Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
    // editor clears any row-edit state so isEditing never resolves both modes for one cell.
    setEditingRowIndex(null);
    setRowDraft({});
    setEditingRow(rowIndex);
    setEditingCol(colIndex);
    setDraftValue(seed != null ? seed : cellValueAt(rowIndex, colIndex));
    setActiveInControl(true);
    setEditVer(editVer() + 1);
    // Editor-owns-focus contract (quick 260711-i5m): THIS cell's column is the current
    // focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope prop
    // from it. Cleared on endEdit.
    setEditFocusColId(colId);
    // B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
    // seeded char so subsequent typing appends instead of replacing it.
    focusEditorWhenReady(seed == null);
  }

  // Return focus to a body cell AFTER the editor unmounts (commit/cancel). The display↔
  // editor re-render must commit before the <td> is focusable with its roving tabindex —
  // on React/Solid/Lit that commit is async, so a synchronous focusActiveCell can run while
  // the cell is still the editor (or mid-swap) and focus is lost. Bounded rAF-poll resolves
  // the [data-row][data-col-index] cell off gridRoot for ~30 frames (the fast targets land
  // on attempt 1; React/Solid retry across the async commit). Mirrors focusEditorWhenReady.
  function focusCellWhenReady(row: any, col: any) {
    if (!gridRoot) return;
    let attempts = 0;
    const tryFocus = () => {
      const el = resolveCellEl(String(row), col);
      if (el) {
        el.focus();
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  }

  // B23: the index of a committed row WITHIN a given (fresh) visible-model array, resolved by
  // row IDENTITY. table-core's default getRowId is source-index-based, so a row's id is stable
  // across a re-sort (only its VISIBLE position moves); a committed edit replaces the row object
  // via a fresh spread (the `original` reference changes), so match by `id` FIRST, `original`
  // only as a fallback. Returns -1 when the row filtered out of the view. PURE (the caller passes
  // the FRESH row list — refreshRowModel's just-pulled `nextRows`, never the React-stale state).
  function indexOfRowIn(rows: any, rowOriginal: any, rowId: any) {
    const list = rows || [];
    for (let i = 0; i < list.length; i++) {
      const r = list[i];
      if (!r) continue;
      if (rowId != null && r.id === rowId) return i;
      if (rowOriginal != null && r.original === rowOriginal) return i;
    }
    return -1;
  }

  // endEdit: tear down the editor (shared by commit/cancel). Clears the editing pair +
  // draft + invalid state and returns to navigation mode. Does NOT move focus (callers
  // decide where focus lands — commit/cancel return it to the owning cell).
  function endEdit() {
    setEditingRow(-1);
    setEditingCol(-1);
    setDraftValue(null);
    setInvalidMsg('');
    setActiveInControl(false);
    setEditVer(editVer() + 1);
    setEditFocusColId(null);
  }

  // endRowEdit: tear down full-row edit (shared by commitRow/cancelRow). Clears the row
  // index + the per-cell drafts + invalid state and returns to navigation mode. Does NOT
  // move focus (callers return it to the active cell). Mirrors endEdit for the row mode.
  function endRowEdit() {
    setEditingRowIndex(null);
    setRowDraft({});
    setInvalidMsg('');
    setActiveInControl(false);
    setEditVer(editVer() + 1);
    setEditFocusColId(null);
  }

  // editorAutofocusFor (quick 260711-i5m, editor-owns-focus contract): the reactive `autofocus`
  // #editor scope prop for a given (colId, rowIndex) — true for EXACTLY the current focus-
  // target cell, re-deriving on every editVer bump (mirrors isEditing's reactive gate so
  // Svelte/Solid re-run this per-cell on a foreign-slot-callback state mutation). Works for
  // BOTH single-cell ($data.editingRow) and row mode ($data.editingRowIndex) since
  // $data.editFocusColId is set by both beginEdit and beginRowEdit/commitRow/rowEditTab.
  function editorAutofocusFor(colId: any, rowIndex: any) {
    if (editVer() < 0) return false;
    if (editingRowIndex() != null) {
      if (editingRowIndex() !== rowIndex) return false;
    } else {
      if (editingRow() !== rowIndex) return false;
    }
    return editFocusColId() != null && editFocusColId() === colId;
  }

  // B3: coerce the committed value by the column's built-in editor type at the single
  // commit funnel. A 'number' editor commits a real Number; an empty/whitespace/non-numeric
  // draft commits null (never '' / never NaN — Number('') === 0 is a silent footgun). Every
  // other editor type commits the value verbatim. Idempotent for the #editor drop-in path
  // (an already-numeric override passes through; an explicit null stays null).
  function coerceCellValue(colId: any, raw: any) {
    if (editorTypeOf(colId) !== 'number') return raw;
    if (raw == null) return null;
    if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
    const s = String(raw).trim();
    if (s === '') return null;
    const n = Number(s);
    return Number.isNaN(n) ? null : n;
  }

  // commitEdit: validate the draft (req-5); on success replace one row in a fresh array,
  // funnel it through writeData (the controlled r-model:data write, req-4), emit EXACTLY
  // ONE cell-edit-commit from THIS single call site (React multi-emit dedup, D-07), then
  // return focus to the cell. On a validation FAILURE keep the editor OPEN (D-01) — set
  // invalid, re-trap focus, never write the model. Captures the optional override value
  // (the #editor slot's commit(v) call) else the live draft.
  // Returns true when the commit succeeded (model written, editor closed); false when a
  // validation failure kept the editor OPEN (D-01). Callers MUST use this return value, not
  // a synchronous re-read of $data.editingRow — React's endEdit setState is async, so an
  // immediate re-read of editingRow still shows the OLD value (the ROZ138 stale-read class).
  function commitEdit(overrideValue = undefined, skipFocusReturn = false) {
    if (editingRow() < 0) return false;
    // Sync idempotency latch (drop-in double cell-edit-commit fix): a second commitEdit call
    // within the SAME edit session — the deferred drop-in's unmount-blur re-entry, which on
    // React fires while $data.editingRow is still async-stale ≥ 0 — no-ops here instead of
    // re-validating/re-writing/re-emitting. Reset by beginEdit/beginRowEdit/editCell.
    if (committedThisSession) return false;
    const colId = editingColumnId();
    if (colId == null) {
      endEdit();
      return false;
    }
    const field = editingColumnField();
    const oldValue = editingCellValue();
    const rowOriginal = editingRowOriginal();
    const rowId = editingRowId();
    // B3: coerce by the column's editor type BEFORE validation + write so the validator
    // and the model both see the typed value (number/null), not the raw draft string.
    const rawValue = overrideValue !== undefined ? overrideValue : draftValue();
    const newValue = coerceCellValue(colId, rawValue);
    const err = runValidator(colId, newValue, rowOriginal);
    if (err !== true) {
      // D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
      setInvalid(err);
      focusEditorWhenReady();
      return false;
    }
    setInvalid('');
    // #5: a no-op commit (the coerced value is UNCHANGED — a bare Enter/Tab/blur that edited
    // nothing) must do NO model write, NO history record, and NO commit event: writeData →
    // recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row identity, so an
    // unconditional write on a no-op would destroy redo + spuriously re-render + emit a no-op
    // cell-edit-commit. Compute `changed` and gate the write/emit on it; ALWAYS close the editor.
    const changed = !Object.is(newValue, oldValue);
    // Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
    const focusRow = editingRow();
    const focusCol = editingCol();
    // Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
    // must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
    editTransition = true;
    // Sync idempotency latch: flip BEFORE writeData/endEdit so the async unmount-blur re-entry
    // (which fires AFTER this call returns, once editTransition is already back to false) finds
    // it set at the top-of-function guard above and no-ops. Set on BOTH paths so a no-op commit
    // is just as re-entry-safe as a real one.
    committedThisSession = true;
    if (changed) {
      const srcIndex = sourceIndexOfRow(editingRow());
      const next = replaceRowValue(currentData(), srcIndex, field, newValue);
      writeData(next);
      // Exactly one emit per commit, from this single call site (writeData does NOT emit).
      _props.onCellEditCommit?.({
        rowId,
        columnId: colId,
        oldValue,
        newValue
      });
    }
    endEdit();
    editTransition = false;
    if (changed) {
      // Defer the focus return so the display↔editor re-render commits first (async on
      // React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
      // editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
      // Tab-advance (the caller immediately opens the next editor and focuses THAT).
      // B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
      // RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
      // index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
      // consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
      // fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
      // active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
      // index → byte-behaviorally identical to before.
      if (skipFocusReturn !== true) pendingEditFollow = {
        rowOriginal,
        rowId,
        col: focusCol
      };
    } else if (skipFocusReturn !== true) {
      // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume
      // a pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
      // relocate (no write), so the B23 relocation hazard that forces the pendingEditFollow path on
      // a real commit does not apply here: the fixed (focusRow, focusCol) is correct and safe.
      focusCellWhenReady(focusRow, focusCol);
    }
    return true;
  }

  // toggleActiveBooleanCell (design doc 2026-07-05, Change 1): the spreadsheet-standard
  // single-keystroke boolean toggle. Flips the ACTIVE cell's value and commits it through the
  // EXACT SAME write funnel commitEdit uses (replaceRowValue → writeData → single $emit) but
  // WITHOUT opening an editor — there is no editingRow/editingCol involvement at all, so this
  // operates entirely off $data.activeRow/activeColIndex. Gated in onGridKeyDown to
  // editor:'checkbox' columns only (Space/Enter/F2), full-row edit mode is unaffected (the
  // editingRowIndex early return in onGridKeyDown already excludes it).
  function toggleActiveBooleanCell() {
    const colId = columnIdAt(activeRow(), activeColIndex());
    if (colId == null || !columnEditable(colId)) return;
    const rowList = rows() || [];
    const row = rowList[activeRow()];
    if (!row) return;
    const rowOriginal = row.original;
    const rowId = row.id;
    const oldValue = cellValueAt(activeRow(), activeColIndex());
    const newValue = !oldValue;
    // D-01: same discipline as commitEdit — a rejecting validator blocks the toggle. There is
    // no editor to keep open here, so the toggle simply does not apply (no model write).
    const err = runValidator(colId, newValue, rowOriginal);
    if (err !== true) {
      setInvalid(err);
      return;
    }
    setInvalid('');
    const def = defFor(colId);
    const field = def && def.accessorKey != null ? def.accessorKey : colId;
    const srcIndex = sourceIndexOfRow(activeRow());
    // Sync idempotency latch: this toggle is a commit-equivalent (mirrors commitEdit's D-07
    // single-emit discipline) — flip it too so a stray re-entry after this toggle no-ops.
    committedThisSession = true;
    writeData(replaceRowValue(currentData(), srcIndex, field, newValue));
    // Exactly one emit per toggle, from this single call site (writeData does NOT emit) —
    // mirrors commitEdit's D-07 single-emit discipline.
    _props.onCellEditCommit?.({
      rowId,
      columnId: colId,
      oldValue,
      newValue
    });
    // Follow the toggled row's focus through a boolean sort/filter relocation AND a
    // fine-grained keyed-row replace (Solid) — the SAME recovery commitEdit relies on; even
    // with no editor to unmount, writeData's re-render can still drop focus.
    pendingEditFollow = {
      rowOriginal,
      rowId,
      col: activeColIndex()
    };
  }

  // cancelEdit: discard the draft (D-05 — revert to the pre-edit value, no model write) and
  // return focus to the owning cell.
  function cancelEdit() {
    if (editingRow() < 0) return;
    // CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
    // Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
    // an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
    // instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
    const focusRow = editingRow();
    const focusCol = editingCol();
    editTransition = true;
    endEdit();
    editTransition = false;
    focusCellWhenReady(focusRow, focusCol);
  }

  // ══ Full-row edit lifecycle (phase 51 plan 03 / req-6 / D-06, RESEARCH Pattern 6) ════════
  // Shift+F2 (and the editRow $expose verb) put EVERY editable cell in the active row into
  // edit at once; one save commits the whole row in ONE writeData (a single fresh-array row
  // replace) + ONE row-edit-commit event; Escape reverts the whole row as a unit. Per-column
  // validation still runs on each edited cell at commit (D-01 keep-open if ANY fails). The
  // editor template branch (isEditing's row arm) is re-used verbatim — no per-mode fork.

  // The editable [columnId, field] pairs for a body row at the given visible-model index,
  // in visible-cell order. field is the column's accessorKey (the row-object key to write).
  function editableColumnsForRow(rowIndex: any) {
    const rowList = rows() || [];
    const row = rowList[rowIndex];
    if (!row) return [];
    const cells = visibleCellsFor(row);
    const out = [];
    for (let c = 0; c < cells.length; c++) {
      const cell = cells[c];
      const colId = cell && cell.column ? cell.column.id : null;
      if (colId == null || !columnEditable(colId)) continue;
      const d = defFor(colId);
      const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
      // colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
      // Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
      // can address a SPECIFIC editor by column, not just the first [data-editing-cell].
      out.push({
        colId,
        field,
        colIndex: c
      });
    }
    return out;
  }

  // B21/B22: focus the row-mode editor at a given VISIBLE col index. In full-row edit every
  // editable cell is already mounted as an editor, so this resolves the cell off gridRoot and
  // focuses its [data-editing-cell] control. Bounded rAF-poll (mirrors focusEditorWhenReady)
  // so a React re-render that recreates the input across the focus call still lands it. select-
  // all on text/number editors (a no-op try/catch on select/checkbox).
  // Editor-owns-focus contract (quick 260711-i5m): when the TARGET column is a #editor
  // drop-in, the host does NOT reach into its DOM (early return, before starting the rAF poll
  // at all) — the drop-in self-focuses via its own reactive `autofocus` prop, which the caller
  // (commitRow's B22 reject path / rowEditTab) already flips via $data.editFocusColId. Built-in
  // columns are unaffected (hasEditorSlot is false for them) — unchanged host direct-focus.
  function focusRowEditorAt(rowIndex: any, colIndex: any) {
    if (!gridRoot) return;
    const colId = columnIdAt(rowIndex, colIndex);
    if (colId != null && hasEditorSlot(colId)) return;
    let attempts = 0;
    const tryFocus = () => {
      const cellEl = resolveCellEl(String(rowIndex), colIndex);
      const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
      if (ed) {
        ed.focus();
        if (ed.select) {
          try {
            ed.select();
          } catch (e: any) {}
        }
        return;
      }
      attempts = attempts + 1;
      if (attempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  }

  // beginRowEdit(row): enter full-row edit on a body row (req-6). Seeds rowDraft from each
  // editable column's CURRENT value (so an immediate save is a no-op), clears any single-cell
  // edit (mutual exclusivity), and focuses the first editable cell's editor (the bounded
  // rAF-poll resolves the first [data-editing-cell] off gridRoot — same mechanism as
  // focusEditorWhenReady). Accepts the row OBJECT (the template/Shift+F2 path) — index-resolved
  // internally via rowIndexOf so it stays in the editingRow/activeRow index space.
  function beginRowEdit(row: any) {
    const rowIndex = rowIndexOf(row);
    if (rowIndex < 0) return;
    const editable = editableColumnsForRow(rowIndex);
    if (editable.length === 0) return;
    // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
    committedThisSession = false;
    // Clear any single-cell editor first (mutual exclusivity).
    setEditingRow(-1);
    setEditingCol(-1);
    setDraftValue(null);
    setInvalid('');
    // Seed each editable cell's draft from its current value.
    const draft = {};
    const rowList = rows() || [];
    const r = rowList[rowIndex];
    const orig = r ? r.original : null;
    for (let i = 0; i < editable.length; i++) {
      const ec = editable[i];
      draft[ec.colId] = orig ? orig[ec.field] : null;
    }
    setRowDraft(draft);
    setEditingRowIndex(rowIndex);
    setActiveInControl(true);
    setEditVer(editVer() + 1);
    // Editor-owns-focus contract (quick 260711-i5m): the row's FIRST editable column is the
    // initial focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope
    // prop from it (a built-in column is also host-focused below via focusEditorWhenReady; a
    // drop-in column self-focuses via its own $onMount, gated off the host reach-in in Task 3).
    setEditFocusColId(editable[0].colId);
    focusEditorWhenReady();
  }

  // commitRow(): validate EVERY edited column (D-01 — keep the row open if ANY fails: set
  // invalid + announce, NEVER write the model); on all-valid build ONE fresh array replacing
  // the single row object with all rowDraft values applied at once, call writeData ONCE, then
  // emit ONE row-edit-commit from THIS single call site, clear the row state, return focus.
  // Returns true on a written commit, false when a validation failure kept the row open.
  function commitRow() {
    if (editingRowIndex() == null) return false;
    const rowIndex = editingRowIndex();
    const editable = editableColumnsForRow(rowIndex);
    if (editable.length === 0) {
      endRowEdit();
      return false;
    }
    const rowList = rows() || [];
    const r = rowList[rowIndex];
    const rowOriginal = r ? r.original : null;
    const rowId = r ? r.id : null;
    const draft = rowDraft() || {};
    // Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
    // B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
    // 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
    // commitEdit already coerces via coerceCellValue; the row path silently committed strings →
    // a number column ended up holding '99'). Coerce once here so the validator and the model both
    // see the typed value, identical to the single-cell funnel.
    for (let i = 0; i < editable.length; i++) {
      const ec = editable[i];
      const err = runValidator(ec.colId, coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
      if (err !== true) {
        setInvalid(err);
        // Editor-owns-focus contract (quick 260711-i5m): the OFFENDING column becomes the new
        // reactive focus target BEFORE the host-focus call below — a #editor drop-in already
        // mounted (full-row edit opens every editable cell at once) picks this up via its own
        // lazy $watch on the `autofocus` scope prop flipping false→true. Bump editVer so the
        // coarse-render targets (React/Vue/Angular/Svelte) re-derive the slot binding (Solid's
        // fine-grained accessor re-runs without the bump, but the bump keeps all 6 in lockstep).
        setEditFocusColId(ec.colId);
        setEditVer(editVer() + 1);
        // B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
        // unconditionally the first editor (focusEditorWhenReady resolves the first
        // [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
        // Gated (Task 3) so a #editor drop-in self-focuses instead of a host DOM reach-in.
        focusRowEditorAt(rowIndex, ec.colIndex);
        return false;
      }
    }
    setInvalid('');
    // Build the changes payload (only the columns whose value actually changed) + the field→
    // value map for the single row-object replace.
    const changes = [];
    const fieldValues = {};
    for (let i = 0; i < editable.length; i++) {
      const ec = editable[i];
      // B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
      // string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
      const newValue = coerceCellValue(ec.colId, draft[ec.colId]);
      const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
      fieldValues[ec.field] = newValue;
      if (oldValue !== newValue) changes.push({
        columnId: ec.colId,
        oldValue,
        newValue
      });
    }
    // Snapshot the active cell to return focus to (the whole row is in edit, so the active-cell
    // row/column is the roving focus target), BEFORE endRowEdit clears editing state.
    const focusRow = activeRow();
    const focusCol = activeColIndex();
    // #5: a no-op row commit (NO column's value actually changed — a bare Enter/save/outside-click
    // that edited nothing) must do NO model write, NO history record, NO row-edit-commit event:
    // writeData → recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row
    // identity, so an unconditional write on a no-op destroys redo + spuriously re-renders + emits
    // a no-op row-edit-commit. Gate the write/emit on `changes.length`; ALWAYS close the editor.
    const changed = changes.length > 0;
    editTransition = true;
    if (changed) {
      // ONE fresh-array replace of the SINGLE row object with all field values applied at once.
      const srcIndex = sourceIndexOfRow(rowIndex);
      const next = replaceRowValues(currentData(), srcIndex, fieldValues);
      writeData(next);
      // EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
      _props.onRowEditCommit?.({
        rowId,
        changes
      });
    }
    endRowEdit();
    editTransition = false;
    if (changed) {
      // WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
      // like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
      // col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
      // $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
      // DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
      // refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
      // display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
      // the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
      // poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
      pendingEditFollow = {
        rowOriginal,
        rowId,
        col: focusCol
      };
    } else {
      // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume a
      // pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
      // relocate (no write), so the B23 relocation hazard does not apply: (focusRow, focusCol) is safe.
      focusCellWhenReady(focusRow, focusCol);
    }
    return true;
  }

  // cancelRow(): revert the whole row as a unit (D-06 — drop every draft, NO model write) and
  // return focus to the active cell.
  function cancelRow() {
    if (editingRowIndex() == null) return;
    const focusRow = activeRow();
    const focusCol = activeColIndex();
    editTransition = true;
    endRowEdit();
    editTransition = false;
    focusCellWhenReady(focusRow, focusCol);
  }

  // replaceRowValues: like replaceRowValue but applies a MAP of field→value to ONE row object
  // in a single fresh-array replace (req-6 — the whole-row commit is ONE write, not per cell).
  function replaceRowValues(rows: any, rowIndex: any, fieldValues: any) {
    const src = rows || [];
    const fv = fieldValues || {};
    const out = [];
    for (let i = 0; i < src.length; i++) {
      if (i === rowIndex) {
        // WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
        // prototype-walking copy. Spread copies own enumerable props only.
        out.push({
          ...(src[i] || {}),
          ...fv
        });
      } else {
        out.push(src[i]);
      }
    }
    return out;
  }

  // Compute the next editable cell for Tab-advance (req-3, RESEARCH Open-Q3 deterministic
  // rule): skip non-editable columns within the row; wrap to the NEXT row's first editable
  // cell at the row's end; stop (return null) at grid end. Pure index math over the visible
  // model. Returns { row, col } or null.
  function nextEditableCell(fromRow: any, fromCol: any) {
    const rowList = rows() || [];
    const rowCount = rowList.length;
    if (rowCount === 0) return null;
    let r = fromRow;
    let c = fromCol + 1;
    while (r < rowCount) {
      const row = rowList[r];
      const cells = row ? visibleCellsFor(row) : [];
      while (c < cells.length) {
        const cell = cells[c];
        const cid = cell && cell.column ? cell.column.id : null;
        if (cid != null && columnEditable(cid)) return {
          row: r,
          col: c
        };
        c = c + 1;
      }
      r = r + 1;
      c = 0;
    }
    return null;
  }

  // B4: the mirror of nextEditableCell — the PREVIOUS editable cell for a Shift+Tab
  // backward move. Skips non-editable columns leftward within the row; wraps to the END
  // of the prior row; stops (returns null) at grid start. Pure index math over the visible
  // model. Returns { row, col } or null.
  function prevEditableCell(fromRow: any, fromCol: any) {
    const rowList = rows() || [];
    const rowCount = rowList.length;
    if (rowCount === 0) return null;
    let r = fromRow;
    let c = fromCol - 1;
    while (r >= 0) {
      const row = rowList[r];
      const cells = row ? visibleCellsFor(row) : [];
      while (c >= 0) {
        const cell = cells[c];
        const cid = cell && cell.column ? cell.column.id : null;
        if (cid != null && columnEditable(cid)) return {
          row: r,
          col: c
        };
        c = c - 1;
      }
      r = r - 1;
      if (r >= 0) {
        const prow = rowList[r];
        const pcells = prow ? visibleCellsFor(prow) : [];
        c = pcells.length - 1;
      }
    }
    return null;
  }

  // Transient guard: true while an editor commit/cancel/Tab-advance is tearing the current
  // editor down. The unmounting editor fires a `blur` as it leaves the DOM — without this
  // guard onEditorBlur would re-enter commitEdit on the (already-resolved or newly-opened)
  // cell, double-counting cell-edit-commit. A top-level `let` (React hoists to useRef).
  let editTransition = false;

  // B23: a pending "follow the committed row's focus" request, set by commitEdit (a single-cell
  // commit that may relocate the row under an active sort/filter) and consumed ONCE by the next
  // refreshRowModel pass — which runs with the FRESH re-derived row model, so it can resolve the
  // committed row's NEW display index (React-stale-safe) and re-seat focus there. Shape:
  // { rowOriginal, rowId, col } or null. A top-level `let` (React hoists to useRef → persists).
  let pendingEditFollow: any = null;

  // Sync idempotency latch for a cell commit (drop-in double cell-edit-commit fix, 260705):
  // commitEdit's `$data.editingRow < 0` re-entry guard is ASYNC-STALE on React — a deferred
  // drop-in editor's unmount-blur (onBlur → $props.commit → commitEdit) fires AFTER commitEdit
  // has already returned (editTransition is a SYNC latch, cleared before the async blur), while
  // `$data.editingRow` in that stale closure still reads the OLD (pre-endEdit) value, so the
  // second commit slips through and re-emits `cell-edit-commit`. A top-level `let` is written/read
  // synchronously by plain assignment (unaffected by React's setState batching — that's the point)
  // so it stays correct across the async window editTransition/editingRow cannot cover. Set true on
  // a SUCCESSFUL commitEdit/toggleActiveBooleanCell; reset to false wherever a NEW edit session
  // begins (beginEdit/beginRowEdit/editCell) so the next legitimate commit fires exactly once.
  // A top-level `let` (React hoists to useRef → persists).
  let committedThisSession = false;

  // ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
  // In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
  // (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
  // rowDraft — so the four editors open simultaneously never clobber one shared value. These
  // helpers let the ONE editor template branch serve BOTH modes (no per-mode template fork):
  // the template binds editorValueFor(colId)/editorCheckedFor(colId) and writes via
  // onCellEditorInput(colId, evt)/onCellEditorCheckbox(colId, evt).
  function inRowEdit() {
    return editingRowIndex() != null;
  }
  function editorValueFor(colId: any) {
    return inRowEdit() ? rowDraft() ? rowDraft()[colId] : null : draftValue();
  }
  function editorCheckedFor(colId: any) {
    return !!(inRowEdit() ? rowDraft() ? rowDraft()[colId] : null : draftValue());
  }

  // #editor custom-slot callbacks (req-2/6): the consumer's slot calls commit(value)/cancel().
  // In SINGLE-CELL mode commit(v) commits that cell (commitEdit override); in ROW mode commit(v)
  // only WRITES this column's draft (the row commits as a unit later — never per cell). cancel()
  // reverts the cell (single) or the whole row (row mode). Factory-bound per columnId so the
  // row-mode commit targets the right draft key.
  function editorCommitFor(colId: any) {
    return (value: any) => {
      if (inRowEdit()) {
        setRowDraft$local(colId, value);
        return;
      }
      commitEdit(value);
    };
  }
  function editorCancelFor() {
    return () => {
      if (inRowEdit()) {
        cancelRow();
        return;
      }
      cancelEdit();
    };
  }

  // Editor input handlers (the global-filter `evt.target.value` idiom — an untyped param
  // neutralizes to `any`, so reading .value/.checked typechecks ×6; an inline
  // `$data.x = $event.target.value` binding does NOT neutralize and breaks Lit/React JSX).
  // Column-aware: in row mode they write rowDraft[colId] (a FRESH object so Solid/Svelte/React
  // re-derive); single-cell they write the shared draftValue.
  function onCellEditorInput(colId: any, evt: any) {
    const v = evt && evt.target ? evt.target.value : '';
    if (inRowEdit()) {
      setRowDraft$local(colId, v);
      return;
    }
    setDraftValue(v);
  }
  function onCellEditorCheckbox(colId: any, evt: any) {
    const v = !!(evt && evt.target && evt.target.checked);
    if (inRowEdit()) {
      setRowDraft$local(colId, v);
      return;
    }
    setDraftValue(v);
  }
  // setRowDraft: write ONE key into a FRESH rowDraft object (whole-object replace — an
  // in-place mutation is silently dropped on React/Solid; the family immutable rule).
  function setRowDraft$local(colId: any, value: any) {
    const src = rowDraft() || {};
    const next = {};
    for (const k in src) next[k] = src[k];
    next[colId] = value;
    setRowDraft(next);
  }

  // B21: contain a Tab WITHIN the editing row (editMode='row'). Resolve the editable cells'
  // visible col indices for the editing row, find the current editor's col (off the blurring
  // editor's owning [data-grid-cell]), then move to the next/prev editable col WITH WRAP so
  // focus never leaves the row. A no-op when no row is editing / the row has no editable cells.
  function rowEditTab(target: any, backward: any) {
    const rowIndex = editingRowIndex();
    if (rowIndex == null) return;
    const editable = editableColumnsForRow(rowIndex);
    if (editable.length === 0) return;
    const cols = editable.map((ec: any) => ec.colIndex);
    const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
    const curAttr = cell ? cell.getAttribute('data-col-index') : null;
    const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
    let pos = cols.indexOf(cur);
    if (pos < 0) pos = 0;
    const len = cols.length;
    const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
    // Editor-owns-focus contract (quick 260711-i5m): the Tab target becomes the new reactive
    // focus target BEFORE the host-focus call below, so Tab onto an already-mounted #editor
    // drop-in (row mode) also refocuses it via its own lazy $watch.
    setEditFocusColId(editable[nextPos].colId);
    setEditVer(editVer() + 1);
    focusRowEditorAt(rowIndex, cols[nextPos]);
  }

  // onEditorKeyDown: the editor-LOCAL keymap (req-3). Enter → commit + stay (focus returns
  // to the cell); Tab → commit + advance to the next editable cell; Escape → cancel +
  // revert. preventDefault on handled keys so the grid keymap / native Tab don't double-act.
  function onEditorKeyDown(e: any) {
    if (!e) return;
    const key = e.key;
    // Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
    // model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
    // row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
    // preventDefault it here.
    if (inRowEdit()) {
      if (key === 'Enter') {
        e.preventDefault();
        commitRow();
      } else if (key === 'Escape') {
        e.preventDefault();
        cancelRow();
      }
      // B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
      // editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
      // Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
      // first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
      // tab order across a Lit shadow boundary).
      else if (key === 'Tab') {
        e.preventDefault();
        rowEditTab(e.target, e.shiftKey);
      }
      return;
    }
    if (key === 'Enter') {
      e.preventDefault();
      commitEdit(undefined);
    } else if (key === 'Tab') {
      e.preventDefault();
      // Resolve the advance target from the EDITING pair (the cell that is open), not the
      // active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
      // moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
      // the editing pair BEFORE commit (commitEdit resets it to -1).
      const fromRow = editingRow();
      const fromCol = editingCol();
      const target = e.shiftKey ? prevEditableCell(fromRow, fromCol) : nextEditableCell(fromRow, fromCol);
      // skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
      // straight into the next editable cell's editor below. Use the RETURN value (not a
      // re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
      // failure returns false and keeps the editor open (the user must fix the value first).
      const committed = commitEdit(undefined, true);
      if (committed && target) {
        setActiveRow(target.row);
        setActiveColIndex(target.col);
        beginEdit(target.row, target.col, null);
      } else if (committed) {
        // B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
        // grid by returning it to the just-committed cell instead of letting it drop to <body>.
        focusCellWhenReady(fromRow, fromCol);
      }
    } else if (key === 'Escape') {
      e.preventDefault();
      cancelEdit();
    }
  }

  // onEditorBlur: commit on a genuine click/focus-away (D-01 — an invalid value keeps the
  // editor open via commitEdit's reject path). SKIP when:
  //  - editTransition is set (a synchronous commit/cancel teardown is unmounting the editor), or
  //  - the blur is part of a controlled keyboard transition: focus is moving to a grid cell
  //    or another editor inside our gridRoot (Tab-advance, Enter/Escape focus-return). On the
  //    async-render targets the unmount-blur can fire AFTER the synchronous flag cleared, so
  //    the relatedTarget/containment check is the load-bearing guard, not the flag alone.
  function onEditorBlur(e: any) {
    // Full-row mode (req-6): a blur that stays WITHIN the row editor — Tab/click between the
    // row's OWN fields — is a normal focus move and must NOT commit (a per-cell blur-commit
    // would split the row into N writes + N events, violating the one-write/one-event contract).
    // But an OUTSIDE-click blur (#7) MUST commit the row: otherwise the model is never written
    // AND editingRowIndex stays set, freezing onGridKeyDown's editingRowIndex early-return so
    // arrow-nav is dead the moment the user clicks back into the grid. Mirror the single-cell
    // branch's relatedTarget shape to tell an in-row focus move from a genuine click-away.
    if (inRowEdit()) {
      // Guard the teardown blur: commitRow's writeData/endRowEdit re-render unmounts the row's
      // editors → a same-tick re-render blur must NOT re-enter commitRow (double row-edit-commit).
      // commitRow sets editTransition synchronously BEFORE writeData, so it is set here during the
      // teardown window (the async unmount-blur that fires after endRowEdit finds editingRowIndex
      // already null → inRowEdit() false → the single-cell tail's editingRow<0 guard returns).
      if (editTransition) return;
      const rowNext = e ? e.relatedTarget : null;
      const rowNextCell = rowNext && rowNext.closest ? rowNext.closest('[data-grid-cell]') : null;
      const rowNextRow = rowNextCell ? rowNextCell.getAttribute('data-row') : null;
      // Focus landing on a cell of the SAME editing row (Tab/click between the row's own fields) →
      // controlled in-row move, do NOT commit. Anything else — a null relatedTarget, another row,
      // a toolbar/widget, or outside the grid entirely — is an outside-click → commit the row as a
      // unit. commitRow clears editingRowIndex, releasing onGridKeyDown's early-return so nav
      // resumes; a no-op row (nothing changed) takes commitRow's clean #5 no-write/no-emit path.
      if (rowNextRow != null && rowNextRow === String(editingRowIndex())) return;
      commitRow();
      return;
    }
    if (editingRow() < 0 || editTransition) return;
    const next = e ? e.relatedTarget : null;
    // A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
    // keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
    // a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
    // conservative null=skip behavior.
    if (next == null) return;
    // Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
    // the editor open on an invalid value).
    if (!(gridRoot && gridRoot.contains && gridRoot.contains(next))) {
      commitEdit(undefined);
      return;
    }
    // Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
    // keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
    // (which must commit + close so the grid is not wedged with an open editor).
    const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
    const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
    // Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
    // controlled move; skip. Also skip when either cell can't be resolved (an unmounting
    // editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
    if (!nextCell || !fromCell || nextCell === fromCell) return;
    // A Tab-advance already committed the old editor and opened the next one, so the live
    // editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
    // editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
    // double commit, WR-04).
    const fromRow = fromCell.getAttribute('data-row');
    const fromCol = fromCell.getAttribute('data-col-index');
    if (fromRow !== String(editingRow()) || fromCol !== String(editingCol())) return;
    // Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
    // commit does NOT bounce focus back to the just-committed editing cell (which would fight
    // the click destination). The commit's writeData re-renders the table and can DROP DOM
    // focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
    // DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
    // (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
    // late, which would steal focus back from a subsequent navigation.
    const destRow = nextCell.getAttribute('data-row');
    const destCol = nextCell.getAttribute('data-col-index');
    commitEdit(undefined, true);
    const reseatDestFocus = () => {
      if (!gridRoot || destRow == null || destCol == null || destRow === '__header') return;
      const root = gridRoot.getRootNode ? gridRoot.getRootNode() : null;
      const act = root && root.activeElement ? root.activeElement : null;
      // Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
      if (act && gridRoot.contains && gridRoot.contains(act)) return;
      const el = resolveCellEl(destRow, parseInt(destCol, 10));
      if (el) el.focus();
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
  }

  // editCell(rowIndex, colIndex) — programmatic edit-entry ($expose, req-3). Coerces +
  // clamps indices, moves the active cell, and opens the editor (no-op on a non-editable
  // cell). Collision-clean (RESEARCH name-check): not a verb/event/prop/ROZ137 member.
  function editCell(rowIndex: any, colIndex: any) {
    const lastRow = bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const maxCol = visibleColCount() - 1;
    const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
    const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
    // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
    committedThisSession = false;
    setActiveIsHeader(false);
    setActiveRow(r);
    setActiveColIndex(c);
    beginEdit(r, c, null);
  }

  // commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
  // nothing is editing. Collision-clean (not `commit`). Handles BOTH edit modes: a full-row
  // edit (editRow()/Shift+F2) drives editingRowIndex and leaves editingRow at -1, so the
  // single-cell commitEdit guard (editingRow >= 0) is false during a row edit — route to
  // commitRow() first so a programmatic commit of a row editor is not a silent no-op.
  function commitEditing() {
    if (inRowEdit()) {
      commitRow();
      return;
    }
    if (editingRow() >= 0) commitEdit(undefined);
  }

  // editRow(rowIndex) — programmatically enter full-row edit on a body row ($expose, req-6 /
  // D-06), the API twin of the Shift+F2 shortcut. Addressed BY INDEX over the visible model
  // (coerced + clamped); no-op on a row with no editable columns. Collision-clean (RESEARCH
  // name-check): `editRow` is not in the 15 existing verbs, not a prop, not a *-change/commit
  // event, not a Lit ROZ137-reserved host member. Moves the active cell to the row first so the
  // commit/cancel focus-return lands in the right row.
  function editRow(rowIndex: any) {
    const lastRow = bodyRowCount() - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const r = clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
    const rowList = rows() || [];
    const row = rowList[r];
    if (!row) return;
    setActiveIsHeader(false);
    setActiveRow(r);
    beginRowEdit(row);
  }

  // ── Grid active-cell $expose verbs (phase 49 plan 03, D-01) — exactly THREE, joining the
  // existing 12 (→ 15). Collision-safe names (Pitfall 1): focusCell NOT `focus` (would shadow
  // HTMLElement.focus on Lit — ROZ137); clearActiveCell NOT `clear` (listbox already exposes
  // `clear`); getActiveCell is a read-style getter. None collide with the 9 *-change events,
  // any prop, or a React auto-setter (ROZ121/137/524 clear). ──────────────────────────────────

  // focusAbsCellWhenReady — paginated page-switch focus poll (C1). After a programmatic page
  // switch the in-page (localRow, col) cell is ambiguous: EVERY page renders a row at the same
  // page-relative index, so a plain resolveCellEl(localRow, col) poll would grab the OLD page's
  // cell on frame 1 (before the switch commits) and focus it — only for the page switch to then
  // REMOVE it, dropping focus to <body>. Disambiguate by the ABSOLUTE aria-rowindex: poll until
  // the cell at (localRow, col) carries the TARGET page's body aria-rowindex (i.e. the TARGET
  // page has actually rendered), THEN focus. DOM-only (reads gridRoot), so React-stale-safe; works
  // for both controlled (round-trips through page-change) and uncontrolled pagination. ~60 frames
  // (~1s) to cover the controlled-state parent round-trip on React/Solid/Lit.
  //   #13: the body aria-rowindex is now header-offset (bodyAriaRowIndex = headerRowCount + absRow
  //   + 1) so header rows + body rows form one consistent aria-rowindex/aria-rowcount space — so
  //   the poll target must add headerRowCount() too, else it never matches and focus drops.
  function focusAbsCellWhenReady(absRow: any, localRow: any, col: any) {
    if (!gridRoot) return;
    let attempts = 0;
    const want = String(headerRowCount() + absRow + 1);
    // #9: capture the focus-intent epoch at arm time (AFTER focusCell's own bump at its top, so
    // this poll never aborts itself). A LATER focus intent — a click landing on a new cell
    // (syncActiveFromEvent) or another focusCell / keyboard nav — bumps the epoch, so this
    // paginated page-switch poll aborts instead of grabbing focus frames after the user moved on.
    const myEpoch = focusIntentEpoch;
    const tryFocus = () => {
      if (focusIntentEpoch !== myEpoch) return;
      const el = resolveCellEl(String(localRow), col);
      if (el) {
        const rowEl = el.closest ? el.closest('[role="row"]') : null;
        const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
        if (ari === want) {
          el.focus();
          return;
        }
      }
      attempts = attempts + 1;
      if (attempts >= 60) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
  }

  // focusCell(rowIndex, colIndex) — move + focus the active cell. C1 (phase 63 wave-6): rowIndex
  // is the ABSOLUTE display-order position in getPrePaginationRowModel().rows (filter+sort+expand
  // applied, BEFORE pagination/windowing), in BOTH paginated and virtual modes — REVERSING the old
  // page-relative-when-paginated meaning. Args are COERCED to integers and CLAMPED before the
  // data-* selector is built (T-49-01/T-63-06-01: never interpolate a raw consumer string; clamp
  // the abs index into getPrePaginationRowModel bounds). The activecell-change payload + getActiveCell
  // speak the SAME absolute language (toAbsRow).
  function focusCell(rowIndex: any, colIndex: any) {
    // B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
    // is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
    // isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
    // instance does not leak a spurious activecell-change.
    if (!isGrid()) return;
    // #9: focusCell is a focus-INTENT entry point — bump the epoch BEFORE arming any poll (the
    // switched-page focusAbsCellWhenReady captures the post-bump value; the same-page / virtual
    // branches route through focusActiveCell, which bumps again — harmless). A subsequent focusCell
    // or user nav bumps again → a pending focusAbsCellWhenReady from THIS call aborts.
    focusIntentEpoch = focusIntentEpoch + 1;
    const maxCol = visibleColCount() - 1;
    const c = clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
    // C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
    const absLast = prePaginationRowCount() - 1;
    const absRow = clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
    // B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
    // real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
    // active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
    const prevAbs = toAbsRow(activeRow());
    const prevIsHeader = activeIsHeader();
    if (local.virtual) {
      // Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
      // the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
      // focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
      setActiveIsHeader(false);
      setActiveInControl(false);
      setActiveRow(absRow);
      setActiveColIndex(c);
      focusActiveCell(absRow, c, false);
    } else {
      // Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
      // in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
      // body's data-row markers (and the roving tabindex) address.
      const size = pageSize();
      const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
      const localRow = absRow - targetPage * size;
      const switched = targetPage !== pageIndex();
      if (switched) setPage(targetPage);
      setActiveIsHeader(false);
      setActiveInControl(false);
      setActiveRow(localRow);
      setActiveColIndex(c);
      if (switched) {
        // The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
        // TARGET page's body aria-rowindex (headerRowCount + absRow + 1, #13) before focusing, so
        // the OLD page's same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only.
        focusAbsCellWhenReady(absRow, localRow, c);
      } else {
        // Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
        // Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
        // async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
        focusActiveCell(localRow, c, false);
      }
    }
    if (absRow !== prevAbs || prevIsHeader) {
      _props.onActivecellChange?.({
        rowIndex: absRow,
        colIndex: c
      });
    }
  }

  // getActiveCell() — return the current active-cell position. Integers only — no row data,
  // no DOM node (T-49-02 Information-Disclosure: return the screen position, nothing else).
  // B15: reflect the HEADER-active state. When a header cell is active the roving position is
  // NOT a body row — return the header sentinel (rowIndex null + isHeader true, colIndex the
  // header column) so a consumer never mistakes a header focus for body 'row 0'. A body cell
  // returns the integer rowIndex + isHeader false (back-compatible: the rowIndex/colIndex pair
  // is unchanged for the body case).
  // C1: a body cell returns the ABSOLUTE display-order rowIndex (toAbsRow) — matching focusCell's
  // addressing + the activecell-change payload — in BOTH paginated and virtual modes.
  function getActiveCell() {
    return activeIsHeader() ? {
      rowIndex: null,
      colIndex: activeColIndex(),
      isHeader: true
    } : {
      rowIndex: toAbsRow(activeRow()),
      colIndex: activeColIndex(),
      isHeader: false
    };
  }

  // clearActiveCell() — reset the roving position to the D-04 entry cell (row 0, col 0) and
  // exit interaction mode; the next Tab-in re-enters at the entry cell (D-01). Does NOT emit
  // (no move to a new addressable cell — a reset, not a navigation). B16: isGrid()-gated — a
  // table-mode instance has no roving active cell, so the verb is a no-op there.
  function clearActiveCell() {
    if (!isGrid()) return;
    setActiveIsHeader(false);
    setActiveInControl(false);
    setActiveRow(0);
    setActiveColIndex(0);
  }

  // ── Expand $expose verbs (phase 50 req-3, D-06) — joining the existing 19 (→ 23).
  // Collision-safe names (ROZ121/137/524): toggleRowExpanded / expandAll / collapseAll are
  // not inherited HTMLElement members, Lit lifecycle names, React auto-setters, prop names,
  // or *-change events; getExpandedRows is a read-style getter (twin of getSelectedRows).
  // Each drives @tanstack/table-core so the onExpandedChange → writeExpanded funnel fires
  // one expanded-change. ──────────────────────────────────────────────────────────────────

  // toggleRowExpanded(rowId) — toggle ONE row's expanded state, addressed by the consumer's
  // row id (the data `id` field) OR the table-core row id. Scans the core flat-row set (all
  // rows regardless of current expansion) so a collapsed parent is still resolvable.
  function toggleRowExpanded(rowId: any) {
    if (!table) return;
    const target = String(rowId);
    const flat = table.getCoreRowModel().flatRows;
    for (const r of flat as any) {
      if (r.id === target || r.original && String(r.original.id) === target) {
        r.toggleExpanded();
        return;
      }
    }
  }

  // expandAll() — open every expandable row (table-core sets ExpandedState to the `true`
  // literal under the hood → Pitfall 2: writeExpanded passes it through verbatim).
  function expandAll() {
    if (!table) return;
    table.toggleAllRowsExpanded(true);
  }

  // collapseAll() — reset to a blank expanded state ({}). resetExpanded(true) forces the
  // blank reset (NOT the initialState) and fires onExpandedChange → one expanded-change.
  function collapseAll() {
    if (!table) return;
    table.resetExpanded(true);
  }

  // getExpandedRows() — return the original row data for every currently-expanded row
  // (read-verb twin of expanded-change). Integers/data only — scans the core flat rows and
  // filters by getIsExpanded(). Empty when nothing is expanded.
  function getExpandedRows() {
    if (!table) return [];
    const out = [];
    const flat = table.getCoreRowModel().flatRows;
    for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
    return out;
  }

  // ── Grouping $expose verbs (phase 50 reqs 4-7, D-06 name-check) ────────────────────────────
  // applyGrouping (RENAMED from setGrouping — ROZ524: a bare `set<ModelProp>` verb shadows
  // React's auto-generated `setGrouping` useState setter for the `grouping` model slice, and an
  // $expose verb is PUBLIC-CONTRACT-PROTECTED from the deconfliction rename; same precedent as
  // setColumnOrder→applyColumnOrder) + clearGrouping. Both drive @tanstack/table-core's
  // table.setGrouping so the onGroupingChange → writeGrouping funnel fires one group-change with
  // the fresh ordered key list. Also handed to the headless #groupBar slot as apply/clear helpers.
  function applyGrouping(cols: any) {
    if (table) table.setGrouping(cols);
  }
  function clearGrouping() {
    if (table) table.setGrouping([]);
  }

  // ── Faceted filtering read helpers (phase 50 reqs 8-9, D-03) ────────────────────────────────
  // Shared by BOTH the getFaceted* $expose verbs AND the #filter slot props. They resolve a
  // column via table.getColumn(colId) (a table-core lookup — NEVER a string-built querySelector,
  // T-50-06 / the T-49-01 index-only discipline) and read table-core's CROSS-FILTERED faceted
  // values (default impl — reflects rows passing all OTHER active column filters, D-03). They
  // touch the reactive tick (`tick() < 0` guard) so the #filter slot props re-derive when an
  // upstream filter changes on the fine-grained targets (Solid/Lit) — the visibleCellsFor idiom.
  //
  // getFacetedUniqueValues: the column's distinct values, KEYS ONLY — occurrence counts are
  // deliberately NOT exposed (D-03; the column's getFacetedUniqueValues() returns Map<any,number>,
  // we return Array.from(map.keys()) — no .entries()/count surface). Empty array on missing
  // column/table. NAMED to match the $expose verb exactly (the ExposedMethod.name shorthand
  // contract: an exposed verb lowers to `{ getFacetedUniqueValues }`, which must resolve to THIS
  // helper — the table-core factory was aliased to makeFacetedUniqueValues to free this name).
  function getFacetedUniqueValues(colId: any) {
    if (tick() < 0 || !table) return [];
    const col = table.getColumn(colId);
    if (!col || !col.getFacetedUniqueValues) return [];
    const map = col.getFacetedUniqueValues(); // Map<any, number>
    return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
  }
  // getFacetedMinMaxValues: the column's [min, max] numeric range, or null when unavailable.
  // Named to match the $expose verb (same shorthand contract as getFacetedUniqueValues above).
  function getFacetedMinMaxValues(colId: any) {
    if (tick() < 0 || !table) return null;
    const col = table.getColumn(colId);
    if (!col || !col.getFacetedMinMaxValues) return null;
    return col.getFacetedMinMaxValues() || null; // [number, number] | null
  }

  return (
    <__ctx_data_table_columns.Provider value={{
  registerColumn: (id: any, spec: any) => {
    if (id == null) return;
    const key = String(id);
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
    setColReg({
      ...colReg(),
      [key]: spec
    });
  },
  unregisterColumn: (id: any) => {
    if (id == null) return;
    const r = {
      ...colReg()
    };
    delete r[String(id)];
    setColReg(r);
  }
}}>
    <>

    <div class={"rozie-data-table-wrap"} ref={(el) => { __rozieRootRef = el as HTMLElement; }} data-rozie-s-d5dcab4c="">

    <div class={"rdt-column-defs"} style={{ display: "none" }} aria-hidden="true" data-rozie-s-d5dcab4c="">{resolved()}</div>

    {<Show when={!!invalidMsg()}><div class={"rdt-sr-live"} role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c="">{invalidMsg()}</div></Show>}{<Show when={!!pasteAnnounce()}><div class={"rdt-sr-live rdt-sr-paste"} data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c="">{pasteAnnounce()}</div></Show>}{<Show when={!!liveAnnounce()}><div class={"rdt-sr-live rdt-sr-sortfilter"} data-testid="sortfilter-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c="">{liveAnnounce()}</div></Show>}<div class={"rdt-toolbar"} data-rozie-s-d5dcab4c="">
      <input type="text" role="searchbox" aria-label="Search table" class={"rdt-global-filter"} value={globalFilterValue()} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onGlobalFilterInput($event); }} data-rozie-s-d5dcab4c="" />
      
      {<Show when={allLeafColumns().length}><details class={"rdt-colvis"} data-rozie-s-d5dcab4c="">
        <summary class={"rdt-colvis-summary"} data-rozie-s-d5dcab4c="">Columns</summary>
        <div class={"rdt-colvis-menu"} role="group" aria-label="Toggle columns" data-rozie-s-d5dcab4c="">
          <Key each={allLeafColumns() as readonly any[]} by={(lc) => lc.id}>{(lc) => <label class={"rdt-colvis-item"} data-rozie-s-d5dcab4c="">
            <input type="checkbox" class={"rdt-colvis-checkbox"} checked={lc().visible} onChange={($event: Event & { currentTarget: HTMLInputElement; target: Element }) => { onToggleVisibility(lc().id); }} data-rozie-s-d5dcab4c="" />
            <span class={"rdt-colvis-label"} data-rozie-s-d5dcab4c="">{rozieDisplay(lc().label)}</span>
          </label>}</Key>
        </div>
      </details></Show>}</div>


    {<Show when={local.groupable}><div class={"rdt-group-bar-host"} data-rozie-s-d5dcab4c="">
      {(_props.groupBarSlot ?? _props.slots?.['groupBar'])?.({ grouping: groupingKeys(), groupableColumns: groupableColumns(), applyGrouping, clearGrouping }) ?? <Key each={groupingKeys() as readonly any[]} by={(gk) => gk}>{(gk) => <span class={"rdt-group-token"} data-group-token="" data-rozie-s-d5dcab4c="">{rozieDisplay(gk())}</span>}</Key>}
    </div></Show>}{<Show when={local.virtual} fallback={<table aria-rowcount={rozieAttr(gridAriaRowCount())} class={"rozie-data-table" + " " + rozieClass({ 'rdt-sticky': local.stickyHeader })} role={rozieAttr(tableRole())} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridKeyDown($event); }} onFocusIn={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { syncActiveFromEvent($event); }} onFocusOut={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridFocusOut($event); }} onMouseDown={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridMouseDown($event); }} onDblClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridDblClick($event); }} onClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridClick($event); }} data-rozie-s-d5dcab4c="">
      <thead class={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
        <Key each={headerGroups() as readonly any[]} by={(hg) => hg.id}>{(hg, hgLevel) => <tr class={"rdt-tr"} role="row" aria-rowindex={rozieAttr(hgLevel() + 1)} data-rozie-s-d5dcab4c="">
          <Key each={hg().headers as readonly any[]} by={(header) => header.id}>{(header) => <th class={"rdt-th" + " " + rozieClass({ 'rdt-select-th': isSelectColumn(header().column.id), 'rdt-expander-th': isExpanderColumn(header().column.id), 'rdt-th-resizing': columnIsResizing(header().column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg(), header()), hgLevel()) })} role="columnheader" data-col={rozieAttr(header().column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel())} colSpan={rozieAttr(header().colSpan > 1 ? header().colSpan : null)} data-col-index={rozieAttr(headerColIndexOf(hg(), header()))} tabIndex={rozieAttr(cellTabindex('__header', headerColIndexOf(hg(), header()), hgLevel()))} aria-sort={rozieAttr(ariaSortFor(header().column.id))} style={parseInlineStyle(thStyle(header().column.id))} data-rozie-s-d5dcab4c="">
            
            
            {<Show when={isSelectColumn(header().column.id)} fallback={<Show when={isExpanderColumn(header().column.id)} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              
              {<Show when={header().column.getCanSort && header().column.getCanSort()} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                <span class={"rdt-header-label"} data-rozie-s-d5dcab4c="">
                  {(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: header().column.id, column: header().column, label: headerLabel(header().column.id) }) ?? rozieDisplay(headerLabel(header().column.id))}
                </span>
              </span>}><button type="button" class={"rdt-sort-btn"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onHeaderSort(header().column.id, $event); }} data-rozie-s-d5dcab4c="">
                
                <span class={"rdt-header-label"} data-rozie-s-d5dcab4c="">
                  {(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: header().column.id, column: header().column, label: headerLabel(header().column.id) }) ?? rozieDisplay(headerLabel(header().column.id))}
                </span>
                <span class={"rdt-sort-ind"} aria-hidden="true" data-rozie-s-d5dcab4c="">{rozieDisplay(sortIndicator(header().column.id))}</span>
              </button></Show>}<Popover trigger="click" placement="bottom-end" strategy="fixed" offset={4} data-rozie-s-d5dcab4c="" anchorSlot={() => (<>
                  <button type="button" class={"rdt-col-menu-trigger"} aria-label={rozieAttr('Column options for ' + headerLabel(header().column.id))} data-rozie-s-d5dcab4c="">⋯</button>
                </>)}><div class={"rdt-col-menu"} role="menu" data-rozie-s-d5dcab4c="">
                  <button type="button" role="menuitem" aria-pressed={columnPinSide(header().column.id) === 'left'} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(header().column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">Pin left</button>
                  <button type="button" role="menuitem" aria-pressed={columnPinSide(header().column.id) === 'right'} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(header().column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">Pin right</button>
                  <button type="button" role="menuitem" aria-pressed={!columnPinSide(header().column.id)} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(header().column.id, false, $event); }} data-rozie-s-d5dcab4c="">Unpin</button>
                  <hr class={"rdt-col-menu-sep"} data-rozie-s-d5dcab4c="" />
                  <button type="button" role="menuitem" class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onHideColumn(header().column.id, $event); }} data-rozie-s-d5dcab4c="">Hide column</button>
                </div></Popover>
              
              <button type="button" aria-label={rozieAttr('Resize ' + headerLabel(header().column.id))} class={"rdt-resize-handle"} onPointerDown={($event: PointerEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onResizeStart(header().column.id, $event); }} onTouchStart={($event: TouchEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onResizeStart(header().column.id, $event); }} data-rozie-s-d5dcab4c=""><span class={"rdt-resize-grip"} aria-hidden="true" data-rozie-s-d5dcab4c="" /></button>
            </span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(_props.selectAllSlot ?? _props.slots?.['selectAll'])?.({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }) ?? <Show when={local.selectionMode === 'multiple'}><input type="checkbox" aria-label="Select all rows" class={"rdt-select-all"} checked={isAllRowsSelected()} onChange={($event: Event & { currentTarget: HTMLInputElement; target: Element }) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c="" /></Show>}
            </span></Show>}</th>}</Key>
        </tr>}</Key>
        
        {<Show when={hasAnyFilterableColumn()}><tr class={"rdt-filter-row"} data-rozie-s-d5dcab4c="">
          <Key each={headerGroups()[headerGroups().length - 1].headers as readonly any[]} by={(header) => header.id}>{(header) => <th class={"rdt-filter-cell"} role="presentation" style={parseInlineStyle(pinStyle(header().column.id))} data-rozie-s-d5dcab4c="">
            {<Show when={isSelectColumn(header().column.id)} fallback={<Show when={isExpanderColumn(header().column.id)} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {<Show when={columnIsFilterable(header().column.id) && !hasFilterSlot()}><input type="text" aria-label={rozieAttr('Filter ' + headerLabel(header().column.id))} class={"rdt-col-filter"} value={columnFilterValue(header().column.id)} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onColumnFilterInput(header().column.id, $event); }} onClick={($event: MouseEvent & { currentTarget: HTMLInputElement; target: Element }) => { stopEvent($event); }} data-rozie-s-d5dcab4c="" /></Show>}{<Show when={columnIsFilterable(header().column.id)}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(_props.filterSlot ?? _props.slots?.['filter'])?.({ columnId: header().column.id, value: columnFilterValue(header().column.id), uniqueValues: getFacetedUniqueValues(header().column.id), minMax: getFacetedMinMaxValues(header().column.id), setFilter: setColumnFilter })}
              </span></Show>}</span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /></Show>}</th>}</Key>
        </tr></Show>}</thead>

      <tbody class={"rdt-tbody"} role="rowgroup" data-rozie-s-d5dcab4c="">
        
        <Key each={rows() as readonly any[]} by={(row) => row.id}>{(row) => <>
        <tr class={"rdt-tr" + " " + rozieClass({ 'rdt-group-header': rowIsGrouped(row()) })} role="row" data-depth={rozieAttr(row().depth)} aria-rowindex={rozieAttr(bodyAriaRowIndex(row()))} data-group-header={rozieAttr(rowIsGrouped(row()) ? row().id : null)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(row()) ? row().id : null)} aria-expanded={(rowIsGrouped(row()) ? !!rowIsExpanded(row()) : null) ?? undefined} aria-selected={(local.selectionMode !== 'none' ? !!rowIsSelected(row()) : null) ?? undefined} aria-level={rozieAttr(groupingActive() ? row().depth + 1 : null)} data-rozie-s-d5dcab4c="">
          <Key each={visibleCellsFor(row()) as readonly any[]} by={(cell) => cell.id}>{(cell) => <td class={"rdt-td" + " " + rozieClass({ 'rdt-select-td': isSelectColumn(cell().column.id), 'rdt-expander-td': isExpanderColumn(cell().column.id), 'rdt-in-range': inRange(rowIndexOf(row()), colIndexOf(row(), cell())), 'rdt-cell-active': isActiveCell(String(rowIndexOf(row())), colIndexOf(row(), cell())) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cell().column.id)} data-grid-cell="" data-row={rozieAttr(rowIndexOf(row()))} data-col-index={rozieAttr(colIndexOf(row(), cell()))} tabIndex={rozieAttr(cellTabindex(String(rowIndexOf(row())), colIndexOf(row(), cell())))} style={parseInlineStyle(bodyCellStyle(row(), cell().column.id))} aria-invalid={rozieAttr(cellAriaInvalid(rowIndexOf(row()), colIndexOf(row(), cell())))} data-in-range={rozieAttr(inRange(rowIndexOf(row()), colIndexOf(row(), cell())) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cell()) ? cell().column.id : null)} data-rozie-s-d5dcab4c="">
            
            {<Show when={isExpanderColumn(cell().column.id)} fallback={<Show when={isSelectColumn(cell().column.id)} fallback={<Show when={cellIsGrouped(cell())} fallback={<Show when={isEditing(rowIndexOf(row()), colIndexOf(row(), cell()))} fallback={<Show when={cellIsPlaceholder(cell())} fallback={<span class={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
              {(_props.cellSlot ?? _props.slots?.['cell'])?.({ columnId: cell().column.id, column: cell().column, row: row().original, value: cell().getValue() }) ?? rozieDisplay(cell().getValue())}
            </span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {<Show when={hasEditorSlot(cell().column.id)} fallback={<Show when={editorTypeOf(cell().column.id) === 'number'} fallback={<Show when={editorTypeOf(cell().column.id) === 'select'} fallback={<Show when={editorTypeOf(cell().column.id) === 'checkbox'} fallback={<input type="text" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cell().column.id)} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onCellEditorInput(cell().column.id, $event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorKeyDown($event); }} onBlur={($event: FocusEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}><input type="checkbox" data-editing-cell="" class={"rdt-cell-editor"} checked={editorCheckedFor(cell().column.id)} onChange={($event: Event & { currentTarget: HTMLInputElement; target: Element }) => { onCellEditorCheckbox(cell().column.id, $event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorKeyDown($event); }} onBlur={($event: FocusEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><select data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cell().column.id)} onChange={($event: Event & { currentTarget: HTMLSelectElement; target: Element }) => { onCellEditorInput(cell().column.id, $event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLSelectElement; target: Element }) => { onEditorKeyDown($event); }} onBlur={($event: FocusEvent & { currentTarget: HTMLSelectElement; target: Element }) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
                <Key each={editorOptionsOf(cell().column.id) as readonly any[]} by={(opt) => opt.value}>{(opt) => <option value={rozieAttr(opt().value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt().label)}</option>}</Key>
              </select></Show>}><input type="number" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cell().column.id)} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onCellEditorInput(cell().column.id, $event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorKeyDown($event); }} onBlur={($event: FocusEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(_props.editorSlot ?? _props.slots?.['editor'])?.({ columnId: cell().column.id, column: cell().column, row: row().original, value: editorValueFor(cell().column.id), commit: editorCommitFor(cell().column.id), cancel: editorCancelFor(), autofocus: editorAutofocusFor(cell().column.id, rowIndexOf(row())) })}
              </span></Show>}</span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              <button type="button" data-expander="" aria-expanded={!!rowIsExpanded(row())} aria-label={rozieAttr(rowIsExpanded(row()) ? 'Collapse group' : 'Expand group')} class={"rdt-expander rdt-group-toggle"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onToggleExpand(row(), $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(row()) ? '▾' : '▸')}</button>
              <span class={"rdt-group-value"} data-rozie-s-d5dcab4c="">
                {(_props.cellSlot ?? _props.slots?.['cell'])?.({ columnId: cell().column.id, column: cell().column, row: row().original, value: cell().getValue() }) ?? rozieDisplay(cell().getValue())}
              </span>
              <span class={"rdt-group-count"} data-rozie-s-d5dcab4c="">{rozieDisplay('(' + groupSubRowCount(row()) + ')')}</span>
            </span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(_props.selectCellSlot ?? _props.slots?.['selectCell'])?.({ row: row().original, checked: rowIsSelected(row()), toggle: e => onToggleRow(row(), e) }) ?? <input type="checkbox" aria-label="Select row" class={"rdt-select-row"} checked={rowIsSelected(row())} onChange={($event: Event & { currentTarget: HTMLInputElement; target: Element }) => { onToggleRow(row(), $event); }} data-rozie-s-d5dcab4c="" />}
            </span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {<Show when={rowCanExpand(row())}><button type="button" data-expander="" aria-expanded={!!rowIsExpanded(row())} aria-label={rozieAttr(rowIsExpanded(row()) ? 'Collapse row' : 'Expand row')} class={"rdt-expander"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onToggleExpand(row(), $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(row()) ? '▾' : '▸')}</button></Show>}</span></Show>}{<Show when={isFillHandleCell(rowIndexOf(row()), colIndexOf(row(), cell()))}><span data-fill-handle="" data-testid="fill-handle" aria-hidden="true" class={"rdt-fill-handle"} onPointerDown={($event: PointerEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c="" /></Show>}</td>}</Key>
        </tr>
        
        {<Show when={rowShowsDetail(row())}><tr class={"rdt-detail-row"} role="row" data-detail-row={rozieAttr(row().id)} data-rozie-s-d5dcab4c="">
          <td class={"rdt-detail-cell"} colSpan={rozieAttr(visibleColCount())} data-rozie-s-d5dcab4c="">
            {(_props.detailSlot ?? _props.slots?.['detail'])?.({ row: row().original })}
          </td>
        </tr></Show>}</>}</Key>
      </tbody>
    </table>}><div class={"rdt-scroll"} style={parseInlineStyle(local.maxHeight ? 'max-height:' + local.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + local.maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c="">
    <table aria-rowcount={rozieAttr(gridAriaRowCount())} class={"rozie-data-table" + " " + rozieClass({ 'rdt-sticky': local.stickyHeader })} role={rozieAttr(tableRole())} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridKeyDown($event); }} onFocusIn={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { syncActiveFromEvent($event); }} onFocusOut={($event: FocusEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridFocusOut($event); }} onMouseDown={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridMouseDown($event); }} onDblClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridDblClick($event); }} onClick={($event: MouseEvent & { currentTarget: HTMLTableElement; target: Element }) => { onGridClick($event); }} data-rozie-s-d5dcab4c="">
      <thead class={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
        <Key each={headerGroups() as readonly any[]} by={(hg) => hg.id}>{(hg, hgLevel) => <tr class={"rdt-tr"} role="row" aria-rowindex={rozieAttr(hgLevel() + 1)} data-rozie-s-d5dcab4c="">
          <Key each={hg().headers as readonly any[]} by={(header) => header.id}>{(header) => <th class={"rdt-th" + " " + rozieClass({ 'rdt-select-th': isSelectColumn(header().column.id), 'rdt-expander-th': isExpanderColumn(header().column.id), 'rdt-th-resizing': columnIsResizing(header().column.id), 'rdt-cell-active': isActiveCell('__header', headerColIndexOf(hg(), header()), hgLevel()) })} role="columnheader" data-col={rozieAttr(header().column.id)} data-grid-cell="" data-row="__header" data-header-level={rozieAttr(hgLevel())} colSpan={rozieAttr(header().colSpan > 1 ? header().colSpan : null)} data-col-index={rozieAttr(headerColIndexOf(hg(), header()))} tabIndex={rozieAttr(cellTabindex('__header', headerColIndexOf(hg(), header()), hgLevel()))} aria-sort={rozieAttr(ariaSortFor(header().column.id))} style={parseInlineStyle(thStyle(header().column.id))} data-rozie-s-d5dcab4c="">
            {<Show when={isSelectColumn(header().column.id)} fallback={<Show when={isExpanderColumn(header().column.id)} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {<Show when={header().column.getCanSort && header().column.getCanSort()} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                <span class={"rdt-header-label"} data-rozie-s-d5dcab4c="">
                  {(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: header().column.id, column: header().column, label: headerLabel(header().column.id) }) ?? rozieDisplay(headerLabel(header().column.id))}
                </span>
              </span>}><button type="button" class={"rdt-sort-btn"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onHeaderSort(header().column.id, $event); }} data-rozie-s-d5dcab4c="">
                <span class={"rdt-header-label"} data-rozie-s-d5dcab4c="">
                  {(_props.colHeaderSlot ?? _props.slots?.['colHeader'])?.({ columnId: header().column.id, column: header().column, label: headerLabel(header().column.id) }) ?? rozieDisplay(headerLabel(header().column.id))}
                </span>
                <span class={"rdt-sort-ind"} aria-hidden="true" data-rozie-s-d5dcab4c="">{rozieDisplay(sortIndicator(header().column.id))}</span>
              </button></Show>}<Popover trigger="click" placement="bottom-end" strategy="fixed" offset={4} data-rozie-s-d5dcab4c="" anchorSlot={() => (<>
                  <button type="button" class={"rdt-col-menu-trigger"} aria-label={rozieAttr('Column options for ' + headerLabel(header().column.id))} data-rozie-s-d5dcab4c="">⋯</button>
                </>)}><div class={"rdt-col-menu"} role="menu" data-rozie-s-d5dcab4c="">
                  <button type="button" role="menuitem" aria-pressed={columnPinSide(header().column.id) === 'left'} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(header().column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">Pin left</button>
                  <button type="button" role="menuitem" aria-pressed={columnPinSide(header().column.id) === 'right'} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(header().column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">Pin right</button>
                  <button type="button" role="menuitem" aria-pressed={!columnPinSide(header().column.id)} class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPinColumn(header().column.id, false, $event); }} data-rozie-s-d5dcab4c="">Unpin</button>
                  <hr class={"rdt-col-menu-sep"} data-rozie-s-d5dcab4c="" />
                  <button type="button" role="menuitem" class={"rdt-col-menu-item"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onHideColumn(header().column.id, $event); }} data-rozie-s-d5dcab4c="">Hide column</button>
                </div></Popover>
              <button type="button" aria-label={rozieAttr('Resize ' + headerLabel(header().column.id))} class={"rdt-resize-handle"} onPointerDown={($event: PointerEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onResizeStart(header().column.id, $event); }} onTouchStart={($event: TouchEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onResizeStart(header().column.id, $event); }} data-rozie-s-d5dcab4c=""><span class={"rdt-resize-grip"} aria-hidden="true" data-rozie-s-d5dcab4c="" /></button>
            </span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(_props.selectAllSlot ?? _props.slots?.['selectAll'])?.({ checked: isAllRowsSelected(), indeterminate: isSomeRowsSelected(), toggle: onToggleAllRows }) ?? <Show when={local.selectionMode === 'multiple'}><input type="checkbox" aria-label="Select all rows" class={"rdt-select-all"} checked={isAllRowsSelected()} onChange={($event: Event & { currentTarget: HTMLInputElement; target: Element }) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c="" /></Show>}
            </span></Show>}</th>}</Key>
        </tr>}</Key>
        
        {<Show when={hasAnyFilterableColumn()}><tr class={"rdt-filter-row"} data-rozie-s-d5dcab4c="">
          <Key each={headerGroups()[headerGroups().length - 1].headers as readonly any[]} by={(header) => header.id}>{(header) => <th class={"rdt-filter-cell"} role="presentation" style={parseInlineStyle(pinStyle(header().column.id))} data-rozie-s-d5dcab4c="">
            {<Show when={isSelectColumn(header().column.id)} fallback={<Show when={isExpanderColumn(header().column.id)} fallback={<span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {<Show when={columnIsFilterable(header().column.id) && !hasFilterSlot()}><input type="text" aria-label={rozieAttr('Filter ' + headerLabel(header().column.id))} class={"rdt-col-filter"} value={columnFilterValue(header().column.id)} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onColumnFilterInput(header().column.id, $event); }} onClick={($event: MouseEvent & { currentTarget: HTMLInputElement; target: Element }) => { stopEvent($event); }} data-rozie-s-d5dcab4c="" /></Show>}{<Show when={columnIsFilterable(header().column.id)}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(_props.filterSlot ?? _props.slots?.['filter'])?.({ columnId: header().column.id, value: columnFilterValue(header().column.id), uniqueValues: getFacetedUniqueValues(header().column.id), minMax: getFacetedMinMaxValues(header().column.id), setFilter: setColumnFilter })}
              </span></Show>}</span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /></Show>}</th>}</Key>
        </tr></Show>}</thead>

      <tbody class={"rdt-tbody"} role="rowgroup" data-rozie-s-d5dcab4c="">
        
        <tr class={"rdt-spacer"} aria-hidden="true" data-rozie-s-d5dcab4c="">
          <td colSpan={rozieAttr(visibleColCount())} style={parseInlineStyle('height:' + padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
        </tr>
        
        <Key each={windowedRows() as readonly any[]} by={(wr) => wr.row.id}>{(wr) => <>
        <tr class={"rdt-tr" + " " + rozieClass({ 'rdt-group-header': rowIsGrouped(wr().row), 'rdt-row-pinned': wr().pinned })} role="row" data-row={rozieAttr(wr().vi.index)} aria-rowindex={rozieAttr(headerRowCount() + wr().vi.index + 1)} data-index={rozieAttr(wr().vi.index)} data-pinned={rozieAttr(wr().pinned ? 'true' : null)} data-depth={rozieAttr(wr().row.depth)} data-group-header={rozieAttr(rowIsGrouped(wr().row) ? wr().row.id : null)} data-group-leaf={rozieAttr(groupingActive() && !rowIsGrouped(wr().row) ? wr().row.id : null)} aria-expanded={(rowIsGrouped(wr().row) ? !!rowIsExpanded(wr().row) : null) ?? undefined} aria-selected={(local.selectionMode !== 'none' ? !!rowIsSelected(wr().row) : null) ?? undefined} aria-level={rozieAttr(groupingActive() ? wr().row.depth + 1 : null)} data-rozie-s-d5dcab4c="">
          <Key each={visibleCellsFor(wr().row) as readonly any[]} by={(cell) => cell.id}>{(cell) => <td class={"rdt-td" + " " + rozieClass({ 'rdt-select-td': isSelectColumn(cell().column.id), 'rdt-expander-td': isExpanderColumn(cell().column.id), 'rdt-in-range': inRange(wr().vi.index, colIndexOf(wr().row, cell())), 'rdt-cell-active': isActiveCell(String(wr().vi.index), colIndexOf(wr().row, cell())) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cell().column.id)} data-grid-cell="" data-row={rozieAttr(wr().vi.index)} data-col-index={rozieAttr(colIndexOf(wr().row, cell()))} tabIndex={rozieAttr(cellTabindex(String(wr().vi.index), colIndexOf(wr().row, cell())))} style={parseInlineStyle(bodyCellStyle(wr().row, cell().column.id))} aria-invalid={rozieAttr(cellAriaInvalid(wr().vi.index, colIndexOf(wr().row, cell())))} data-in-range={rozieAttr(inRange(wr().vi.index, colIndexOf(wr().row, cell())) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cell()) ? cell().column.id : null)} data-rozie-s-d5dcab4c="">
            
            {<Show when={isExpanderColumn(cell().column.id)} fallback={<Show when={isSelectColumn(cell().column.id)} fallback={<Show when={cellIsGrouped(cell())} fallback={<Show when={isEditing(wr().vi.index, colIndexOf(wr().row, cell()))} fallback={<Show when={cellIsPlaceholder(cell())} fallback={<span class={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
              {(_props.cellSlot ?? _props.slots?.['cell'])?.({ columnId: cell().column.id, column: cell().column, row: wr().row.original, value: cell().getValue() }) ?? rozieDisplay(cell().getValue())}
            </span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {<Show when={hasEditorSlot(cell().column.id)} fallback={<Show when={editorTypeOf(cell().column.id) === 'number'} fallback={<Show when={editorTypeOf(cell().column.id) === 'select'} fallback={<Show when={editorTypeOf(cell().column.id) === 'checkbox'} fallback={<input type="text" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cell().column.id)} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onCellEditorInput(cell().column.id, $event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorKeyDown($event); }} onBlur={($event: FocusEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}><input type="checkbox" data-editing-cell="" class={"rdt-cell-editor"} checked={editorCheckedFor(cell().column.id)} onChange={($event: Event & { currentTarget: HTMLInputElement; target: Element }) => { onCellEditorCheckbox(cell().column.id, $event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorKeyDown($event); }} onBlur={($event: FocusEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><select data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cell().column.id)} onChange={($event: Event & { currentTarget: HTMLSelectElement; target: Element }) => { onCellEditorInput(cell().column.id, $event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLSelectElement; target: Element }) => { onEditorKeyDown($event); }} onBlur={($event: FocusEvent & { currentTarget: HTMLSelectElement; target: Element }) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
                <Key each={editorOptionsOf(cell().column.id) as readonly any[]} by={(opt) => opt.value}>{(opt) => <option value={rozieAttr(opt().value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt().label)}</option>}</Key>
              </select></Show>}><input type="number" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cell().column.id)} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onCellEditorInput(cell().column.id, $event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorKeyDown($event); }} onBlur={($event: FocusEvent & { currentTarget: HTMLInputElement; target: Element }) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(_props.editorSlot ?? _props.slots?.['editor'])?.({ columnId: cell().column.id, column: cell().column, row: wr().row.original, value: editorValueFor(cell().column.id), commit: editorCommitFor(cell().column.id), cancel: editorCancelFor(), autofocus: editorAutofocusFor(cell().column.id, wr().vi.index) })}
              </span></Show>}</span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              <button type="button" data-expander="" aria-expanded={!!rowIsExpanded(wr().row)} aria-label={rozieAttr(rowIsExpanded(wr().row) ? 'Collapse group' : 'Expand group')} class={"rdt-expander rdt-group-toggle"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onToggleExpand(wr().row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(wr().row) ? '▾' : '▸')}</button>
              <span class={"rdt-group-value"} data-rozie-s-d5dcab4c="">
                {(_props.cellSlot ?? _props.slots?.['cell'])?.({ columnId: cell().column.id, column: cell().column, row: wr().row.original, value: cell().getValue() }) ?? rozieDisplay(cell().getValue())}
              </span>
              <span class={"rdt-group-count"} data-rozie-s-d5dcab4c="">{rozieDisplay('(' + groupSubRowCount(wr().row) + ')')}</span>
            </span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(_props.selectCellSlot ?? _props.slots?.['selectCell'])?.({ row: wr().row.original, checked: rowIsSelected(wr().row), toggle: e => onToggleRow(wr().row, e) }) ?? <input type="checkbox" aria-label="Select row" class={"rdt-select-row"} checked={rowIsSelected(wr().row)} onChange={($event: Event & { currentTarget: HTMLInputElement; target: Element }) => { onToggleRow(wr().row, $event); }} data-rozie-s-d5dcab4c="" />}
            </span></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {<Show when={rowCanExpand(wr().row)}><button type="button" data-expander="" aria-expanded={!!rowIsExpanded(wr().row)} aria-label={rozieAttr(rowIsExpanded(wr().row) ? 'Collapse row' : 'Expand row')} class={"rdt-expander"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onToggleExpand(wr().row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(wr().row) ? '▾' : '▸')}</button></Show>}</span></Show>}{<Show when={isFillHandleCell(wr().vi.index, colIndexOf(wr().row, cell()))}><span data-fill-handle="" data-testid="fill-handle" aria-hidden="true" class={"rdt-fill-handle"} onPointerDown={($event: PointerEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c="" /></Show>}</td>}</Key>
        </tr>
        
        {<Show when={rowShowsDetail(wr().row)}><tr class={"rdt-detail-row"} role="row" data-detail-row={rozieAttr(wr().row.id)} data-rozie-s-d5dcab4c="">
          <td class={"rdt-detail-cell"} colSpan={rozieAttr(visibleColCount())} data-rozie-s-d5dcab4c="">
            {(_props.detailSlot ?? _props.slots?.['detail'])?.({ row: wr().row.original })}
          </td>
        </tr></Show>}</>}</Key>
        
        <tr class={"rdt-spacer"} aria-hidden="true" data-rozie-s-d5dcab4c="">
          <td colSpan={rozieAttr(visibleColCount())} style={parseInlineStyle('height:' + padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c="" />
        </tr>
      </tbody>
    </table>
    </div></Show>}{<Show when={!local.virtual}><div class={"rdt-pagination"} role="group" aria-label="Pagination" data-rozie-s-d5dcab4c="">
      <button type="button" class={"rdt-page-btn rdt-page-prev"} disabled={!canPrevPage()} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onPrevPage(); }} data-rozie-s-d5dcab4c="">Prev</button>
      <span class={"rdt-page-status"} aria-live="polite" data-rozie-s-d5dcab4c="">
        {rozieDisplay('Page ' + (pageIndex() + 1) + ' of ' + displayPageCount())}
      </span>
      <button type="button" class={"rdt-page-btn rdt-page-next"} disabled={!canNextPage()} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onNextPage(); }} data-rozie-s-d5dcab4c="">Next</button>
      <select aria-label="Rows per page" class={"rdt-page-size"} value={pageSize()} onChange={($event: Event & { currentTarget: HTMLSelectElement; target: Element }) => { onPageSizeChange($event); }} data-rozie-s-d5dcab4c="">
        <option value={10} data-rozie-s-d5dcab4c="">10</option>
        <option value={25} data-rozie-s-d5dcab4c="">25</option>
        <option value={50} data-rozie-s-d5dcab4c="">50</option>
        <option value={100} data-rozie-s-d5dcab4c="">100</option>
      </select>
    </div></Show>}</div>
    </>
    </__ctx_data_table_columns.Provider>
  );
}
ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
import { RozieSlotDistributor, createLitControllableProperty, rozieAttr, rozieDisplay, rozieStyle } from '@rozie/runtime-lit';
import { ContextProvider, createContext } from '@lit/context';
import { repeat } from 'lit/directives/repeat.js';
import '@rozie-ui/popover-lit';
import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
// Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
// (mirrors the expand/group models) — inert until a consumer READS a column facet via the
// getFaceted* $expose verbs or the #filter slot props, so byte-identical-off (req-10) holds.
// getFacetedUniqueValues/getFacetedMinMaxValues default impls are CROSS-FILTERED out of the
// box (D-03 — reflect rows passing all OTHER active column filters); unique values + min/max
// ONLY — occurrence counts are deliberately NOT exposed (Array.from(map.keys()) — D-03).
getFacetedRowModel,
// Aliased to make<…> so the bare names `getFacetedUniqueValues`/`getFacetedMinMaxValues`
// are FREE for the $expose verb helpers below. The $expose IR carries only the verb NAME
// (the `key:value` alias is discarded — ExposedMethod.name), so an exposed
// `getFacetedUniqueValues` lowers to the shorthand `{ getFacetedUniqueValues }`, which MUST
// resolve to the in-scope helper, NOT this table-core factory import (the collision that made
// the verb return the factory fn instead of the keys array — roundout facet block).
getFacetedUniqueValues as makeFacetedUniqueValues, getFacetedMinMaxValues as makeFacetedMinMaxValues } from '@tanstack/table-core';
// Vertical row windowing (phase 53). A3: this static import line is emitted UNCONDITIONALLY
// (virtual-core is a peer dep the consumer installs); byte-identical-off (req-1) is satisfied
// by ALL virtual-core RUNTIME references sitting behind `if ($props.virtual)` / a `virtualizer`
// guard so they never execute when off — the import token is the only static virtual-core
// presence. NO per-framework adapter (the codegen guard forbids @tanstack/<fw>-virtual).
import { Virtualizer, elementScroll, observeElementRect, observeElementOffset, measureElement } from '@tanstack/virtual-core';

// table-core instance — top-level `let` referenced from hooks → React hoists to
// useRef (hoistModuleLet). NULL until $onMount: createTable lives in $onMount so its
// getRowModel-reading closures capture the LIVE instance, NOT an empty initial
// snapshot (the rete stale-closure anti-pattern — a top-level $computed/useCallback
// freezes the table at the empty-initial state on React).

const __rozieCtx_data_table_columns = createContext(Symbol.for("rozie:data-table:columns"));

interface RozieGroupBarSlotCtx {
  grouping: any;
  groupableColumns: any;
  applyGrouping: any;
  clearGrouping: any;
}

interface RozieSelectAllSlotCtx {
  checked: any;
  indeterminate: any;
  toggle: any;
}

interface RozieColHeaderSlotCtx {
  columnId: any;
  column: any;
  label: any;
}

interface RozieFilterSlotCtx {
  columnId: any;
  value: any;
  uniqueValues: any;
  minMax: any;
  setFilter: any;
}

interface RozieSelectCellSlotCtx {
  row: any;
  checked: any;
  toggle: any;
}

interface RozieCellSlotCtx {
  columnId: any;
  column: any;
  row: any;
  value: any;
}

interface RozieEditorSlotCtx {
  columnId: any;
  column: any;
  row: any;
  value: any;
  commit: any;
  cancel: any;
  autofocus: any;
}

interface RozieDetailSlotCtx {
  row: any;
}

@customElement('rozie-data-table')
export default class DataTable extends SignalWatcher(LitElement) {
  static shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, slotAssignment: 'manual' };

  static styles = css`
:host{display:contents}
.rozie-data-table[data-rozie-s-d5dcab4c] {
  border-collapse: collapse;
  width: 100%;
  font: var(--rdt-font, 14px system-ui, sans-serif);
  color: var(--rdt-color, inherit);
}
.rdt-sr-live[data-rozie-s-d5dcab4c] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-cell-editor[data-rozie-s-d5dcab4c] {
  font: inherit;
  width: 100%;
  box-sizing: border-box;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[aria-invalid="true"][data-rozie-s-d5dcab4c] {
  outline: var(--rdt-invalid-outline, 2px solid #d33);
  outline-offset: -2px;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td.rdt-in-range[data-rozie-s-d5dcab4c] {
  background: var(--rdt-range-bg, rgba(37, 99, 235, 0.12));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td.rdt-cell-active[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th.rdt-cell-active[data-rozie-s-d5dcab4c] {
  outline: var(--rdt-active-cell-outline, 2px solid #2563eb);
  outline-offset: -2px;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
  position: relative;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-fill-handle[data-rozie-s-d5dcab4c] {
  position: absolute;
  right: -3px;
  bottom: -3px;
  width: 8px;
  height: 8px;
  background: var(--rdt-fill-handle-bg, #2563eb);
  border: 1px solid #fff;
  cursor: crosshair;
  z-index: 1;
  touch-action: none;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
  padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
  text-align: left;
  border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
  font-weight: var(--rdt-header-weight, 600);
  /* OPAQUE default (was rgba(0,0,0,0.03)): a translucent header lets the scrolling body
     bleed through in sticky mode. #f7f7f7 is the visual equivalent of the old 3%-black
     tint over white, but solid. The three design-system themes already ship opaque
     header backgrounds; this makes the zero-config default consistent with them. */
  background: var(--rdt-header-bg, #f7f7f7);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-btn[data-rozie-s-d5dcab4c] {
  display: inline-flex;
  align-items: center;
  gap: var(--rdt-sort-gap, 0.35em);
  background: none;
  border: none;
  font: inherit;
  font-weight: inherit;
  color: inherit;
  cursor: pointer;
  padding: 0;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-sort-ind[data-rozie-s-d5dcab4c] {
  font-size: 0.8em;
  opacity: var(--rdt-sort-ind-opacity, 0.7);
}
.rozie-data-table.rdt-sticky[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
  position: sticky;
  top: var(--rdt-sticky-top, 0);
  z-index: var(--rdt-sticky-z, 2);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-scroll[data-rozie-s-d5dcab4c] {
  max-height: var(--rozie-data-table-max-height);
  overflow: auto;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-bar-host[data-rozie-s-d5dcab4c] {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--rdt-group-bar-gap, 0.375rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-group-token {
  display: inline-flex;
  align-items: center;
  padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
  border-radius: var(--rdt-group-token-radius, 999px);
  background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
  font-size: var(--rdt-group-token-size, 0.8125em);
}
::part(group-token) {
  display: inline-flex;
  align-items: center;
  padding: var(--rdt-group-token-pad, 0.125rem 0.5rem);
  border-radius: var(--rdt-group-token-radius, 999px);
  background: var(--rdt-group-token-bg, rgba(0, 0, 0, 0.06));
  font-size: var(--rdt-group-token-size, 0.8125em);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-header[data-rozie-s-d5dcab4c] {
  background: var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025));
  font-weight: var(--rdt-group-header-weight, 600);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-toggle[data-rozie-s-d5dcab4c] {
  margin-right: var(--rdt-group-toggle-gap, 0.375rem);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-count[data-rozie-s-d5dcab4c] {
  margin-left: var(--rdt-group-count-gap, 0.375rem);
  opacity: var(--rdt-group-count-opacity, 0.65);
  font-weight: 400;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] {
  display: flex;
  flex-direction: column;
  gap: var(--rdt-chrome-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-toolbar[data-rozie-s-d5dcab4c] {
  display: flex;
  gap: var(--rdt-toolbar-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-global-filter[data-rozie-s-d5dcab4c],
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter {
  font: inherit;
  /* border-box so the padding + border count INSIDE the declared width — without it
     the col-filter's \`width: 100%\` + padding overflows its (constrained) header cell. */
  box-sizing: border-box;
  padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
  border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-filter-radius, 4px);
  background: var(--rdt-filter-bg, transparent);
  color: inherit;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-col-filter {
  display: block;
  margin-top: var(--rdt-col-filter-gap, 0.25rem);
  width: 100%;
  font-weight: normal;
}
::part(col-filter) {
  font: inherit;
  box-sizing: border-box;
  padding: var(--rdt-filter-padding, 0.25rem 0.5rem);
  border: var(--rdt-filter-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-filter-radius, 4px);
  background: var(--rdt-filter-bg, transparent);
  color: inherit;
  display: block;
  margin-top: var(--rdt-col-filter-gap, 0.25rem);
  width: 100%;
  font-weight: normal;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-filter-row[data-rozie-s-d5dcab4c] {
  background: var(--rdt-filter-row-bg, rgba(0, 0, 0, 0.015));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-filter-cell[data-rozie-s-d5dcab4c] {
  padding: var(--rdt-filter-cell-padding, 0.35rem 0.75rem);
  border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-filter-row[data-rozie-s-d5dcab4c] .rdt-col-filter {
  font-size: var(--rdt-filter-row-input-size, 0.9em);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-pagination[data-rozie-s-d5dcab4c] {
  display: flex;
  align-items: center;
  gap: var(--rdt-pagination-gap, 0.5rem);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c] {
  font: inherit;
  cursor: pointer;
  padding: var(--rdt-page-btn-padding, 0.25rem 0.6rem);
  border: var(--rdt-page-btn-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-page-btn-radius, 4px);
  background: var(--rdt-page-btn-bg, transparent);
  color: inherit;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-btn[data-rozie-s-d5dcab4c]:disabled {
  opacity: var(--rdt-page-btn-disabled-opacity, 0.4);
  cursor: default;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-status[data-rozie-s-d5dcab4c] {
  font-size: var(--rdt-page-status-size, 0.9em);
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-page-size[data-rozie-s-d5dcab4c] {
  font: inherit;
  padding: var(--rdt-page-size-padding, 0.2rem 0.4rem);
  border: var(--rdt-page-size-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-page-size-radius, 4px);
  background: var(--rdt-page-size-bg, transparent);
  color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
  position: relative;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c] {
  position: absolute;
  top: 0;
  right: 0;
  height: 100%;
  width: var(--rdt-resize-handle-width, 6px);
  padding: 0;
  border: none;
  background: none;
  cursor: col-resize;
  touch-action: none;
  user-select: none;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
  display: block;
  width: var(--rdt-resize-grip-width, 2px);
  height: 100%;
  margin: 0 auto;
  background: var(--rdt-resize-grip-color, rgba(0, 0, 0, 0.12));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-resize-handle[data-rozie-s-d5dcab4c]:hover .rdt-resize-grip[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-th-resizing[data-rozie-s-d5dcab4c] .rdt-resize-grip[data-rozie-s-d5dcab4c] {
  background: var(--rdt-resize-grip-active, rgba(0, 0, 0, 0.4));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-trigger[data-rozie-s-d5dcab4c] {
  font: inherit;
  font-size: var(--rdt-col-menu-trigger-size, 0.9em);
  line-height: 1;
  cursor: pointer;
  margin-left: var(--rdt-col-menu-trigger-margin, 0.35em);
  padding: var(--rdt-col-menu-trigger-padding, 0.15em 0.4em);
  border: var(--rdt-col-menu-trigger-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-col-menu-trigger-radius, 3px);
  background: var(--rdt-col-menu-trigger-bg, transparent);
  color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-trigger[data-rozie-s-d5dcab4c]:hover {
  background: var(--rdt-col-menu-trigger-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-trigger[data-rozie-s-d5dcab4c]:focus-visible {
  outline: var(--rdt-col-menu-trigger-focus-outline, 2px solid #2563eb);
  outline-offset: 1px;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu[data-rozie-s-d5dcab4c] {
  display: flex;
  flex-direction: column;
  gap: var(--rdt-col-menu-item-gap, 0.15rem);
  min-width: var(--rdt-col-menu-min-width, 9rem);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-item[data-rozie-s-d5dcab4c] {
  display: block;
  width: 100%;
  text-align: left;
  font: inherit;
  cursor: pointer;
  padding: var(--rdt-col-menu-item-padding, 0.35em 0.6em);
  border: none;
  border-radius: var(--rdt-col-menu-item-radius, 3px);
  background: none;
  color: inherit;
  white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-item[data-rozie-s-d5dcab4c]:hover {
  background: var(--rdt-col-menu-item-hover-bg, rgba(0, 0, 0, 0.06));
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-item[aria-pressed='true'][data-rozie-s-d5dcab4c] {
  background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
  font-weight: 700;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-col-menu-sep[data-rozie-s-d5dcab4c] {
  margin: var(--rdt-col-menu-sep-margin, 0.25rem 0);
  border: none;
  border-top: var(--rdt-col-menu-sep-border, 1px solid rgba(0, 0, 0, 0.1));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis[data-rozie-s-d5dcab4c] {
  position: relative;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-summary[data-rozie-s-d5dcab4c] {
  cursor: pointer;
  font: inherit;
  padding: var(--rdt-colvis-summary-padding, 0.25rem 0.6rem);
  border: var(--rdt-colvis-summary-border, 1px solid rgba(0, 0, 0, 0.2));
  border-radius: var(--rdt-colvis-summary-radius, 4px);
  list-style: none;
  user-select: none;
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-menu[data-rozie-s-d5dcab4c] {
  position: absolute;
  z-index: var(--rdt-colvis-menu-z, 5);
  margin-top: var(--rdt-colvis-menu-gap, 0.25rem);
  padding: var(--rdt-colvis-menu-padding, 0.4rem 0.6rem);
  display: flex;
  flex-direction: column;
  gap: var(--rdt-colvis-item-gap, 0.25rem);
  border: var(--rdt-colvis-menu-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-colvis-menu-radius, 4px);
  background: var(--rdt-colvis-menu-bg, #fff);
  box-shadow: var(--rdt-colvis-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
}
.rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-colvis-item[data-rozie-s-d5dcab4c] {
  display: flex;
  align-items: center;
  gap: var(--rdt-colvis-label-gap, 0.4em);
  cursor: pointer;
  white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-td[data-rozie-s-d5dcab4c] {
  width: var(--rdt-select-col-width, 44px);
  text-align: var(--rdt-select-col-align, center);
  white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-expander-th[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-expander-td[data-rozie-s-d5dcab4c] {
  width: var(--rdt-expander-col-width, 40px);
  text-align: var(--rdt-expander-col-align, center);
  white-space: nowrap;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-all[data-rozie-s-d5dcab4c],
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-select-row[data-rozie-s-d5dcab4c] {
  cursor: pointer;
  accent-color: var(--rdt-select-accent, currentColor);
}
`;

  /**
   * The row data — `model: true`, so a committed cell/row edit writes a **fresh** array back through `r-model:data` (uncontrolled fallback `dataDefault`). A stable reference per Rozie's setup-once model — fed directly into table-core (never map/cloned in the watcher).
   * @example
   * <DataTable r-model:data="rows" :columns="cols" />
   */
  @property({ type: Array, attribute: 'data' }) _data_attr!: any[];
  private _dataControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'data-change', defaultValue: [], initialControlledValue: undefined });
  /**
   * Config-array column fallback (lower precedence than `<Column>` children). Each entry: `{ id?, field, header?, sortable?, filterable?, pinned?, width? }`. Columns may come from this array, from `<Column>` children, or both (id-keyed last-write-wins union).
   */
  @property({ type: Array }) columns: any[] = [];
  /**
   * Row-selection mode: `'none'` | `'single'` | `'multiple'`. `'multiple'` auto-injects a leading checkbox column with a select-all header.
   */
  @property({ type: String, reflect: true }) selectionMode: string = 'none';
  /**
   * `SortingState` — `[{ id, desc }]`. Uncontrolled fallback when unbound. Two-way: writes funnel a fresh value through the `sort-change` event regardless of binding.
   */
  @property({ type: Array, attribute: 'sorting' }) _sorting_attr: any[] = [];
  private _sortingControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'sorting-change', defaultValue: [], initialControlledValue: undefined });
  /**
   * The global search string — narrows all columns. Feeds `getFilteredRowModel()`. Surfaces through `filter-change`. Two-way: fires `filter-change` regardless of binding.
   */
  @property({ type: String, attribute: 'global-filter' }) _globalFilter_attr: string = '';
  private _globalFilterControllable = createLitControllableProperty<string>({ host: this, eventName: 'global-filter-change', defaultValue: '', initialControlledValue: undefined });
  /**
   * `ColumnFiltersState` — `[{ id, value }]` per-column narrowing (gated by each column's `filterable`). Two-way: whole-array replace on write, fires `filter-change`.
   */
  @property({ type: Array, attribute: 'column-filters' }) _columnFilters_attr: any[] = [];
  private _columnFiltersControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'column-filters-change', defaultValue: [], initialControlledValue: undefined });
  /**
   * `{ pageIndex, pageSize }`. Defaults to `{ pageIndex: 0, pageSize: 10 }`; feeds the prev/next + page-size chrome (and `getPaginationRowModel()`). Two-way: funnels a fresh object through `page-change`.
   */
  @property({ type: Object, attribute: 'pagination' }) _pagination_attr: any = {
  pageIndex: 0,
  pageSize: 10
};
  private _paginationControllable = createLitControllableProperty<any>({ host: this, eventName: 'pagination-change', defaultValue: {
  pageIndex: 0,
  pageSize: 10
}, initialControlledValue: undefined });
  /**
   * Server-side hook: sets `manualPagination` / `manualFiltering` / `manualSorting` so table-core trusts the consumer-supplied rows and only emits the change events (the consumer fetches each page).
   */
  @property({ type: Boolean, reflect: true }) manual: boolean = false;
  /**
   * Total server-side row count for `manual` pagination; lets the table compute page count when it doesn't hold the full dataset.
   */
  @property({ type: Number, reflect: true }) rowCount: number | null = null;
  /**
   * Explicit total page count for `manual` pagination; overrides rowCount-derived count.
   */
  @property({ type: Number, reflect: true }) pageCount: number | null = null;
  /**
   * Opt-in **expandable rows**. When `true`, a leading chevron expander column auto-injects (after the select column) and `getExpandedRowModel` activates; default `false` is byte-identical-off. Every row can expand to reveal a `#detail` panel unless `getSubRows` is supplied (then only rows with children expand). Bind `:expandable="true"` (a bare attr only coerces on Vue+Lit).
   */
  @property({ type: Boolean, reflect: true }) expandable: boolean = false;
  /**
   * `ExpandedState` — `{ [rowId]: true }`, or the `true` literal after `expandAll` (declared `type: [Object, Boolean]`). Multi-expand (multiple rows open at once). Surfaces through `expand-change`; uncontrolled fallback (`$data.expandedDefault`) when unbound — the default is `null` so the uncontrolled fallback AND the grouping auto-expand default are reachable (a non-null default would short-circuit them). When grouping is active and `expanded` is untouched, group subtrees auto-expand.
   */
  @property({ type: Object, attribute: 'expanded' }) _expanded_attr: any | boolean = null;
  private _expandedControllable = createLitControllableProperty<any | boolean>({ host: this, eventName: 'expanded-change', defaultValue: null, initialControlledValue: undefined });
  /**
   * Table-level child-row accessor `(originalRow, index) => TData[] | undefined` that drives nested sub-rows. When supplied (with `expandable`), table-core flattens the hierarchy and the expand seam reveals depth-indented child rows. Null → the `#detail` scoped slot is the expand mode.
   */
  @property({ type: Function }) getSubRows: ((...args: any[]) => any) | null = null;
  /**
   * Opt-in gate for the **headless `#groupBar`** host region. Default `false` is byte-identical-off. `getGroupedRowModel` is wired unconditionally (inert when `grouping` is empty), so grouping is driven by the `grouping` model; this flag only gates the consumer-facing group-bar surface (the component ships **no** built-in drag UI).
   */
  @property({ type: Boolean, reflect: true }) groupable: boolean = false;
  /**
   * `GroupingState` — an ordered `string[]` of column ids (multi-column → nested groups, e.g. `['region','category']`). An empty/unbound list is ungrouped (byte-identical-off). Group-header rows are collapsible (they ride the expand model). Surfaces through `group-change`; uncontrolled fallback (`$data.groupingDefault`, default `[]`) when unbound — the default is `null` (mirroring `expanded`) so the uncontrolled fallback is reachable and the grouping auto-expand default can activate when a consumer applies grouping without binding `r-model:grouping` (a non-null `[]` default would short-circuit it). All reads are null-guarded, so table-core still receives an array.
   */
  @property({ type: Array, attribute: 'grouping' }) _grouping_attr: any[] | null = null;
  private _groupingControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'grouping-change', defaultValue: null, initialControlledValue: undefined });
  /**
   * `RowSelectionState` — `{ [rowId]: true }`. Checkbox-only toggle (the row body does not select). Driven by the `selectionMode` chrome. Two-way: fires `selection-change` regardless of binding.
   */
  @property({ type: Object, attribute: 'row-selection' }) _rowSelection_attr: any = {};
  private _rowSelectionControllable = createLitControllableProperty<any>({ host: this, eventName: 'row-selection-change', defaultValue: {}, initialControlledValue: undefined });
  /**
   * `VisibilityState` — `{ [colId]: boolean }`. Hidden columns drop automatically from header + body. Two-way: funnels a fresh object through `visibility-change`.
   */
  @property({ type: Object, attribute: 'column-visibility' }) _columnVisibility_attr: any = {};
  private _columnVisibilityControllable = createLitControllableProperty<any>({ host: this, eventName: 'column-visibility-change', defaultValue: {}, initialControlledValue: undefined });
  /**
   * `ColumnSizingState` — `{ [colId]: number }`. Driven live by the pointer-drag resize handle (`columnResizeMode: 'onChange'`). Two-way: fires `resize-change`.
   */
  @property({ type: Object, attribute: 'column-sizing' }) _columnSizing_attr: any = {};
  private _columnSizingControllable = createLitControllableProperty<any>({ host: this, eventName: 'column-sizing-change', defaultValue: {}, initialControlledValue: undefined });
  /**
   * `ColumnOrderState` — `string[]`. A fresh order array on reorder (never an in-place splice). Two-way: fires `reorder-change`.
   */
  @property({ type: Array, attribute: 'column-order' }) _columnOrder_attr: any[] = [];
  private _columnOrderControllable = createLitControllableProperty<any[]>({ host: this, eventName: 'column-order-change', defaultValue: [], initialControlledValue: undefined });
  /**
   * `ColumnPinningState` — `{ left: string[], right: string[] }`. Pinned columns get `position: sticky` + computed offsets. Defaults to `{ left: [], right: [] }`. Two-way: fires `pin-change`.
   */
  @property({ type: Object, attribute: 'column-pinning' }) _columnPinning_attr: any = {
  left: [],
  right: []
};
  private _columnPinningControllable = createLitControllableProperty<any>({ host: this, eventName: 'column-pinning-change', defaultValue: {
  left: [],
  right: []
}, initialControlledValue: undefined });
  /**
   * Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
   */
  @property({ type: Boolean, reflect: true }) stickyHeader: boolean = false;
  /**
   * `'table'` (default, row-oriented, byte-behaviorally identical to a plain accessible table) | `'grid'` (GA since Phase 63) — lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)**: `role="grid"`, a roving single tab-stop, 2-D APG arrow-key cell navigation, range selection, and clipboard support.
   */
  @property({ type: String, reflect: true }) interactionMode: string = 'table';
  /**
   * Grid mode only. When `true`, a plain click on an **editable** cell opens its editor immediately (single-click-to-edit) instead of just activating the cell. Default `false` keeps click-to-activate (double-click opens the editor). Shift+click (range selection) and clicks on non-editable cells are unaffected.
   */
  @property({ type: Boolean, reflect: true }) singleClickEdit: boolean = false;
  /**
   * Grid mode. When `true`, every committed data mutation (cell/row edit, paste, fill, cut, clear) becomes one undo step: Ctrl/Cmd+Z undoes, Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z redoes. Default `false` records no history and Ctrl+Z/Y are inert.
   */
  @property({ type: Boolean, reflect: true }) undoable: boolean = false;
  /**
   * The maximum number of undo steps retained (oldest evicted past this depth). Only consulted when `undoable` is `true`.
   */
  @property({ type: Number, reflect: true }) undoLimit: number = 100;
  /**
   * Opt-in vertical **row windowing**. When `true`, only the visible slice of rows renders inside a bounded `rdt-scroll` container (with leading/trailing spacer rows preserving total scroll height), windowing over the full filtered + sorted (pre-pagination) model and suppressing the client pagination chrome. Default `false` is byte-identical to a non-virtual table.
   */
  @property({ type: Boolean, reflect: true }) virtual: boolean = false;
  /**
   * Estimated row height (px) seeding the windowing engine before `measureElement` refines actual heights. Only consulted when `virtual` is on.
   */
  @property({ type: Number, reflect: true }) estimateRowHeight: number = 40;
  /**
   * A CSS length string bounding the `rdt-scroll` container when `virtual` is on (e.g. `'400px'`). Mirrored to the `--rozie-data-table-max-height` custom property; the prop wins, the token is the fallback.
   */
  @property({ type: String, reflect: true }) maxHeight: string = '';
  private _dataDefault = signal<any[]>([]);
  private _sortingDefault = signal<any[]>([]);
  private _globalFilterDefault = signal('');
  private _columnFiltersDefault = signal<any[]>([]);
  private _paginationDefault = signal({
  pageIndex: 0,
  pageSize: 10
});
  private _rowSelectionDefault = signal<any>({});
  private _expandedDefault = signal<any>({});
  private _groupingDefault = signal<any[]>([]);
  private _columnVisibilityDefault = signal<any>({});
  private _columnSizingDefault = signal<any>({});
  private _columnOrderDefault = signal<any[]>([]);
  private _columnPinningDefault = signal({
  left: [],
  right: []
});
  private _columnSizingInfo = signal({
  startOffset: null,
  startSize: null,
  deltaOffset: null,
  deltaPercentage: null,
  isResizingColumn: false,
  columnSizingStart: []
});
  private _colReg = signal<any>({});
  private _rows = signal<any[]>([]);
  private _headerGroups = signal<any[]>([]);
  private _rowModelVer = signal(0);
  private _windowVer = signal(0);
  private _activeRow = signal(0);
  private _activeColIndex = signal(0);
  private _activeIsHeader = signal(false);
  private _activeHeaderLevel = signal(0);
  private _activeInControl = signal(false);
  private _editingRow = signal(-1);
  private _editingCol = signal(-1);
  private _draftValue = signal<any>(null);
  private _invalidMsg = signal('');
  private _editVer = signal(0);
  private _editFocusColId = signal<any>(null);
  private _editingRowIndex = signal<any>(null);
  private _rowDraft = signal<any>({});
  private _rangeAnchor = signal<any>(null);
  private _rangeFocus = signal<any>(null);
  private _pasteAnnounce = signal('');
  private _liveAnnounce = signal('');
  @query('[data-rozie-ref="__rozieRoot"]') private _ref__rozieRoot!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { context: __rozieCtx_data_table_columns, initialValue: ((__rozieCtxHost) => ({
  registerColumn: (id: any, spec: any) => {
    if (id == null) return;
    const key = String(id);
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
    __rozieCtxHost._colReg.value = {
      ...__rozieCtxHost._colReg.value,
      [key]: spec
    };
  },
  unregisterColumn: (id: any) => {
    if (id == null) return;
    const r = {
      ...__rozieCtxHost._colReg.value
    };
    delete r[String(id)];
    __rozieCtxHost._colReg.value = r;
  }
}))(this) });

  private _rozieSlotDistributor = new RozieSlotDistributor(this);

  @state() private _hasSlotDefault = false;
  @queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
  @state() private _hasSlotGroupBar = false;
  @queryAssignedElements({ slot: 'groupBar', flatten: true }) private _slotGroupBarElements!: Element[];
  @property({ attribute: false }) groupBar?: (scope: { grouping: any; groupableColumns: any; applyGrouping: any; clearGrouping: any }) => unknown;
  @state() private _hasSlotSelectAll = false;
  @queryAssignedElements({ slot: 'selectAll', flatten: true }) private _slotSelectAllElements!: Element[];
  @property({ attribute: false }) selectAll?: (scope: { checked: any; indeterminate: any; toggle: any }) => unknown;
  @state() private _hasSlotColHeader = false;
  @queryAssignedElements({ slot: 'colHeader', flatten: true }) private _slotColHeaderElements!: Element[];
  @property({ attribute: false }) colHeader?: (scope: { columnId: any; column: any; label: any }) => unknown;
  @state() private _hasSlotFilter = false;
  @queryAssignedElements({ slot: 'filter', flatten: true }) private _slotFilterElements!: Element[];
  @property({ attribute: false }) filter?: (scope: { columnId: any; value: any; uniqueValues: any; minMax: any; setFilter: any }) => unknown;
  @state() private _hasSlotSelectCell = false;
  @queryAssignedElements({ slot: 'selectCell', flatten: true }) private _slotSelectCellElements!: Element[];
  @property({ attribute: false }) selectCell?: (scope: { row: any; checked: any; toggle: any }) => unknown;
  @state() private _hasSlotCell = false;
  @queryAssignedElements({ slot: 'cell', flatten: true }) private _slotCellElements!: Element[];
  @property({ attribute: false }) cell?: (scope: { columnId: any; column: any; row: any; value: any }) => unknown;
  @state() private _hasSlotEditor = false;
  @queryAssignedElements({ slot: 'editor', flatten: true }) private _slotEditorElements!: Element[];
  @property({ attribute: false }) editor?: (scope: { columnId: any; column: any; row: any; value: any; commit: any; cancel: any; autofocus: any }) => unknown;
  @state() private _hasSlotDetail = false;
  @queryAssignedElements({ slot: 'detail', flatten: true }) private _slotDetailElements!: Element[];
  @property({ attribute: false }) detail?: (scope: { row: any }) => unknown;
  // Phase 79 Plan 08 (R4) contract for 79-09: the record intake for
  // record-routed slot fills. 79-09's consumer-side emitSlotFiller
  // accumulates an object literal onto the SAME `.rozieSlots=${{ ... }}`
  // open-tag binding; the KEY is the fill's authored (possibly
  // non-identifier) name and the VALUE is a scope-taking render
  // function. `rozieSlots?.[name]` must be checked BEFORE the legacy
  // named function-prop / <slot> fallback (AC-9). Attribute
  // deserialization is disabled — this is a function-valued record,
  // never reflected to/from an HTML attribute.
  @property({ attribute: false }) rozieSlots?: Record<string, (scope: any) => unknown>;

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  private _armListeners(): void {
    {
      const slotEl = this.shadowRoot?.querySelector('slot:not([name])');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotDefault = this._slotDefaultElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="groupBar"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotGroupBar = this._slotGroupBarElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="selectAll"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotSelectAll = this._slotSelectAllElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="colHeader"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotColHeader = this._slotColHeaderElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="filter"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotFilter = this._slotFilterElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="selectCell"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotSelectCell = this._slotSelectCellElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="cell"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotCell = this._slotCellElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="editor"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotEditor = this._slotEditorElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }

    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="detail"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotDetail = this._slotDetailElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }
  }

  connectedCallback(): void {
    // Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
    this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
    this._hasSlotGroupBar = Array.from(this.children).some((el) => el.getAttribute('slot') === 'groupBar');
    this._hasSlotSelectAll = Array.from(this.children).some((el) => el.getAttribute('slot') === 'selectAll');
    this._hasSlotColHeader = Array.from(this.children).some((el) => el.getAttribute('slot') === 'colHeader');
    this._hasSlotFilter = Array.from(this.children).some((el) => el.getAttribute('slot') === 'filter');
    this._hasSlotSelectCell = Array.from(this.children).some((el) => el.getAttribute('slot') === 'selectCell');
    this._hasSlotCell = Array.from(this.children).some((el) => el.getAttribute('slot') === 'cell');
    this._hasSlotEditor = Array.from(this.children).some((el) => el.getAttribute('slot') === 'editor');
    this._hasSlotDetail = Array.from(this.children).some((el) => el.getAttribute('slot') === 'detail');
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    this._armListeners();

    this._disconnectCleanups.push(effect(() => { const __watchVal = (() => [this.sorting, this.globalFilter, this.columnFilters, this.pagination, // Server-side page-count sources (#2): re-feed when the consumer's rowCount/pageCount
    // changes at runtime (e.g. a server response updates the total) so getPageCount() and the
    // Next button availability track the new total.
    this.rowCount, this.pageCount, this.rowSelection, this.expanded, this.expandable, this.grouping, this.groupable, this.columnVisibility, this.columnSizing, this.columnOrder, this.columnPinning, this.selectionMode, (this.data || []).length, // Phase 51 req-4: key on the data REFERENCE (both sinks) so a committed edit re-feeds
    // even when the fresh array is the SAME length (a single-cell edit replaces one row
    // object → new array ref, identical length → the .length key alone would miss it). The
    // controlled path observes $props.data; the uncontrolled path observes $data.dataDefault.
    // writeData is echo-guarded (programmatic) and reFeed writes neither sink, so no loop.
    this.data, this._dataDefault.value, // Column CONFIG prop (the `:columns` array form) — the sibling column source to
    // $data.colReg (the `<Column>` children). Watch it so a runtime `:columns` swap re-feeds:
    // columnDefs()/tableColumns() build the UNION of both, and reFeed re-passes columns.
    // (Consumers memoize the array as with $props.data/$props.sorting; the uncontrolled
    // <Column>-children path leaves $props.columns undefined — a stable no-op getter.)
    this.columns, this._colReg.value])(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
      this.reFeed();
      this.maybeClearHistoryOnExternalSwap();
    })(); }); }));
    this._disconnectCleanups.push(effect(() => { const __watchVal = (() => [this.sorting, this.columnFilters, this.globalFilter, this._sortingDefault.value, this._columnFiltersDefault.value, this._globalFilterDefault.value])(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => {
      const msg = this.buildSortFilterAnnounce();
      if (msg) this._liveAnnounce.value = msg;
    })(); }); }));

    this._disconnectCleanups.push(effect(() => { void this._colReg.value; this.__rozieCtxProvider_data_table_columns.setValue(((__rozieCtxHost) => ({
      registerColumn: (id: any, spec: any) => {
        if (id == null) return;
        const key = String(id);
        if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
        __rozieCtxHost._colReg.value = {
          ...__rozieCtxHost._colReg.value,
          [key]: spec
        };
      },
      unregisterColumn: (id: any) => {
        if (id == null) return;
        const r = {
          ...__rozieCtxHost._colReg.value
        };
        delete r[String(id)];
        __rozieCtxHost._colReg.value = r;
      }
    }))(this)); }));

    // Seed the uncontrolled `data` fallback (Phase 51 req-4) from the initial prop so an
    // edit committed BEFORE the consumer ever pushes new rows (or when the consumer passes
    // a one-way `:data`) has a base array to whole-array-replace. currentData() then sources
    // the bound prop when controlled, this fallback otherwise.
    this._dataDefault.value = this.data || [];
    // Build the table instance HERE so the closures below capture the live `table`.
    // Build the table instance HERE so the closures below capture the live `table`.
    this.table = createTable({
      // Plain value (NOT a `get data()` getter): an object-literal getter rebinds
      // `this` to the options object, and the Angular/Lit emitters resolve $props via
      // `this.data` — so `get data() { return $props.data }` lowers to `this.data`
      // re-entering the getter → infinite recursion (max call stack). `data` is re-fed
      // on every change by the watch's setOptions below, exactly like columns/state, so
      // the getter bought nothing. Snapshot the initial data here; setOptions owns updates.
      // currentData() = the bound prop when controlled, else the uncontrolled $data.dataDefault
      // (Phase 51 req-4 — so a committed edit's writeData re-feed is observed either way).
      data: this.currentData(),
      columns: this.tableColumns(),
      state: this.currentState(),
      getCoreRowModel: getCoreRowModel(),
      getSortedRowModel: getSortedRowModel(),
      getFilteredRowModel: getFilteredRowModel(),
      getPaginationRowModel: getPaginationRowModel(),
      // Expandable rows (phase 50, D-04): the expanded row model is supplied UNCONDITIONALLY
      // (mirrors the other models) — inert when `expanded` is empty + no getSubRows
      // (byte-identical-off, req-10). getSubRows is the TABLE-level child accessor (NOT a
      // ColumnDef field). getRowCanExpand makes EVERY row expandable for the #detail seam
      // (no subRows to gate on); when getSubRows IS supplied, leave it undefined so the
      // default `!!subRows.length` rule applies (only parents with children expand).
      getExpandedRowModel: getExpandedRowModel(),
      getSubRows: (this.getSubRows || undefined) as any,
      getRowCanExpand: this.expandable === true && this.getSubRows == null ? () => true : undefined,
      onExpandedChange: this.onExpandedChangeCb,
      // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
      // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
      // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
      // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
      // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
      // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
      // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
      // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
      // are unaffected (they fire regardless of this flag).
      autoResetExpanded: false,
      // Grouping (phase 50 reqs 4-7, D-04/D-05): the grouped row model is supplied
      // UNCONDITIONALLY (mirrors the expand model) — inert when `grouping` is empty
      // (byte-identical-off, req-10). When `grouping` is a non-empty ordered key list,
      // table-core FLATTENS group-header rows (carrying getIsGrouped()/subRows) and their
      // members into getRowModel().rows, so they ride the SAME D-04 <template r-for> seam (no
      // nested r-for — Pitfall 1). Group rows are expandable via the EXISTING expanded model
      // (getRowCanExpand default `!!subRows.length`), so collapsing a group hides its subtree.
      getGroupedRowModel: getGroupedRowModel(),
      onGroupingChange: this.onGroupingChangeCb,
      // Faceted filtering (phase 50 reqs 8-9, D-03): the 3 faceted models are supplied
      // UNCONDITIONALLY (mirrors the expand/group models) — INERT until a consumer reads a
      // column facet (the getFaceted* verbs / #filter slot), so byte-identical-off holds (req-10).
      // The default getFacetedUniqueValues/getFacetedMinMaxValues impls are cross-filtered (D-03).
      getFacetedRowModel: getFacetedRowModel(),
      getFacetedUniqueValues: makeFacetedUniqueValues(),
      getFacetedMinMaxValues: makeFacetedMinMaxValues(),
      // Server-side hook (req-6): when `manual` is set, table-core trusts the consumer's
      // rows verbatim (no client-side filter/sort/paginate) and only emits the change
      // events so the consumer can fetch the next page/filtered slice.
      manualPagination: this.manual === true,
      manualFiltering: this.manual === true,
      manualSorting: this.manual === true,
      // Server-side page-count sources (#2): pass the consumer-supplied total row count and/or
      // explicit page count so table-core can compute getPageCount() under `manual` (where it
      // does not hold the full dataset). undefined when unset → table-core auto-derives from the
      // loaded data (client-pagination path byte-unchanged). Precedence is table-core's: explicit
      // pageCount wins, else ⌈rowCount / pageSize⌉, else auto. With a real count getCanNextPage()
      // becomes true, so a server-pagination consumer can leave page 0.
      rowCount: this.rowCount ?? undefined,
      pageCount: this.pageCount ?? undefined,
      // Row selection (req-7): enabled unless 'none'; 'single' caps at ≤1
      // (enableMultiRowSelection:false). Select-all scope = filtered rows (TanStack
      // default, D-06 — NOT overridden).
      enableRowSelection: this.selectionMode !== 'none',
      enableMultiRowSelection: this.selectionMode === 'multiple',
      // PER-SLICE callbacks (Open-Q1: each maps 1:1 to a slice's r-model + change event,
      // no global onStateChange diff) — hoisted top-level consts, re-passed by the re-feed
      // $watch so React reads fresh currentState (the stale-closure fix, F6).
      onSortingChange: this.onSortingChangeCb,
      onGlobalFilterChange: this.onGlobalFilterChangeCb,
      onColumnFiltersChange: this.onColumnFiltersChangeCb,
      onPaginationChange: this.onPaginationChangeCb,
      onRowSelectionChange: this.onRowSelectionChangeCb,
      onColumnVisibilityChange: this.onColumnVisibilityChangeCb,
      onColumnSizingChange: this.onColumnSizingChangeCb,
      onColumnOrderChange: this.onColumnOrderChangeCb,
      onColumnPinningChange: this.onColumnPinningChangeCb,
      onColumnSizingInfoChange: this.onColumnSizingInfoChangeCb,
      // Resize mode: 'onChange' so the bound columnSizing model updates live during the
      // drag (the behavioral width-delta assertion observes the in-progress width). Column
      // resizing is enabled at the table level; per-column opt-out is via the ColumnDef.
      columnResizeMode: 'onChange',
      enableColumnResizing: true,
      renderFallbackValue: null,
      // table-core's RESOLVED options type (TableOptionsResolved) requires a global
      // onStateChange + renderFallbackValue; we drive state via the per-slice on<Slice>Change
      // callbacks above, so the global hook is a no-op. Present so the createTable() argument
      // satisfies the strict bundled-leaf tsc (deferred-items strict-tsc #2 close).
      onStateChange: () => {}
    });
    this.refreshRowModel = () => {
      if (!this.table) return;
      // Capture fresh locals; never write a $data key then re-read it in the same fn
      // (ROZ138 / React stale-read — setState is async on React, the closure binds the
      // PRE-write value).
      // windowSource(): the FULL pre-pagination model when virtual (windowing replaces client
      // pagination, req-9), else the normal paginated row model (non-virtual path byte-unchanged).
      const nextRows = this.windowSource().slice();
      const nextGroups = this.table.getHeaderGroups().slice();
      this._rows.value = nextRows;
      this._headerGroups.value = nextGroups;
      this._rowModelVer.value = this._rowModelVer.value + 1;
      // Vertical windowing re-feed (Pitfall 2 — stale count): push the fresh full-model count
      // into the virtualizer + reconcile IMPERATIVELY here (the table.setOptions re-feed path),
      // NEVER in a render helper (Pitfall 1). Pass the COMPLETE options set (virtual-core's
      // setOptions replaces, not merges). Guarded so the off path executes no virtual-core code.
      if (this.virtual && this.virtualizer) {
        this.virtualizer.setOptions(this.virtualizerOptions());
        this.virtualizer._willUpdate();
      }
      // D-05: on every data change (re-sort/filter/paginate/page-size — all re-pull here),
      // clamp the active cell to the new bounds (same indices, clamped if the grid shrank;
      // no row-id following, no top-bounce). isGrid()-gated so 'table' mode is untouched.
      // B8/B23: pass the FRESH bounds derived from `nextRows` (NOT $data.rows, which is the
      // async-stale useState snapshot on React) so a filter-to-fewer clamps the active cell AND
      // the range corners on React too — never re-reading the pre-change model.
      const nextRowCount = nextRows.length;
      const nextColCount = nextRows.length ? nextRows[0].getVisibleCells().length : nextGroups.length ? (nextGroups[nextGroups.length - 1].headers || []).length : 0;
      this.clampActiveCell(nextRowCount, nextColCount);
      // #4: clamp a pageIndex that now points PAST the last page. When the consumer holds
      // pagination.pageIndex (controlled) and shrinks the data (filter / replace) so there are
      // fewer pages, the body renders blank ("Page 6 of 3" with Next disabled). Read table-core's
      // LIVE post-re-derive state: getPageCount() is the fresh count (now correct under `manual`
      // too, #2) and getState().pagination is the just-fed state. Funnel the correction through
      // writePagination (the single-emit + two-way-model funnel) so the consumer's controlled
      // pagination prop converges to the last valid page (page-change carries { pageIndex, pageSize }).
      //   • pc > 0 skips the manual-WITHOUT-count case (getPageCount() === -1) — never clamp toward
      //     an unknown total.
      //   • LOOP-GUARD: emit ONLY when the clamped index actually differs. After the consumer echoes
      //     the clamp back through the pagination prop, the re-feed re-enters here with
      //     pageIndex === pc - 1, so `pageIndex > pc - 1` is false → no re-emit; a consumer that
      //     ignores the event triggers no further re-feed, so it stays a single emit either way.
      //   • No fight with table-core's autoResetPageIndex: that reset only fires on table-core's OWN
      //     setX mutations, which this fully-controlled-state architecture never calls (filters/data
      //     flow through setOptions), so reading the live state here can only fire on a genuine
      //     overflow — if the index is already valid we stay silent (uncontrolled self-heals too,
      //     writing paginationDefault, with no regression since table-core does not auto-clamp here).
      const pgState = this.table.getState().pagination;
      const pc = this.table.getPageCount();
      if (pc > 0 && pgState.pageIndex > pc - 1) {
        this.writePagination({
          pageIndex: pc - 1,
          pageSize: pgState.pageSize
        });
      }
      // B23: a just-committed single-cell edit may have RELOCATED its row under an active sort/
      // filter. `nextRows` is the FRESH visible model (its index space == the rendered data-row
      // indices), so resolve the committed row's NEW index by identity HERE (never from the React-
      // stale state) and re-seat focus on that cell via the DOM-only poll (focusCellWhenReady reads
      // gridRoot only → React-safe). Consumed ONCE (cleared) so a multi-render re-feed focuses once;
      // a no-relocation commit resolves the same index → byte-behaviorally identical to before.
      if (this.pendingEditFollow && this.isGrid()) {
        const follow = this.pendingEditFollow;
        this.pendingEditFollow = null;
        const followIdx = this.indexOfRowIn(nextRows, follow.rowOriginal, follow.rowId);
        if (followIdx >= 0) this.focusCellWhenReady(followIdx, follow.col);
      }
      // keep the select-all checkbox's `indeterminate` DOM property in lockstep with the
      // selection state (bound :indeterminate is inert on 5/6 targets). The box persists
      // across selection changes; a microtask defer covers React's post-render DOM patch.
      this.syncIndeterminate();
      if (typeof queueMicrotask !== 'undefined') queueMicrotask(this.syncIndeterminate);else Promise.resolve().then(this.syncIndeterminate);
    };

    // initial pull
    // initial pull
    this.refreshRowModel();

    // ── Grid mode: capture the table root ──────────────────────────────────────────────
    // $el is the component root; the <table class="rozie-data-table"> is the grid root the
    // cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
    // (post-mount) so it is non-null and ROZ123-clean.
    // ── Grid mode: capture the table root ──────────────────────────────────────────────
    // $el is the component root; the <table class="rozie-data-table"> is the grid root the
    // cell selectors hang off (the exact idiom proven ×6 by plan 01's probe). Captured here
    // (post-mount) so it is non-null and ROZ123-clean.
    this.gridRoot = this._ref__rozieRoot ? this._ref__rozieRoot.querySelector('.rozie-data-table') : null;
    // WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
    // page load AND was non-deterministic on React/Solid (the entry cell may not be
    // committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
    // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
    // mount"); the consumer drives focus by Tabbing/clicking in, never the component.

    // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
    // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
    // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
    // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
    // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
    // WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
    // page load AND was non-deterministic on React/Solid (the entry cell may not be
    // committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
    // cell IS the first Tab-in target (matching the Wave-0 probe's "no auto-focus on
    // mount"); the consumer drives focus by Tabbing/clicking in, never the component.

    // ── Vertical windowing: construct the virtualizer (req-1/2 — ONLY when virtual) ───────
    // Built HERE (post-mount) so getScrollElement resolves the rendered .rdt-scroll div and
    // getPrePaginationRowModel reads the live table. ENTIRELY inside the $props.virtual guard:
    // when off, NO virtual-core runtime code executes (byte-identical-off). _didMount() registers
    // the scroll-element ResizeObserver and returns the teardown stored for $onUnmount.
    if (this.virtual) {
      this.gridScrollEl = this._ref__rozieRoot ? this._ref__rozieRoot.querySelector('.rdt-scroll') : null;
      this.virtualizer = new Virtualizer(this.virtualizerOptions());
      this.virtualizerCleanup = this.virtualizer._didMount();
      // FINE-GRAINED FIRST-WINDOW KICK (Solid/Svelte): the windowed <For>/{#each} accessor was first
      // evaluated at initial render — while `virtualizer` was still null — and (because windowedRows()
      // reads $data.windowVer up top) subscribed to windowVer then returned []. `virtualizer` is a
      // non-reactive `let`, so its assignment above does NOT notify the accessor; we must bump the
      // SIGNAL it subscribed to. _didMount() computes the first window synchronously but its onChange
      // only fires on SUBSEQUENT scroll/resize, so without this explicit bump the first window would
      // never paint on the fine-grained targets. Idempotent + harmless on the coarse targets (they
      // re-render wholesale anyway). One bump = one re-run that now sees the non-null virtualizer and
      // pulls getVirtualItems().
      this._windowVer.value = this._windowVer.value + 1;
      // After the first window commits (next frame), refine heights + fire the dev-mode warns
      // ONCE. Entirely inside the $props.virtual guard so the virtual=false emitted path adds NO
      // code and these warns can never fire there (req-1 byte-identical-off preserved).
      const afterFirstFrame = () => {
        // D-10: measure the rendered rows.
        this.remeasureWindow();
        // D-08/A1: a dev-mode runtime warn when the scroll container has no bounded height (the
        // bound may come from consumer CSS the compiler can't see — no compile diagnostic). No
        // process.env guard (not bundler-portable); always-warn-on-misconfig is acceptable.
        const h = this.gridScrollEl ? this.gridScrollEl.clientHeight : 0;
        if (!h) {
          console.warn('[rozie-data-table] virtual is on but the scroll container has no bounded height; set maxHeight or --rozie-data-table-max-height');
        }
        // D-07 (RESOLVED — runtime warn, not a compile diagnostic): warn ONCE when the consumer
        // CONFIGURED client pagination alongside virtual, in the non-manual case (the valid
        // virtual+manual combo per D-09 is silent). The pagination prop carries a non-null default
        // ({ pageIndex: 0, pageSize: 10 }) so it is never strictly null — "configured" is therefore
        // detected as a pagination that DIFFERS from that default (a consumer who set a real page
        // size / index). The uncontrolled default ({0,10}) does NOT trip the warn. Behavior + the
        // virtual=false path are untouched (this lives entirely inside the $props.virtual guard).
        const pg = this.pagination;
        const pgConfigured = pg != null && !(pg.pageIndex === 0 && pg.pageSize === 10);
        if (this.manual !== true && pgConfigured) {
          console.warn('[rozie-data-table] virtual+pagination: client pagination is configured but virtual windowing replaces it — the pagination chrome is auto-suppressed. Remove the pagination prop or set manual to silence this.');
        }
      };
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(afterFirstFrame));else setTimeout(afterFirstFrame, 0);
    }

    // #14: seed the sort/filter announce baseline from the initial (post-mount) state so the LAZY
    // watch's first fire — a real user sort/filter — compares against the true starting values and
    // is classified correctly (a null sentinel would misread the first filter change as a sort change).
    // #14: seed the sort/filter announce baseline from the initial (post-mount) state so the LAZY
    // watch's first fire — a real user sort/filter — compares against the true starting values and
    // is classified correctly (a null sentinel would misread the first filter change as a sort change).
    this.announceState.sorting = this.effectiveSorting();
    this.announceState.columnFilters = this.effectiveColumnFilters();
    this.announceState.globalFilter = this.effectiveGlobalFilter();
  }

  updated(changedProperties: Map<string, unknown>): void {
    this.maybeClearHistoryOnExternalSwap();
    if (!this.table) return;
    // Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
    // $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
    // bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
    // identical length) still re-feeds.
    // Phase 51 req-4: track currentData() (the bound prop OR the uncontrolled
    // $data.dataDefault) so a committed edit re-feeds on Lit whether or not r-model:data is
    // bound. Compare by reference AND length so a same-length single-cell edit (fresh array,
    // identical length) still re-feeds.
    const d = this.currentData() || [];
    if (d === this.lastData && d.length === this.lastDataLen) return;
    this.lastData = d;
    this.lastDataLen = d.length;
    this.reFeed();
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      () => {
        if (this.virtualizerCleanup) this.virtualizerCleanup();
        // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
        this.teardownFillDrag();
        // §6 (260709-3qt): remove any live drag-select document listeners on a mid-drag unmount.
        this.teardownRangeDrag();
      };
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  attributeChangedCallback(name: string, old: string | null, value: string | null): void {
    super.attributeChangedCallback(name, old, value);
    if (name === 'data') this._dataControllable.notifyAttributeChange(value as unknown as any[]);
    if (name === 'sorting') this._sortingControllable.notifyAttributeChange(value as unknown as any[]);
    if (name === 'global-filter') this._globalFilterControllable.notifyAttributeChange(value as unknown as string);
    if (name === 'column-filters') this._columnFiltersControllable.notifyAttributeChange(value as unknown as any[]);
    if (name === 'pagination') this._paginationControllable.notifyAttributeChange(value as unknown as any);
    if (name === 'expanded') this._expandedControllable.notifyAttributeChange(value as unknown as any | boolean);
    if (name === 'grouping') this._groupingControllable.notifyAttributeChange(value as unknown as any[]);
    if (name === 'row-selection') this._rowSelectionControllable.notifyAttributeChange(value as unknown as any);
    if (name === 'column-visibility') this._columnVisibilityControllable.notifyAttributeChange(value as unknown as any);
    if (name === 'column-sizing') this._columnSizingControllable.notifyAttributeChange(value as unknown as any);
    if (name === 'column-order') this._columnOrderControllable.notifyAttributeChange(value as unknown as any[]);
    if (name === 'column-pinning') this._columnPinningControllable.notifyAttributeChange(value as unknown as any);
  }

  render() {
    return html`

<div class="rozie-data-table-wrap" data-rozie-ref="__rozieRoot" data-rozie-s-d5dcab4c>

<div class="rdt-column-defs" style="display:none" aria-hidden="true" data-rozie-s-d5dcab4c><slot></slot></div>

${!!this._invalidMsg.value ? html`<div class="rdt-sr-live" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c>${this._invalidMsg.value}</div>` : nothing}${!!this._pasteAnnounce.value ? html`<div class="rdt-sr-live rdt-sr-paste" data-testid="paste-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c>${this._pasteAnnounce.value}</div>` : nothing}${!!this._liveAnnounce.value ? html`<div class="rdt-sr-live rdt-sr-sortfilter" data-testid="sortfilter-announce" role="status" aria-live="polite" aria-atomic="true" data-rozie-s-d5dcab4c>${this._liveAnnounce.value}</div>` : nothing}<div class="rdt-toolbar" data-rozie-s-d5dcab4c>
  <input class="rdt-global-filter" type="text" role="searchbox" aria-label="Search table" .value=${this.globalFilterValue()} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onGlobalFilterInput($event); }} data-rozie-s-d5dcab4c />
  
  ${this.allLeafColumns().length ? html`<details class="rdt-colvis" data-rozie-s-d5dcab4c>
    <summary class="rdt-colvis-summary" data-rozie-s-d5dcab4c>Columns</summary>
    <div class="rdt-colvis-menu" role="group" aria-label="Toggle columns" data-rozie-s-d5dcab4c>
      ${repeat<any>(this.allLeafColumns(), (lc, _idx) => lc.id, (lc, _idx) => html`<label class="rdt-colvis-item" data-rozie-s-d5dcab4c>
        <input class="rdt-colvis-checkbox" type="checkbox" ?checked=${lc.visible} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onToggleVisibility(lc.id); }} data-rozie-s-d5dcab4c />
        <span class="rdt-colvis-label" data-rozie-s-d5dcab4c>${rozieDisplay(lc.label)}</span>
      </label>`)}
    </div>
  </details>` : nothing}</div>


${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
  ${this.groupBar !== undefined ? this.groupBar({grouping: this.groupingKeys(), groupableColumns: this.groupableColumns(), applyGrouping: this.applyGrouping, clearGrouping: this.clearGrouping}) : html`<slot name="groupBar" data-rozie-params=${(() => { try { return JSON.stringify({grouping: this.groupingKeys(), groupableColumns: this.groupableColumns()}); } catch { return '{}'; } })()} @rozie-group-bar-apply-grouping=${($event: CustomEvent) => ((this.applyGrouping) as (...args: any[]) => any)($event.detail)} @rozie-group-bar-clear-grouping=${($event: CustomEvent) => ((this.clearGrouping) as (...args: any[]) => any)($event.detail)}>
    ${repeat<any>(this.groupingKeys(), (gk, _idx) => gk, (gk, _idx) => html`<span class="rdt-group-token" data-group-token="" data-rozie-s-d5dcab4c>${rozieDisplay(gk)}</span>`)}
  </slot>`}
</div>` : nothing}${this.virtual ? html`<div class="rdt-scroll" style=${rozieStyle(this.maxHeight ? 'max-height:' + this.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + this.maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c>
<table class="${Object.entries({ "rozie-data-table": true, 'rdt-sticky': this.stickyHeader }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.tableRole())} aria-rowcount=${rozieAttr(this.gridAriaRowCount())} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridKeyDown($event); }} @focusin=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridFocusOut($event); }} @mousedown=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridMouseDown($event); }} @dblclick=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridDblClick($event); }} @click=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridClick($event); }} data-rozie-s-d5dcab4c>
  <thead class="rdt-thead" role="rowgroup" data-rozie-s-d5dcab4c>
    ${repeat<any>(this._headerGroups.value, (hg, hgLevel) => hg.id, (hg, hgLevel) => html`<tr class="rdt-tr" role="row" aria-rowindex=${rozieAttr(hgLevel + 1)} data-rozie-s-d5dcab4c>
      ${repeat<any>(hg.headers, (header, _idx) => header.id, (header, _idx) => html`<th class="${Object.entries({ "rdt-th": true, 'rdt-select-th': this.isSelectColumn(header.column.id), 'rdt-expander-th': this.isExpanderColumn(header.column.id), 'rdt-th-resizing': this.columnIsResizing(header.column.id), 'rdt-cell-active': this.isActiveCell('__header', this.headerColIndexOf(hg, header), hgLevel) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="columnheader" data-col=${rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level=${rozieAttr(hgLevel)} colspan=${rozieAttr(header.colSpan > 1 ? header.colSpan : null)} data-col-index=${rozieAttr(this.headerColIndexOf(hg, header))} tabindex=${rozieAttr(this.cellTabindex('__header', this.headerColIndexOf(hg, header), hgLevel))} aria-sort=${rozieAttr(this.ariaSortFor(header.column.id))} style=${rozieStyle(this.thStyle(header.column.id))} data-rozie-s-d5dcab4c>
        ${this.isSelectColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.selectAll !== undefined ? this.selectAll({checked: this.isAllRowsSelected(), indeterminate: this.isSomeRowsSelected(), toggle: this.onToggleAllRows}) : html`<slot name="selectAll" data-rozie-params=${(() => { try { return JSON.stringify({checked: this.isAllRowsSelected(), indeterminate: this.isSomeRowsSelected()}); } catch { return '{}'; } })()} @rozie-select-all-toggle=${($event: CustomEvent) => ((this.onToggleAllRows) as (...args: any[]) => any)($event.detail)}>
            ${this.selectionMode === 'multiple' ? html`<input class="rdt-select-all" type="checkbox" aria-label="Select all rows" ?checked=${this.isAllRowsSelected()} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onToggleAllRows($event); }} data-rozie-s-d5dcab4c />` : nothing}</slot>`}
        </span>` : this.isExpanderColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${header.column.getCanSort && header.column.getCanSort() ? html`<button class="rdt-sort-btn" type="button" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c>
            <span class="rdt-header-label" data-rozie-s-d5dcab4c>
              ${this.colHeader !== undefined ? this.colHeader({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(header.column.id))}</slot>`}
            </span>
            <span class="rdt-sort-ind" aria-hidden="true" data-rozie-s-d5dcab4c>${rozieDisplay(this.sortIndicator(header.column.id))}</span>
          </button>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
            <span class="rdt-header-label" data-rozie-s-d5dcab4c>
              ${this.colHeader !== undefined ? this.colHeader({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(header.column.id))}</slot>`}
            </span>
          </span>`}<rozie-popover trigger="click" placement="bottom-end" strategy="fixed" .offset=${4} data-rozie-s-d5dcab4c><button class="rdt-col-menu-trigger" type="button" aria-label=${rozieAttr('Column options for ' + this.headerLabel(header.column.id))} data-rozie-s-d5dcab4c slot="anchor">⋯</button><div class="rdt-col-menu" role="menu" data-rozie-s-d5dcab4c>
              <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${this.columnPinSide(header.column.id) === 'left'} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>Pin left</button>
              <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${this.columnPinSide(header.column.id) === 'right'} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>Pin right</button>
              <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${!this.columnPinSide(header.column.id)} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>Unpin</button>
              <hr class="rdt-col-menu-sep" data-rozie-s-d5dcab4c />
              <button class="rdt-col-menu-item" type="button" role="menuitem" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onHideColumn(header.column.id, $event); }} data-rozie-s-d5dcab4c>Hide column</button>
            </div></rozie-popover>
          <button class="rdt-resize-handle" type="button" aria-label=${rozieAttr('Resize ' + this.headerLabel(header.column.id))} @pointerdown=${($event: PointerEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onResizeStart(header.column.id, $event); }} @touchstart=${($event: TouchEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-resize-grip" aria-hidden="true" data-rozie-s-d5dcab4c></span></button>
        </span>`}</th>`)}
    </tr>`)}
    
    ${this.hasAnyFilterableColumn() ? html`<tr class="rdt-filter-row" data-rozie-s-d5dcab4c>
      ${repeat<any>(this._headerGroups.value[this._headerGroups.value.length - 1].headers, (header, _idx) => header.id, (header, _idx) => html`<th class="rdt-filter-cell" role="presentation" style=${rozieStyle(this.pinStyle(header.column.id))} data-rozie-s-d5dcab4c>
        ${this.isSelectColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : this.isExpanderColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.columnIsFilterable(header.column.id) && !this.hasFilterSlot() ? html`<input class="rdt-col-filter" type="text" aria-label=${rozieAttr('Filter ' + this.headerLabel(header.column.id))} .value=${this.columnFilterValue(header.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onColumnFilterInput(header.column.id, $event); }} @click=${($event: MouseEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.stopEvent($event); }} data-rozie-s-d5dcab4c />` : nothing}${this.columnIsFilterable(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
            ${this.filter !== undefined ? this.filter({columnId: header.column.id, value: this.columnFilterValue(header.column.id), uniqueValues: this.getFacetedUniqueValues(header.column.id), minMax: this.getFacetedMinMaxValues(header.column.id), setFilter: this.setColumnFilter}) : html`<slot name="filter" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, value: this.columnFilterValue(header.column.id), uniqueValues: this.getFacetedUniqueValues(header.column.id), minMax: this.getFacetedMinMaxValues(header.column.id)}); } catch { return '{}'; } })()} @rozie-filter-set-filter=${($event: CustomEvent) => ((this.setColumnFilter) as (...args: any[]) => any)($event.detail)}></slot>`}
          </span>` : nothing}</span>`}</th>`)}
    </tr>` : nothing}</thead>

  <tbody class="rdt-tbody" role="rowgroup" data-rozie-s-d5dcab4c>
    
    <tr class="rdt-spacer" aria-hidden="true" data-rozie-s-d5dcab4c>
      <td colspan=${rozieAttr(this.visibleColCount())} style=${rozieStyle('height:' + this.padTop() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td>
    </tr>
    
    ${repeat<any>(this.windowedRows(), (wr, _idx) => wr.row.id, (wr, _idx) => html`
    <tr class="${Object.entries({ "rdt-tr": true, 'rdt-group-header': this.rowIsGrouped(wr.row), 'rdt-row-pinned': wr.pinned }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="row" data-row=${rozieAttr(wr.vi.index)} aria-rowindex=${rozieAttr(this.headerRowCount() + wr.vi.index + 1)} data-index=${rozieAttr(wr.vi.index)} data-pinned=${rozieAttr(wr.pinned ? 'true' : null)} data-depth=${rozieAttr(wr.row.depth)} data-group-header=${rozieAttr(this.rowIsGrouped(wr.row) ? wr.row.id : null)} data-group-leaf=${rozieAttr(this.groupingActive() && !this.rowIsGrouped(wr.row) ? wr.row.id : null)} aria-expanded=${rozieAttr(this.rowIsGrouped(wr.row) ? !!this.rowIsExpanded(wr.row) : null)} aria-selected=${rozieAttr(this.selectionMode !== 'none' ? !!this.rowIsSelected(wr.row) : null)} aria-level=${rozieAttr(this.groupingActive() ? wr.row.depth + 1 : null)} data-rozie-s-d5dcab4c>
      ${repeat<any>(this.visibleCellsFor(wr.row), (cell, _idx) => cell.id, (cell, _idx) => html`<td class="${Object.entries({ "rdt-td": true, 'rdt-select-td': this.isSelectColumn(cell.column.id), 'rdt-expander-td': this.isExpanderColumn(cell.column.id), 'rdt-in-range': this.inRange(wr.vi.index, this.colIndexOf(wr.row, cell)), 'rdt-cell-active': this.isActiveCell(String(wr.vi.index), this.colIndexOf(wr.row, cell)) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.cellRole())} data-col=${rozieAttr(cell.column.id)} data-grid-cell="" data-row=${rozieAttr(wr.vi.index)} data-col-index=${rozieAttr(this.colIndexOf(wr.row, cell))} tabindex=${rozieAttr(this.cellTabindex(String(wr.vi.index), this.colIndexOf(wr.row, cell)))} style=${rozieStyle(this.bodyCellStyle(wr.row, cell.column.id))} aria-invalid=${rozieAttr(this.cellAriaInvalid(wr.vi.index, this.colIndexOf(wr.row, cell)))} data-in-range=${rozieAttr(this.inRange(wr.vi.index, this.colIndexOf(wr.row, cell)) ? 'true' : null)} data-agg-cell=${rozieAttr(this.cellIsAggregated(cell) ? cell.column.id : null)} data-rozie-s-d5dcab4c>
        
        ${this.isExpanderColumn(cell.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.rowCanExpand(wr.row) ? html`<button class="rdt-expander" type="button" data-expander="" aria-expanded=${!!this.rowIsExpanded(wr.row)} aria-label=${rozieAttr(this.rowIsExpanded(wr.row) ? 'Collapse row' : 'Expand row')} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c>${rozieDisplay(this.rowIsExpanded(wr.row) ? '▾' : '▸')}</button>` : nothing}</span>` : this.isSelectColumn(cell.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.selectCell !== undefined ? this.selectCell({row: wr.row.original, checked: this.rowIsSelected(wr.row), toggle: e => this.onToggleRow(wr.row, e)}) : html`<slot name="selectCell" data-rozie-params=${(() => { try { return JSON.stringify({row: wr.row.original, checked: this.rowIsSelected(wr.row)}); } catch { return '{}'; } })()} @rozie-select-cell-toggle=${($event: CustomEvent) => ((e => this.onToggleRow(wr.row, e)) as (...args: any[]) => any)($event.detail)}>
            <input class="rdt-select-row" type="checkbox" aria-label="Select row" ?checked=${this.rowIsSelected(wr.row)} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onToggleRow(wr.row, $event); }} data-rozie-s-d5dcab4c />
          </slot>`}
        </span>` : this.cellIsGrouped(cell) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          <button class="rdt-expander rdt-group-toggle" type="button" data-expander="" aria-expanded=${!!this.rowIsExpanded(wr.row)} aria-label=${rozieAttr(this.rowIsExpanded(wr.row) ? 'Collapse group' : 'Expand group')} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c>${rozieDisplay(this.rowIsExpanded(wr.row) ? '▾' : '▸')}</button>
          <span class="rdt-group-value" data-rozie-s-d5dcab4c>
            ${this.cell !== undefined ? this.cell({columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cell.getValue())}</slot>`}
          </span>
          <span class="rdt-group-count" data-rozie-s-d5dcab4c>${rozieDisplay('(' + this.groupSubRowCount(wr.row) + ')')}</span>
        </span>` : this.isEditing(wr.vi.index, this.colIndexOf(wr.row, cell)) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.hasEditorSlot(cell.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
            ${this.editor !== undefined ? this.editor({columnId: cell.column.id, column: cell.column, row: wr.row.original, value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), autofocus: this.editorAutofocusFor(cell.column.id, wr.vi.index)}) : html`<slot name="editor" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: wr.row.original, value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), autofocus: this.editorAutofocusFor(cell.column.id, wr.vi.index)}); } catch { return '{}'; } })()}></slot>`}
          </span>` : this.editorTypeOf(cell.column.id) === 'number' ? html`<input class="rdt-cell-editor" type="number" data-editing-cell="" .value=${this.editorValueFor(cell.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : this.editorTypeOf(cell.column.id) === 'select' ? html`<select class="rdt-cell-editor" data-editing-cell="" .value=${this.editorValueFor(cell.column.id)} @change=${($event: Event & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c>
            ${repeat<any>(this.editorOptionsOf(cell.column.id), (opt, _idx) => opt.value, (opt, _idx) => html`<option value=${rozieAttr(opt.value)} data-rozie-s-d5dcab4c>${rozieDisplay(opt.label)}</option>`)}
          </select>` : this.editorTypeOf(cell.column.id) === 'checkbox' ? html`<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" ?checked=${this.editorCheckedFor(cell.column.id)} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorCheckbox(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : html`<input class="rdt-cell-editor" type="text" data-editing-cell="" .value=${this.editorValueFor(cell.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />`}</span>` : this.cellIsPlaceholder(cell) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : html`<span class="rdt-cell-value" data-rozie-s-d5dcab4c>
          ${this.cell !== undefined ? this.cell({columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: wr.row.original, value: cell.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cell.getValue())}</slot>`}
        </span>`}${this.isFillHandleCell(wr.vi.index, this.colIndexOf(wr.row, cell)) ? html`<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" @pointerdown=${($event: PointerEvent & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c></span>` : nothing}</td>`)}
    </tr>
    
    ${this.rowShowsDetail(wr.row) ? html`<tr class="rdt-detail-row" role="row" data-detail-row=${rozieAttr(wr.row.id)} data-rozie-s-d5dcab4c>
      <td class="rdt-detail-cell" colspan=${rozieAttr(this.visibleColCount())} data-rozie-s-d5dcab4c>
        ${this.detail !== undefined ? this.detail({row: wr.row.original}) : html`<slot name="detail" data-rozie-params=${(() => { try { return JSON.stringify({row: wr.row.original}); } catch { return '{}'; } })()}></slot>`}
      </td>
    </tr>` : nothing}`)}
    
    <tr class="rdt-spacer" aria-hidden="true" data-rozie-s-d5dcab4c>
      <td colspan=${rozieAttr(this.visibleColCount())} style=${rozieStyle('height:' + this.padBottom() + 'px;padding:0;border:0')} data-rozie-s-d5dcab4c></td>
    </tr>
  </tbody>
</table>
</div>` : html`<table class="${Object.entries({ "rozie-data-table": true, 'rdt-sticky': this.stickyHeader }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.tableRole())} aria-rowcount=${rozieAttr(this.gridAriaRowCount())} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridKeyDown($event); }} @focusin=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridFocusOut($event); }} @mousedown=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridMouseDown($event); }} @dblclick=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridDblClick($event); }} @click=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridClick($event); }} data-rozie-s-d5dcab4c>
  <thead class="rdt-thead" role="rowgroup" data-rozie-s-d5dcab4c>
    ${repeat<any>(this._headerGroups.value, (hg, hgLevel) => hg.id, (hg, hgLevel) => html`<tr class="rdt-tr" role="row" aria-rowindex=${rozieAttr(hgLevel + 1)} data-rozie-s-d5dcab4c>
      ${repeat<any>(hg.headers, (header, _idx) => header.id, (header, _idx) => html`<th class="${Object.entries({ "rdt-th": true, 'rdt-select-th': this.isSelectColumn(header.column.id), 'rdt-expander-th': this.isExpanderColumn(header.column.id), 'rdt-th-resizing': this.columnIsResizing(header.column.id), 'rdt-cell-active': this.isActiveCell('__header', this.headerColIndexOf(hg, header), hgLevel) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="columnheader" data-col=${rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level=${rozieAttr(hgLevel)} colspan=${rozieAttr(header.colSpan > 1 ? header.colSpan : null)} data-col-index=${rozieAttr(this.headerColIndexOf(hg, header))} tabindex=${rozieAttr(this.cellTabindex('__header', this.headerColIndexOf(hg, header), hgLevel))} aria-sort=${rozieAttr(this.ariaSortFor(header.column.id))} style=${rozieStyle(this.thStyle(header.column.id))} data-rozie-s-d5dcab4c>
        
        
        ${this.isSelectColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.selectAll !== undefined ? this.selectAll({checked: this.isAllRowsSelected(), indeterminate: this.isSomeRowsSelected(), toggle: this.onToggleAllRows}) : html`<slot name="selectAll" data-rozie-params=${(() => { try { return JSON.stringify({checked: this.isAllRowsSelected(), indeterminate: this.isSomeRowsSelected()}); } catch { return '{}'; } })()} @rozie-select-all-toggle=${($event: CustomEvent) => ((this.onToggleAllRows) as (...args: any[]) => any)($event.detail)}>
            
            ${this.selectionMode === 'multiple' ? html`<input class="rdt-select-all" type="checkbox" aria-label="Select all rows" ?checked=${this.isAllRowsSelected()} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onToggleAllRows($event); }} data-rozie-s-d5dcab4c />` : nothing}</slot>`}
        </span>` : this.isExpanderColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
          
          ${header.column.getCanSort && header.column.getCanSort() ? html`<button class="rdt-sort-btn" type="button" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onHeaderSort(header.column.id, $event); }} data-rozie-s-d5dcab4c>
            
            <span class="rdt-header-label" data-rozie-s-d5dcab4c>
              ${this.colHeader !== undefined ? this.colHeader({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(header.column.id))}</slot>`}
            </span>
            <span class="rdt-sort-ind" aria-hidden="true" data-rozie-s-d5dcab4c>${rozieDisplay(this.sortIndicator(header.column.id))}</span>
          </button>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
            <span class="rdt-header-label" data-rozie-s-d5dcab4c>
              ${this.colHeader !== undefined ? this.colHeader({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}) : html`<slot name="colHeader" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, column: header.column, label: this.headerLabel(header.column.id)}); } catch { return '{}'; } })()}>${rozieDisplay(this.headerLabel(header.column.id))}</slot>`}
            </span>
          </span>`}<rozie-popover trigger="click" placement="bottom-end" strategy="fixed" .offset=${4} data-rozie-s-d5dcab4c><button class="rdt-col-menu-trigger" type="button" aria-label=${rozieAttr('Column options for ' + this.headerLabel(header.column.id))} data-rozie-s-d5dcab4c slot="anchor">⋯</button><div class="rdt-col-menu" role="menu" data-rozie-s-d5dcab4c>
              <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${this.columnPinSide(header.column.id) === 'left'} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>Pin left</button>
              <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${this.columnPinSide(header.column.id) === 'right'} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>Pin right</button>
              <button class="rdt-col-menu-item" type="button" role="menuitem" aria-pressed=${!this.columnPinSide(header.column.id)} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>Unpin</button>
              <hr class="rdt-col-menu-sep" data-rozie-s-d5dcab4c />
              <button class="rdt-col-menu-item" type="button" role="menuitem" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onHideColumn(header.column.id, $event); }} data-rozie-s-d5dcab4c>Hide column</button>
            </div></rozie-popover>
          
          <button class="rdt-resize-handle" type="button" aria-label=${rozieAttr('Resize ' + this.headerLabel(header.column.id))} @pointerdown=${($event: PointerEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onResizeStart(header.column.id, $event); }} @touchstart=${($event: TouchEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onResizeStart(header.column.id, $event); }} data-rozie-s-d5dcab4c><span class="rdt-resize-grip" aria-hidden="true" data-rozie-s-d5dcab4c></span></button>
        </span>`}</th>`)}
    </tr>`)}
    
    ${this.hasAnyFilterableColumn() ? html`<tr class="rdt-filter-row" data-rozie-s-d5dcab4c>
      ${repeat<any>(this._headerGroups.value[this._headerGroups.value.length - 1].headers, (header, _idx) => header.id, (header, _idx) => html`<th class="rdt-filter-cell" role="presentation" style=${rozieStyle(this.pinStyle(header.column.id))} data-rozie-s-d5dcab4c>
        ${this.isSelectColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : this.isExpanderColumn(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.columnIsFilterable(header.column.id) && !this.hasFilterSlot() ? html`<input class="rdt-col-filter" type="text" aria-label=${rozieAttr('Filter ' + this.headerLabel(header.column.id))} .value=${this.columnFilterValue(header.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onColumnFilterInput(header.column.id, $event); }} @click=${($event: MouseEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.stopEvent($event); }} data-rozie-s-d5dcab4c />` : nothing}${this.columnIsFilterable(header.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
            ${this.filter !== undefined ? this.filter({columnId: header.column.id, value: this.columnFilterValue(header.column.id), uniqueValues: this.getFacetedUniqueValues(header.column.id), minMax: this.getFacetedMinMaxValues(header.column.id), setFilter: this.setColumnFilter}) : html`<slot name="filter" data-rozie-params=${(() => { try { return JSON.stringify({columnId: header.column.id, value: this.columnFilterValue(header.column.id), uniqueValues: this.getFacetedUniqueValues(header.column.id), minMax: this.getFacetedMinMaxValues(header.column.id)}); } catch { return '{}'; } })()} @rozie-filter-set-filter=${($event: CustomEvent) => ((this.setColumnFilter) as (...args: any[]) => any)($event.detail)}></slot>`}
          </span>` : nothing}</span>`}</th>`)}
    </tr>` : nothing}</thead>

  <tbody class="rdt-tbody" role="rowgroup" data-rozie-s-d5dcab4c>
    
    ${repeat<any>(this._rows.value, (row, _idx) => row.id, (row, _idx) => html`
    <tr class="${Object.entries({ "rdt-tr": true, 'rdt-group-header': this.rowIsGrouped(row) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="row" data-depth=${rozieAttr(row.depth)} aria-rowindex=${rozieAttr(this.bodyAriaRowIndex(row))} data-group-header=${rozieAttr(this.rowIsGrouped(row) ? row.id : null)} data-group-leaf=${rozieAttr(this.groupingActive() && !this.rowIsGrouped(row) ? row.id : null)} aria-expanded=${rozieAttr(this.rowIsGrouped(row) ? !!this.rowIsExpanded(row) : null)} aria-selected=${rozieAttr(this.selectionMode !== 'none' ? !!this.rowIsSelected(row) : null)} aria-level=${rozieAttr(this.groupingActive() ? row.depth + 1 : null)} data-rozie-s-d5dcab4c>
      ${repeat<any>(this.visibleCellsFor(row), (cell, _idx) => cell.id, (cell, _idx) => html`<td class="${Object.entries({ "rdt-td": true, 'rdt-select-td': this.isSelectColumn(cell.column.id), 'rdt-expander-td': this.isExpanderColumn(cell.column.id), 'rdt-in-range': this.inRange(this.rowIndexOf(row), this.colIndexOf(row, cell)), 'rdt-cell-active': this.isActiveCell(String(this.rowIndexOf(row)), this.colIndexOf(row, cell)) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.cellRole())} data-col=${rozieAttr(cell.column.id)} data-grid-cell="" data-row=${rozieAttr(this.rowIndexOf(row))} data-col-index=${rozieAttr(this.colIndexOf(row, cell))} tabindex=${rozieAttr(this.cellTabindex(String(this.rowIndexOf(row)), this.colIndexOf(row, cell)))} style=${rozieStyle(this.bodyCellStyle(row, cell.column.id))} aria-invalid=${rozieAttr(this.cellAriaInvalid(this.rowIndexOf(row), this.colIndexOf(row, cell)))} data-in-range=${rozieAttr(this.inRange(this.rowIndexOf(row), this.colIndexOf(row, cell)) ? 'true' : null)} data-agg-cell=${rozieAttr(this.cellIsAggregated(cell) ? cell.column.id : null)} data-rozie-s-d5dcab4c>
        
        ${this.isExpanderColumn(cell.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.rowCanExpand(row) ? html`<button class="rdt-expander" type="button" data-expander="" aria-expanded=${!!this.rowIsExpanded(row)} aria-label=${rozieAttr(this.rowIsExpanded(row) ? 'Collapse row' : 'Expand row')} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c>${rozieDisplay(this.rowIsExpanded(row) ? '▾' : '▸')}</button>` : nothing}</span>` : this.isSelectColumn(cell.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.selectCell !== undefined ? this.selectCell({row: row.original, checked: this.rowIsSelected(row), toggle: e => this.onToggleRow(row, e)}) : html`<slot name="selectCell" data-rozie-params=${(() => { try { return JSON.stringify({row: row.original, checked: this.rowIsSelected(row)}); } catch { return '{}'; } })()} @rozie-select-cell-toggle=${($event: CustomEvent) => ((e => this.onToggleRow(row, e)) as (...args: any[]) => any)($event.detail)}>
            <input class="rdt-select-row" type="checkbox" aria-label="Select row" ?checked=${this.rowIsSelected(row)} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onToggleRow(row, $event); }} data-rozie-s-d5dcab4c />
          </slot>`}
        </span>` : this.cellIsGrouped(cell) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          <button class="rdt-expander rdt-group-toggle" type="button" data-expander="" aria-expanded=${!!this.rowIsExpanded(row)} aria-label=${rozieAttr(this.rowIsExpanded(row) ? 'Collapse group' : 'Expand group')} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c>${rozieDisplay(this.rowIsExpanded(row) ? '▾' : '▸')}</button>
          <span class="rdt-group-value" data-rozie-s-d5dcab4c>
            ${this.cell !== undefined ? this.cell({columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cell.getValue())}</slot>`}
          </span>
          <span class="rdt-group-count" data-rozie-s-d5dcab4c>${rozieDisplay('(' + this.groupSubRowCount(row) + ')')}</span>
        </span>` : this.isEditing(this.rowIndexOf(row), this.colIndexOf(row, cell)) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.hasEditorSlot(cell.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
            ${this.editor !== undefined ? this.editor({columnId: cell.column.id, column: cell.column, row: row.original, value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), autofocus: this.editorAutofocusFor(cell.column.id, this.rowIndexOf(row))}) : html`<slot name="editor" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: row.original, value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), autofocus: this.editorAutofocusFor(cell.column.id, this.rowIndexOf(row))}); } catch { return '{}'; } })()}></slot>`}
          </span>` : this.editorTypeOf(cell.column.id) === 'number' ? html`<input class="rdt-cell-editor" type="number" data-editing-cell="" .value=${this.editorValueFor(cell.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : this.editorTypeOf(cell.column.id) === 'select' ? html`<select class="rdt-cell-editor" data-editing-cell="" .value=${this.editorValueFor(cell.column.id)} @change=${($event: Event & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c>
            ${repeat<any>(this.editorOptionsOf(cell.column.id), (opt, _idx) => opt.value, (opt, _idx) => html`<option value=${rozieAttr(opt.value)} data-rozie-s-d5dcab4c>${rozieDisplay(opt.label)}</option>`)}
          </select>` : this.editorTypeOf(cell.column.id) === 'checkbox' ? html`<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" ?checked=${this.editorCheckedFor(cell.column.id)} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorCheckbox(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : html`<input class="rdt-cell-editor" type="text" data-editing-cell="" .value=${this.editorValueFor(cell.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />`}</span>` : this.cellIsPlaceholder(cell) ? html`<span style="display:contents" data-rozie-s-d5dcab4c></span>` : html`<span class="rdt-cell-value" data-rozie-s-d5dcab4c>
          ${this.cell !== undefined ? this.cell({columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: row.original, value: cell.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cell.getValue())}</slot>`}
        </span>`}${this.isFillHandleCell(this.rowIndexOf(row), this.colIndexOf(row, cell)) ? html`<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" @pointerdown=${($event: PointerEvent & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c></span>` : nothing}</td>`)}
    </tr>
    
    ${this.rowShowsDetail(row) ? html`<tr class="rdt-detail-row" role="row" data-detail-row=${rozieAttr(row.id)} data-rozie-s-d5dcab4c>
      <td class="rdt-detail-cell" colspan=${rozieAttr(this.visibleColCount())} data-rozie-s-d5dcab4c>
        ${this.detail !== undefined ? this.detail({row: row.original}) : html`<slot name="detail" data-rozie-params=${(() => { try { return JSON.stringify({row: row.original}); } catch { return '{}'; } })()}></slot>`}
      </td>
    </tr>` : nothing}`)}
  </tbody>
</table>`}${!this.virtual ? html`<div class="rdt-pagination" role="group" aria-label="Pagination" data-rozie-s-d5dcab4c>
  <button class="rdt-page-btn rdt-page-prev" type="button" ?disabled=${!this.canPrevPage()} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onPrevPage(); }} data-rozie-s-d5dcab4c>Prev</button>
  <span class="rdt-page-status" aria-live="polite" data-rozie-s-d5dcab4c>
    ${rozieDisplay('Page ' + (this.pageIndex() + 1) + ' of ' + this.displayPageCount())}
  </span>
  <button class="rdt-page-btn rdt-page-next" type="button" ?disabled=${!this.canNextPage()} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onNextPage(); }} data-rozie-s-d5dcab4c>Next</button>
  <select class="rdt-page-size" aria-label="Rows per page" .value=${this.pageSize()} @change=${($event: Event & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onPageSizeChange($event); }} data-rozie-s-d5dcab4c>
    <option value=${10} data-rozie-s-d5dcab4c>10</option>
    <option value=${25} data-rozie-s-d5dcab4c>25</option>
    <option value=${50} data-rozie-s-d5dcab4c>50</option>
    <option value=${100} data-rozie-s-d5dcab4c>100</option>
  </select>
</div>` : nothing}</div>
`;
  }

  table: any = null;

  virtualizer: any = null;

  virtualizerCleanup: any = null;

  gridScrollEl: any = null;

  remeasurePending = false;

  GRID_PAGE_STEP = 10;

  gridRoot: any = null;

  programmatic = 0;

  focusIntentEpoch = 0;

  DATA_WRITE_TOKEN_KEY = '__rozieDataWriteToken';

  undoStack: unknown[] = [];

  redoStack: unknown[] = [];

  restoringHistory: boolean = false;

  expandedTouched = false;

  groupingActiveDefault = () => ((this.grouping != null ? this.grouping : this._groupingDefault.value) || []).length > 0;

  effectiveColumnPinning = (): any => {
  const base = this.columnPinning != null ? this.columnPinning : this._columnPinningDefault.value;
  const rail: string[] = [];
  if (this.selectionEnabled()) rail.push(this.SELECT_COL_ID);
  if (this.expandable === true) rail.push(this.EXPANDER_COL_ID);
  if (rail.length === 0) return base;
  const left = base && base.left ? base.left : [];
  const deduped = left.filter((id: string) => id !== this.SELECT_COL_ID && id !== this.EXPANDER_COL_ID);
  return {
    ...base,
    left: rail.concat(deduped)
  };
};

  currentState = (): any => ({
  sorting: this.sorting != null ? this.sorting : this._sortingDefault.value,
  globalFilter: this.globalFilter != null ? this.globalFilter : this._globalFilterDefault.value,
  columnFilters: this.columnFilters != null ? this.columnFilters : this._columnFiltersDefault.value,
  pagination: this.pagination != null ? this.pagination : this._paginationDefault.value,
  rowSelection: this.rowSelection != null ? this.rowSelection : this._rowSelectionDefault.value,
  // expanded (phase 50 req-1/3): ExpandedState ({ [rowId]: true } | the `true` expand-all
  // literal). Passed to table-core verbatim — never Object.keys'd without a `=== true`
  // guard (Pitfall 2). Falls back to $data.expandedDefault when r-model:expanded is unbound.
  // GROUPING AUTO-EXPAND (req-4): when grouping is active and the consumer has neither bound
  // `expanded` nor toggled a group yet (!expandedTouched), default to the `true` expand-all
  // literal so the grouped subtree is visible by default; the first toggle latches
  // expandedTouched and the user's expanded state wins thereafter. Non-grouping path is
  // unchanged → byte-identical-off (the table + the expandable-rows feature both keep
  // $data.expandedDefault).
  expanded: this.expanded != null ? this.expanded : this.groupingActiveDefault() && !this.expandedTouched ? true : this._expandedDefault.value,
  // grouping (phase 50 reqs 4-7): GroupingState = ordered string[] of column ids. Falls back
  // to $data.groupingDefault when r-model:grouping is unbound. table-core's getGroupedRowModel
  // is inert when this is empty (byte-identical-off, req-10).
  grouping: this.grouping != null ? this.grouping : this._groupingDefault.value,
  columnVisibility: this.columnVisibility != null ? this.columnVisibility : this._columnVisibilityDefault.value,
  columnSizing: this.columnSizing != null ? this.columnSizing : this._columnSizingDefault.value,
  columnOrder: this.columnOrder != null ? this.columnOrder : this._columnOrderDefault.value,
  columnPinning: this.effectiveColumnPinning(),
  // columnSizingInfo: table-core's transient resize-gesture state. We pass an
  // EXPLICIT `state` object, so table-core does NOT fill its own defaults — and
  // `column.getIsResizing()` / `getResizeHandler()` read
  // `getState().columnSizingInfo.isResizingColumn`, which THROWS if the key is
  // absent. Seed the default shape (matches table-core's
  // getDefaultColumnSizingInfoState) so the resize-chrome predicates are safe on
  // every render. Not a two-way model slice (transient gesture state, not consumer
  // state) — held in $data.columnSizingInfo and reset by table-core mid-drag.
  columnSizingInfo: this._columnSizingInfo.value
});

  currentData = (): any => this.data != null ? this.data : this._dataDefault.value;

  isSafeKey = (k: any) => k !== '__proto__' && k !== 'constructor' && k !== 'prototype';

  wrapAggregationFn = (fn: any) => {
  if (typeof fn === 'string') return fn;
  if (typeof fn !== 'function') return undefined;
  return (columnId: any, leafRows: any, childRows: any) => {
    try {
      return fn(columnId, leafRows, childRows);
    } catch (err: any) {
      return undefined;
    }
  };
};

  buildConfigDef = (c: any) => {
  if (!c) return null;
  // Grouped (multi-level) header column: an entry carrying a `columns` array. table-core's
  // getHeaderGroups() yields ONE extra header-row level per group depth — the parent group
  // header spans its leaf children (B12). The group id falls back to its header text so it
  // stays addressable (no accessor; group columns carry no data).
  if (Array.isArray(c.columns)) {
    const kids = [];
    for (const child of c.columns as any) {
      const cd = this.buildConfigDef(child);
      if (cd) kids.push(cd);
    }
    if (!kids.length) return null;
    // Group id: an explicit c.id wins. Otherwise synthesize a STABLE UNIQUE id from the child
    // column ids (which are unique per leaf accessor / recursively-synthesized per nested group)
    // — NOT the header text. Falling back to c.header collided two same-titled groups (e.g. both
    // "Details") into one by-id map key, so the columnDefs LWW merge silently dropped one whole
    // group column + its children. The child-id derivation is deterministic (stable across
    // renders — no Math.random/Date). A group with neither id nor header (nor derivable children)
    // stays dropped as before.
    let gid = c.id;
    if (gid == null) gid = c.header != null ? '__grp_' + kids.map((k: any) => k.id).join('_') : null;
    if (gid == null) return null;
    const id = String(gid);
    if (!this.isSafeKey(id)) return null;
    return {
      id,
      header: c.header != null ? c.header : id,
      columns: kids
    };
  }
  const rawId = c.id != null ? c.id : c.field;
  if (rawId == null) return null;
  const id = String(rawId);
  if (!this.isSafeKey(id)) return null;
  return {
    id,
    accessorKey: c.field != null ? c.field : id,
    header: c.header != null ? c.header : id,
    enableSorting: c.sortable === true,
    // per-column filter opt-in (req-5). table-core gates the filter input + value
    // funnel on enableColumnFilter; a column with filterable !== true cannot be
    // filtered (and renders no per-column filter input in the chrome below).
    enableColumnFilter: c.filterable === true,
    filterable: c.filterable === true,
    // Expandable-rows reserved per-column metadata (phase 50, D-04).
    expandable: c.expandable === true,
    // Grouping (phase 50 reqs 4-7): groupable defaults TRUE (opt-OUT via groupable:false)
    // so every data column is offered to the headless #groupBar by default; the per-column
    // aggregationFn (built-in name OR custom fn) flows straight onto the ColumnDef (D-05),
    // a custom fn defensively wrapped (T-50-04).
    groupable: c.groupable !== false,
    aggregationFn: this.wrapAggregationFn(c.aggregationFn),
    pinned: c.pinned != null ? c.pinned : '',
    width: c.width != null ? c.width : '',
    // Editable-cell config (Phase 51) → ColumnDef.meta, the table-core per-column
    // metadata carrier the display↔editor branch + runValidator read. Off by default.
    meta: {
      editable: c.editable === true,
      editor: c.editor != null ? c.editor : 'text',
      editorOptions: c.editorOptions != null ? c.editorOptions : [],
      validate: typeof c.validate === 'function' ? c.validate : null
    }
  };
};

  columnDefs = () => {
  const byId = Object.create(null);
  const order = [];
  const cfg = this.columns || [];
  for (const c of cfg as any) {
    const def = this.buildConfigDef(c);
    if (!def) continue;
    const id = def.id;
    if (!(id in byId)) order.push(id);
    byId[id] = def;
  }
  const reg = this._colReg.value || {};
  for (const id in reg) {
    if (!this.isSafeKey(id)) continue;
    const spec = reg[id];
    if (!spec) continue;
    if (!(id in byId)) order.push(id);
    byId[id] = {
      id,
      accessorKey: spec.field != null ? spec.field : id,
      header: spec.header != null ? spec.header : id,
      enableSorting: spec.sortable === true,
      enableColumnFilter: spec.filterable === true,
      filterable: spec.filterable === true,
      // Expandable-rows reserved per-column metadata (phase 50, D-04).
      expandable: spec.expandable === true,
      // Grouping (phase 50 reqs 4-7) — same shape as the config branch (D-05 / T-50-04).
      groupable: spec.groupable !== false,
      aggregationFn: this.wrapAggregationFn(spec.aggregationFn),
      pinned: spec.pinned != null ? spec.pinned : '',
      width: spec.width != null ? spec.width : '',
      // Editable-cell config (Phase 51) → ColumnDef.meta from the <Column> registry spec.
      meta: {
        editable: spec.editable === true,
        editor: spec.editor != null ? spec.editor : 'text',
        editorOptions: spec.editorOptions != null ? spec.editorOptions : [],
        validate: typeof spec.validate === 'function' ? spec.validate : null
      }
    };
  }
  const out = [];
  for (const id of order as any) if (byId[id]) out.push(byId[id]);
  return out;
};

  SELECT_COL_ID = '__rdt_select';

  EXPANDER_COL_ID = '__rdt_expander';

  selectionEnabled = () => this.selectionMode === 'single' || this.selectionMode === 'multiple';

  tableColumns = () => {
  const cols = this.columnDefs();
  // Expander column (phase 50, D-04): injected LEADING when expandable, carrying an
  // isExpanderColumn marker the template uses to render the chevron toggle (NOT an accessor
  // value). enableSorting/enableColumnFilter:false (it is chrome, not data). Off by default
  // → byte-identical-off (req-10).
  let withExpander = cols;
  if (this.expandable === true) {
    const expanderCol = {
      id: this.EXPANDER_COL_ID,
      enableSorting: false,
      enableColumnFilter: false,
      filterable: false,
      isExpanderColumn: true,
      pinned: '',
      width: '',
      // Explicit narrow size so table-core's getSize()/getStart('left') match the RENDERED
      // width. Without it table-core assumes its 150px default, which is fine for an UNPINNED
      // chrome column (a CSS `width:1%` trick shrinks it visually) but breaks the moment the
      // column joins the left-pinned rail: pinStyle's sticky offset is Σ preceding pinned
      // SIZES, so a phantom 150px would push every real pinned column ~150px too far right and
      // overlap. Keep this in sync with the `--rdt-expander-col-width` CSS default (40px).
      size: 40
    };
    withExpander = [expanderCol].concat(cols);
  }
  if (this.selectionEnabled()) {
    const selectCol = {
      id: this.SELECT_COL_ID,
      enableSorting: false,
      enableColumnFilter: false,
      filterable: false,
      isSelectColumn: true,
      pinned: '',
      width: '',
      // Explicit narrow size so table-core's sticky-offset math (getStart('left')) matches the
      // rendered checkbox width once this column joins the left-pinned rail — see the expander
      // note above. Keep in sync with the `--rdt-select-col-width` CSS default (44px).
      size: 44
    };
    return [selectCol].concat(withExpander);
  }
  return withExpander;
};

  writeSorting = (next: any) => {
  if (this.programmatic) return;
  this.programmatic++;
  this._sortingDefault.value = next; // fresh array only (never in-place)
  this._sortingControllable.write(next); // two-way emit if bound (no-op-diff if not)
  this.dispatchEvent(new CustomEvent("sort-change", {
    detail: next,
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  applyUpdater = (updater: any, current: any) => typeof updater === 'function' ? updater(current) : updater;

  writeExpanded = (next: any) => {
  if (this.programmatic) return;
  this.programmatic++;
  // Latch the grouping auto-expand default (req-4): the FIRST expand/collapse toggle means
  // the user now owns the expanded state, so currentState() stops defaulting grouped rows to
  // the `true` expand-all literal and honors $data.expandedDefault from here on.
  this.expandedTouched = true;
  this._expandedDefault.value = next; // fresh value only (never in-place)
  this._expandedControllable.write(next); // two-way emit if bound (no-op-diff if not)
  // Event stem is `expand-change`, NOT `expanded-change`: the model:true `expanded`
  // prop auto-generates an `onExpandedChange` callback on the React/Solid flat Props
  // interface, and an `expanded-change` event would camelCase to the SAME identifier
  // → duplicate-identifier TS2300 (the model-prop==emit-name collision class). Every
  // sibling slice avoids this by stemming the event off a DISTINCT name (sorting→
  // sort-change, rowSelection→selection-change); `expanded`→`expand-change` follows suit.
  this.dispatchEvent(new CustomEvent("expand-change", {
    detail: next,
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  writeGrouping = (next: any) => {
  if (this.programmatic) return;
  this.programmatic++;
  this._groupingDefault.value = next; // fresh ordered array only (never in-place push)
  this._groupingControllable.write(next); // two-way emit if bound (no-op-diff if not)
  this.dispatchEvent(new CustomEvent("group-change", {
    detail: next,
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  writeGlobalFilter = (next: any) => {
  if (this.programmatic) return;
  this.programmatic++;
  this._globalFilterDefault.value = next;
  this._globalFilterControllable.write(next);
  this.dispatchEvent(new CustomEvent("filter-change", {
    detail: {
      globalFilter: next
    },
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  writeColumnFilters = (next: any) => {
  if (this.programmatic) return;
  this.programmatic++;
  this._columnFiltersDefault.value = next;
  this._columnFiltersControllable.write(next);
  this.dispatchEvent(new CustomEvent("filter-change", {
    detail: {
      columnFilters: next
    },
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  writePagination = (next: any) => {
  if (this.programmatic) return;
  this.programmatic++;
  this._paginationDefault.value = next;
  this._paginationControllable.write(next);
  this.dispatchEvent(new CustomEvent("page-change", {
    detail: next,
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  writeRowSelection = (next: any) => {
  if (this.programmatic) return;
  this.programmatic++;
  this._rowSelectionDefault.value = next;
  this._rowSelectionControllable.write(next);
  this.dispatchEvent(new CustomEvent("selection-change", {
    detail: next,
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  writeColumnVisibility = (next: any) => {
  if (this.programmatic) return;
  this.programmatic++;
  this._columnVisibilityDefault.value = next;
  this._columnVisibilityControllable.write(next);
  this.dispatchEvent(new CustomEvent("visibility-change", {
    detail: next,
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  writeColumnSizing = (next: any) => {
  if (this.programmatic) return;
  this.programmatic++;
  this._columnSizingDefault.value = next;
  this._columnSizingControllable.write(next);
  this.dispatchEvent(new CustomEvent("resize-change", {
    detail: next,
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  writeColumnOrder = (next: any) => {
  if (this.programmatic) return;
  this.programmatic++;
  this._columnOrderDefault.value = next;
  this._columnOrderControllable.write(next);
  this.dispatchEvent(new CustomEvent("reorder-change", {
    detail: next,
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  writeColumnPinning = (next: any) => {
  if (this.programmatic) return;
  // effectiveColumnPinning() forces the auto-injected chrome ids (select/expander) into the
  // table-core `left` rail, so table-core hands them back here on every pin change. Strip them
  // before persisting: the CONSUMER's columnPinning model + the pin-change event must reflect
  // only their own columns, never our internal rail ids (which re-inject each render anyway).
  const strip = (ids: any) => (ids || []).filter((id: any) => id !== this.SELECT_COL_ID && id !== this.EXPANDER_COL_ID);
  const clean = {
    ...next,
    left: strip(next && next.left),
    right: strip(next && next.right)
  };
  this.programmatic++;
  this._columnPinningDefault.value = clean;
  this._columnPinningControllable.write(clean);
  this.dispatchEvent(new CustomEvent("pin-change", {
    detail: clean,
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  writeData = (next: any) => {
  if (this.programmatic) return;
  if (this.undoable && !this.restoringHistory) {
    const prevU = this.canUndo();
    const prevR = this.canRedo();
    this.recordSnapshot(this.currentData());
    this.emitHistoryChangeIfEdged(prevU, prevR);
  }
  const fresh = Array.isArray(next) ? next.slice() : next;
  try {
    Object.defineProperty(fresh, this.DATA_WRITE_TOKEN_KEY, {
      value: true,
      enumerable: false,
      configurable: true,
      writable: true
    });
  } catch (_e: any) {/* a frozen/sealed array can't be stamped — our fresh arrays never are */}
  this.programmatic++;
  this._dataDefault.value = fresh; // fresh raw array only (never in-place, never a proxy)
  this._dataControllable.write(fresh); // two-way emit if bound (no-op-diff if not)
  this.programmatic--;
};

  columnFilterValue = (colId: any) => {
  const cf = this.currentState().columnFilters || [];
  for (const f of cf as any) if (f && f.id === colId) return f.value != null ? f.value : '';
  return '';
};

  setColumnFilter = (colId: any, value: any) => {
  const prev = this.currentState().columnFilters || [];
  const next = [];
  for (const f of prev as any) if (f && f.id !== colId) next.push(f);
  if (value != null && value !== '') next.push({
    id: colId,
    value
  });
  this.writeColumnFilters(next);
};

  recordSnapshot = (current: any) => {
  this.undoStack.push(current);
  const limit = this.undoLimit != null ? this.undoLimit : 100;
  while (this.undoStack.length > limit) this.undoStack.shift();
  this.redoStack = [];
};

  canUndo = () => this.undoStack.length > 0;

  canRedo = () => this.redoStack.length > 0;

  clearHistory = () => {
  this.undoStack = [];
  this.redoStack = [];
};

  emitHistoryChange = () => {
  this.dispatchEvent(new CustomEvent("history-change", {
    detail: {
      canUndo: this.canUndo(),
      canRedo: this.canRedo()
    },
    bubbles: true,
    composed: true
  }));
};

  emitHistoryChangeIfEdged = (prevU: any, prevR: any) => {
  const nextU = this.canUndo();
  const nextR = this.canRedo();
  if (nextU !== prevU || nextR !== prevR) this.emitHistoryChange();
};

  undo = () => {
  if (!this.canUndo()) return;
  const prev = this.undoStack.pop();
  this.redoStack.push(this.currentData());
  this.restoringHistory = true;
  this.writeData(prev);
  this.restoringHistory = false;
  this.emitHistoryChange();
};

  redo = () => {
  if (!this.canRedo()) return;
  const next = this.redoStack.pop();
  this.undoStack.push(this.currentData());
  this.restoringHistory = true;
  this.writeData(next);
  this.restoringHistory = false;
  this.emitHistoryChange();
};

  refreshRowModel: any = null;

  onSortingChangeCb = (updater: any) => {
  this.writeSorting(this.applyUpdater(updater, this.currentState().sorting));
};

  onExpandedChangeCb = (updater: any) => {
  this.writeExpanded(this.applyUpdater(updater, this.currentState().expanded));
};

  onGroupingChangeCb = (updater: any) => {
  this.writeGrouping(this.applyUpdater(updater, this.currentState().grouping));
};

  onGlobalFilterChangeCb = (updater: any) => {
  this.writeGlobalFilter(this.applyUpdater(updater, this.currentState().globalFilter));
};

  onColumnFiltersChangeCb = (updater: any) => {
  this.writeColumnFilters(this.applyUpdater(updater, this.currentState().columnFilters));
};

  onPaginationChangeCb = (updater: any) => {
  this.writePagination(this.applyUpdater(updater, this.currentState().pagination));
};

  onRowSelectionChangeCb = (updater: any) => {
  this.writeRowSelection(this.applyUpdater(updater, this.currentState().rowSelection));
};

  onColumnVisibilityChangeCb = (updater: any) => {
  this.writeColumnVisibility(this.applyUpdater(updater, this.currentState().columnVisibility));
};

  onColumnSizingChangeCb = (updater: any) => {
  this.writeColumnSizing(this.applyUpdater(updater, this.currentState().columnSizing));
};

  onColumnOrderChangeCb = (updater: any) => {
  this.writeColumnOrder(this.applyUpdater(updater, this.currentState().columnOrder));
};

  onColumnPinningChangeCb = (updater: any) => {
  this.writeColumnPinning(this.applyUpdater(updater, this.currentState().columnPinning));
};

  onColumnSizingInfoChangeCb = (updater: any) => {
  const next = this.applyUpdater(updater, this._columnSizingInfo.value);
  this._columnSizingInfo.value = next != null ? next : this._columnSizingInfo.value;
};

  windowSource = () => {
  if (!this.table) return [];
  if (this.virtual) return this.table.getPrePaginationRowModel().rows;
  return this.table.getRowModel().rows;
};

  scheduleRemeasure = () => {
  if (this.remeasurePending) return;
  this.remeasurePending = true;
  let ranMicro = false;
  const microPass = () => {
    this.remeasureWindow();
  };
  const rafPass = () => {
    this.remeasurePending = false;
    this.remeasureWindow();
  };
  if (typeof queueMicrotask !== 'undefined') {
    ranMicro = true;
    queueMicrotask(microPass);
  }
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(rafPass);else if (ranMicro) this.remeasurePending = false;else setTimeout(rafPass, 0);
};

  pinnedEditIndex = () => {
  if (this._editingRow.value >= 0) return this._editingRow.value;
  if (this._editingRowIndex.value != null) return this._editingRowIndex.value;
  return -1;
};

  pinnedMeasurement = (pin: any) => {
  if (!this.virtualizer || pin < 0) return null;
  const ms = this.virtualizer.getMeasurements();
  return ms && ms[pin] ? ms[pin] : null;
};

  remeasureWindow = () => {
  if (!this.virtualizer || !this.gridRoot) return;
  // Bail ONLY while a PROGRAMMATIC scroll is in flight: virtualizer.scrollState is non-null
  // exclusively during scrollToIndex / scrollToOffset (the D-12 scroll-then-focus seam) and
  // null for ordinary user/scrollTop-driven scrolling (verified virtual-core@3.17.1: set in
  // scrollToIndex L992, cleared to null on reconcile L378). Measuring mid-scrollToIndex lets
  // resizeItem nudge the offset and starve the scroll target (the Solid off-window focus
  // regression); the next settled onChange re-measures the stable window. Manual-scroll
  // recycling (the CR-01 case) has scrollState === null, so it measures normally.
  if (this.virtualizer.scrollState) return;
  const trs = this.gridRoot.querySelectorAll('tbody.rdt-tbody > tr[data-index]');
  for (const tr of trs as any) this.virtualizer.measureElement(tr);
};

  virtualItemKey = (i: any) => {
  const src = this.windowSource();
  return src && src[i] ? src[i].id : undefined;
};

  virtualizerOptions = (): any => ({
  count: this.windowSource().length,
  getScrollElement: () => this.gridScrollEl,
  estimateSize: () => this.estimateRowHeight,
  observeElementRect,
  observeElementOffset,
  scrollToFn: elementScroll,
  measureElement,
  overscan: 8,
  getItemKey: this.virtualItemKey,
  onChange: () => {
    this._windowVer.value = this._windowVer.value + 1;
    // CR-01: re-observe the freshly-committed window so RECYCLED rows get measured.
    // virtual-core only observe()s a node you explicitly hand to measureElement (it does
    // NOT auto-discover rendered rows — measureElement is the SOLE caller of
    // observer.observe, virtual-core@3.17.1 dist/esm/index.js:794-817). Rows that recycle
    // into view on scroll are brand-new DOM nodes; without re-sweeping they keep the
    // estimateRowHeight seed forever and the spacer math drifts (req-2). Deferred one frame
    // so the new <tr> set is in the DOM before we measure. Safe from an infinite
    // measure→onChange→measure loop: measureElement is idempotent on an already-observed
    // node (the `prevNode !== node` guard), and resizeItem only re-fires onChange when the
    // measured height actually DIFFERS from the cached one (delta !== 0) — an unchanged
    // re-measure is a no-op.
    this.scheduleRemeasure();
  }
});

  pinMeasurement = (pin: number): {
  start: number;
  size: number;
  index: number;
  end: number;
} | null => this.pinnedMeasurement(pin);

  windowedRows = () => {
  // SUBSCRIBE FIRST (fine-grained targets): touch the reactive windowVer at the TOP — BEFORE any
  // early return — so Solid's <For>/Svelte's {#each} accessor subscribes to it on its FIRST eval,
  // which happens at initial render while `virtualizer` is still null (it is built in $onMount,
  // after the first render). `virtualizer` is a non-reactive `let`, so if the windowVer read sat
  // BELOW the `!virtualizer` guard the accessor would early-return [] without ever reading the
  // signal → it would NEVER re-run when onChange later bumps windowVer, and the window would stay
  // blank forever (the Solid/Svelte fine-grained bug). Coarse targets re-render wholesale so the
  // placement is a no-op for them. The post-construction windowVer bump in $onMount fires the
  // first re-run that picks up the now-non-null virtualizer.
  // ALSO subscribe to editVer here so the slice re-derives when an editor opens/closes (the
  // pin/unpin transition), mirroring the probe's windowVer bump on pin (Solid/Svelte fine-grained).
  void this._windowVer.value;
  void this._editVer.value;
  if (!this.virtualizer) {
    // Virtual OFF → full set (the r-else table never calls this, but keep it total). Virtual ON
    // but the virtualizer is not yet constructed (pre-$onMount first paint) → render NOTHING so
    // the template never dereferences a null `vi` (the windowed bindings read wr.vi.index); the
    // rows appear on the first onChange after _didMount.
    if (!this.virtual) {
      const rowList = this._rows.value || [];
      return rowList.map((r: any) => ({
        vi: null,
        row: r
      }));
    }
    return [];
  }
  const items = this.virtualizer.getVirtualItems();
  const rowList = this._rows.value || [];
  // WR-01: drop any virtual item whose index outruns the current full-model rows (a brief
  // shrink window where the virtualizer count is stale relative to $data.rows on the async
  // onChange→windowVer path). The template keys on wr.row.id, so a row:undefined entry would
  // throw "Cannot read properties of undefined"; filter it here so the template never sees it.
  const out = items.map((vi: any) => ({
    vi,
    row: rowList[vi.index]
  })).filter((wr: any) => wr.row);
  // ── D-02 pin-row union (req-9): if an editor is open on a row that is NOT in the current
  // window, UNION it into the slice (keyed on row.id so Lit repeat / Solid For never recycle it
  // into another full-model row), LEADING the slice when it sits above the window and TRAILING
  // it when below — so DOM order matches visual/aria order. The spacer subtraction (padTop/
  // padBottom) keeps the total exactly getTotalSize(). This is the 51-01-proven mechanism wired
  // into the real windowing.
  const pin = this.pinnedEditIndex();
  if (pin >= 0 && rowList[pin]) {
    let inWindow = false;
    for (let i = 0; i < items.length; i++) {
      if (items[i].index === pin) {
        inWindow = true;
        break;
      }
    }
    if (!inWindow) {
      const pm = this.pinMeasurement(pin);
      const firstStart = items.length ? items[0].start : 0;
      const above = pm ? pm.start < firstStart : pin < (items.length ? items[0].index : pin);
      const pinnedEntry = {
        vi: pm != null ? pm : {
          index: pin
        },
        row: rowList[pin],
        pinned: true
      };
      if (above) out.unshift(pinnedEntry);else out.push(pinnedEntry);
    }
  }
  return out;
};

  padTop = () => {
  // SUBSCRIBE FIRST (the windowedRows() discipline): touch windowVer + editVer at the TOP so the
  // spacer-<td> :style binding subscribes on the fine-grained targets before the early return,
  // and re-derives on the pin/unpin transition (the D-02 spacer subtraction below).
  void this._windowVer.value;
  void this._editVer.value;
  if (!this.virtual || !this.virtualizer) return 0;
  const items = this.virtualizer.getVirtualItems();
  let pad = items.length ? items[0].start : 0;
  // D-02 spacer subtraction: when the pinned editing row sits ABOVE the window it is rendered
  // in-flow as the slice's LEADING <tr> (its measured height is now a real <tr>), so subtract
  // that height from the leading spacer to keep padTop + Σ rendered <tr> + padBottom = total.
  const pin = this.pinnedEditIndex();
  if (pin >= 0) {
    const pm = this.pinMeasurement(pin);
    const inWindow = this.pmIndexInWindow(items, pin);
    if (pm && !inWindow && pm.start < pad) pad = pad - pm.size;
  }
  return pad < 0 ? 0 : pad;
};

  padBottom = () => {
  // subscribe-first, see windowedRows() (IN-04): touch windowVer + editVer before the early
  // return so the fine-grained spacer :style binding subscribes on its first eval + re-derives
  // on pin/unpin.
  void this._windowVer.value;
  void this._editVer.value;
  if (!this.virtual || !this.virtualizer) return 0;
  const items = this.virtualizer.getVirtualItems();
  if (!items.length) return 0;
  let pad = this.virtualizer.getTotalSize() - items[items.length - 1].end;
  // D-02 spacer subtraction: when the pinned editing row sits BELOW the window it is rendered
  // in-flow as the slice's TRAILING <tr>, so subtract its height from the trailing spacer.
  const pin = this.pinnedEditIndex();
  if (pin >= 0) {
    const pm = this.pinMeasurement(pin);
    const inWindow = this.pmIndexInWindow(items, pin);
    // WR-01: decide "below the window" by INDEX, not by start-OFFSET. On variable-height rows
    // measurement drift can leave pm.start at-or-past items[0].start while the pinned row's
    // index is actually ABOVE the window, mis-subtracting its height from the trailing spacer.
    // The pinned full-model index vs the last rendered item's index is drift-proof. Fall back to
    // the offset comparison only if the measurement lacks an index (defensive).
    const lastItemIdx = items[items.length - 1].index;
    const below = pm && pm.index != null ? pm.index > lastItemIdx : pm && pm.start >= items[0].start;
    if (pm && !inWindow && below) {
      // below the window → it trailed the slice; subtract its height from the trailing spacer.
      if (pm.end > items[items.length - 1].end) pad = pad - pm.size;
    }
  }
  return pad < 0 ? 0 : pad;
};

  pmIndexInWindow = (items: any, idx: any) => {
  for (let i = 0; i < items.length; i++) if (items[i].index === idx) return true;
  return false;
};

  rowIsOutsideWindow = (r: any) => {
  if (!this.virtual || !this.virtualizer) return false;
  const items = this.virtualizer.getVirtualItems();
  for (const it of items as any) if (it.index === r) return false;
  return true;
};

  announceState: {
  sorting: unknown;
  columnFilters: unknown;
  globalFilter: unknown;
} = {
  sorting: null,
  columnFilters: null,
  globalFilter: null
};

  effectiveSorting = () => this.sorting != null ? this.sorting : this._sortingDefault.value;

  effectiveColumnFilters = () => this.columnFilters != null ? this.columnFilters : this._columnFiltersDefault.value;

  effectiveGlobalFilter = () => this.globalFilter != null ? this.globalFilter : this._globalFilterDefault.value;

  buildSortFilterAnnounce = () => {
  const nextSorting = this.effectiveSorting();
  const nextColumnFilters = this.effectiveColumnFilters();
  const nextGlobalFilter = this.effectiveGlobalFilter();
  const sortChanged = nextSorting !== this.announceState.sorting;
  const filterChanged = nextColumnFilters !== this.announceState.columnFilters || nextGlobalFilter !== this.announceState.globalFilter;
  this.announceState.sorting = nextSorting;
  this.announceState.columnFilters = nextColumnFilters;
  this.announceState.globalFilter = nextGlobalFilter;
  if (sortChanged) {
    const active = nextSorting && nextSorting.length ? nextSorting[0] : null;
    if (!active) return 'Sorting cleared';
    const rawLabel = this.headerLabel(active.id);
    const label = typeof rawLabel === 'string' && rawLabel ? rawLabel : active.id;
    return 'Sorted by ' + label + ', ' + (active.desc ? 'descending' : 'ascending');
  }
  if (filterChanged) {
    return this.totalRowCount() + ' results';
  }
  return '';
};

  reFeed = () => {
  if (!this.table) return;
  // NOTE: the external-swap history reset does NOT live here. reFeed() fires on EVERY watched
  // change — including our OWN synchronous internal `$data.dataDefault` write — so a clear keyed
  // on a `currentData()` read here would (on fine-grained targets) fire mid-round-trip against a
  // TRANSIENTLY-STALE `$props.data` and wrongly wipe a just-recorded edit's history. The reset is
  // keyed on the `$props.data` REFERENCE actually changing instead — see the $onUpdate backstop
  // below (`maybeClearHistoryOnExternalSwap`), which runs on all six targets.
  this.table.setOptions((prev: any) => ({
    ...prev,
    data: this.currentData(),
    columns: this.tableColumns(),
    state: this.currentState(),
    enableRowSelection: this.selectionMode !== 'none',
    enableMultiRowSelection: this.selectionMode === 'multiple',
    // Re-pass the server-side page-count sources (#2) so a RUNTIME rowCount/pageCount change
    // takes effect: setOptions REPLACES via `...prev`, which holds the value captured at
    // createTable time, so an omitted key would freeze the mount-time count. The re-feed
    // $watch keys on both props below.
    rowCount: this.rowCount ?? undefined,
    pageCount: this.pageCount ?? undefined,
    // Re-pass the expand model fns + callback (Pitfall 4 — virtual-core/table-core's
    // setOptions REPLACES, so an omitted fn would drop the model on re-feed; on React the
    // onExpandedChange callback must re-capture fresh currentState each cycle, F6).
    getExpandedRowModel: getExpandedRowModel(),
    getSubRows: (this.getSubRows || undefined) as any,
    getRowCanExpand: this.expandable === true && this.getSubRows == null ? () => true : undefined,
    onExpandedChange: this.onExpandedChangeCb,
    // Grouping auto-expand (phase 50 req-4): table-core's autoResetExpanded defaults TRUE, so a
    // POST-MOUNT setGrouping (the consumer #groupBar / applyGrouping verb) auto-fires
    // onExpandedChange({}) to reset the expanded set. That spurious reset funnels through
    // writeExpanded and would LATCH expandedTouched=true — defeating the grouping auto-expand
    // default (currentState().expanded would fall back to {} → nested group subtrees collapsed).
    // Disabling it makes post-mount grouping behave like initial grouping (subtrees auto-expanded
    // until the FIRST real user toggle). Inert for the plain/expand-only table (no grouping/sort/
    // filter mutation triggers an auto-reset there); explicit expandAll/collapseAll/toggle verbs
    // are unaffected (they fire regardless of this flag).
    autoResetExpanded: false,
    // Re-pass the grouped row model + callback (Pitfall 4 — setOptions REPLACES, so an
    // omitted fn would drop the model on re-feed; on React onGroupingChange must re-capture
    // fresh currentState each cycle, F6).
    getGroupedRowModel: getGroupedRowModel(),
    onGroupingChange: this.onGroupingChangeCb,
    // Re-pass the 3 faceted models (Pitfall 4 — setOptions REPLACES, so an omitted fn would
    // drop the model on re-feed; on React the faceted closures must re-capture so exposed
    // unique values + min/max update when an upstream filter changes, F6 / req-8 cross-filter).
    getFacetedRowModel: getFacetedRowModel(),
    getFacetedUniqueValues: makeFacetedUniqueValues(),
    getFacetedMinMaxValues: makeFacetedMinMaxValues(),
    // Re-pass the per-slice callbacks so React captures fresh currentState each cycle
    // (table-core keeps the prior callbacks otherwise → mount-time stale closure, F6).
    onSortingChange: this.onSortingChangeCb,
    onGlobalFilterChange: this.onGlobalFilterChangeCb,
    onColumnFiltersChange: this.onColumnFiltersChangeCb,
    onPaginationChange: this.onPaginationChangeCb,
    onRowSelectionChange: this.onRowSelectionChangeCb,
    onColumnVisibilityChange: this.onColumnVisibilityChangeCb,
    onColumnSizingChange: this.onColumnSizingChangeCb,
    onColumnOrderChange: this.onColumnOrderChangeCb,
    onColumnPinningChange: this.onColumnPinningChangeCb,
    onColumnSizingInfoChange: this.onColumnSizingInfoChangeCb
  }));
  if (this.refreshRowModel) this.refreshRowModel();
};

  lastPropsData: unknown = null;

  maybeClearHistoryOnExternalSwap = () => {
  const pd = this.data;
  if (pd === this.lastPropsData) return; // $props.data did not change → not an external swap
  this.lastPropsData = pd;
  if (!this.undoable) return;
  if (pd != null && (pd as any)[this.DATA_WRITE_TOKEN_KEY] != null) return; // descends from our write → keep
  this.clearHistory();
};

  lastData: any = null;

  lastDataLen = -1;

  onHeaderSort = (colId: any, evt: any) => {
  if (!this.table) return;
  const col = this.table.getColumn(colId);
  if (!col || !col.getCanSort()) return;
  const multi = !!(evt && evt.shiftKey);
  // toggleSorting(desc?, isMulti?) cycles asc → desc → none; multi accumulates.
  col.toggleSorting(undefined, multi);
};

  tick = () => this._rowModelVer.value;

  ariaSortFor = (colId: any) => {
  if (this.tick() < 0 || !this.table) return 'none';
  const col = this.table.getColumn(colId);
  if (!col) return 'none';
  const dir = col.getIsSorted();
  if (dir === 'asc') return 'ascending';
  if (dir === 'desc') return 'descending';
  return 'none';
};

  sortIndicator = (colId: any) => {
  if (this.tick() < 0 || !this.table) return '';
  const col = this.table.getColumn(colId);
  if (!col) return '';
  const dir = col.getIsSorted();
  if (dir === 'asc') return '▲';
  if (dir === 'desc') return '▼';
  return '';
};

  defFor = (colId: any) => {
  const defs = this.columnDefs();
  for (const d of defs as any) if (d.id === colId) return d;
  return null;
};

  visibleCellsFor = (row: any) => this._rowModelVer.value >= 0 ? row.getVisibleCells() : [];

  editMetaOf = (colId: any) => {
  const d = this.defFor(colId);
  return d && d.meta ? d.meta : null;
};

  columnEditable = (colId: any) => {
  const m = this.editMetaOf(colId);
  return !!(m && m.editable === true);
};

  editorTypeOf = (colId: any) => {
  const m = this.editMetaOf(colId);
  return m && m.editor != null ? m.editor : 'text';
};

  editorOptionsOf = (colId: any) => {
  const m = this.editMetaOf(colId);
  return m && m.editorOptions != null ? m.editorOptions : [];
};

  hasEditorSlot = (colId: any) => this.editorTypeOf(colId) === 'custom' && !!(this._hasSlotEditor || this.editor !== undefined);

  hasFilterSlot = () => !!(this._hasSlotFilter || this.filter !== undefined);

  columnIsFilterable = (colId: any) => {
  const d = this.defFor(colId);
  return !!(d && d.filterable);
};

  headerLabel = (colId: any) => {
  const d = this.defFor(colId);
  return d ? d.header : colId;
};

  headerWidth = (colId: any) => {
  if (this.tick() < 0 || !this.table) return null;
  const col = this.table.getColumn(colId);
  if (!col) return null;
  const w = col.getSize();
  return w != null && w > 0 ? w + 'px' : null;
};

  onResizeStart = (colId: any, evt: any) => {
  // stop here (NOT a `.stop` modifier) — the Angular `.stop`-in-@for hoist is broken (F5).
  if (evt && evt.stopPropagation) evt.stopPropagation();
  if (!this.table) return;
  const header = this.findHeader(colId);
  if (!header || !header.getResizeHandler) return;
  const handler = header.getResizeHandler();
  if (handler) handler(evt);
};

  findHeader = (colId: any) => {
  const groups = this._headerGroups.value || [];
  for (const hg of groups as any) {
    const hs = hg.headers || [];
    for (const h of hs as any) if (h && h.column && h.column.id === colId) return h;
  }
  return null;
};

  columnIsResizing = (colId: any) => {
  if (this.tick() < 0 || !this.table) return false;
  const header = this.findHeader(colId);
  return !!(header && header.column && header.column.getIsResizing && header.column.getIsResizing());
};

  columnIsVisible = (colId: any) => {
  if (this.tick() < 0 || !this.table) return true;
  const col = this.table.getColumn(colId);
  return !!(col && (col.getIsVisible ? col.getIsVisible() : true));
};

  onToggleVisibility = (colId: any) => {
  if (!this.table) return;
  const col = this.table.getColumn(colId);
  if (col && col.toggleVisibility) col.toggleVisibility();
};

  allLeafColumns = () => {
  if (this.tick() < 0 || !this.table) return [];
  const cols = this.table.getAllLeafColumns ? this.table.getAllLeafColumns() : [];
  const out = [];
  for (const c of cols as any) {
    if (!c || c.id === this.SELECT_COL_ID || c.id === this.EXPANDER_COL_ID) continue;
    out.push({
      id: c.id,
      label: this.headerLabel(c.id),
      visible: !!(c.getIsVisible && c.getIsVisible())
    });
  }
  return out;
};

  columnPinSide = (colId: any) => {
  if (this.tick() < 0 || !this.table) return false;
  const col = this.table.getColumn(colId);
  if (!col || !col.getIsPinned) return false;
  return col.getIsPinned();
};

  onPinColumn = (colId: any, side: any, evt: any) => {
  if (evt && evt.stopPropagation) evt.stopPropagation();
  if (!this.table) return;
  const col = this.table.getColumn(colId);
  if (col && col.pin) col.pin(side);
};

  pinStyle = (colId: any, zIndex = 1) => {
  if (this.tick() < 0 || !this.table) return '';
  const col = this.table.getColumn(colId);
  if (!col || !col.getIsPinned) return '';
  const side = col.getIsPinned();
  if (side === 'left') {
    const left = col.getStart ? col.getStart('left') : 0;
    return 'position:sticky;left:' + left + 'px;z-index:' + zIndex + ';';
  }
  if (side === 'right') {
    const right = col.getAfter ? col.getAfter('right') : 0;
    return 'position:sticky;right:' + right + 'px;z-index:' + zIndex + ';';
  }
  return '';
};

  thStyle = (colId: any) => {
  let s = '';
  const w = this.headerWidth(colId);
  if (w) s += 'width:' + w + ';';
  s += this.pinStyle(colId, 2);
  return s;
};

  onGlobalFilterInput = (evt: any) => {
  const value = evt && evt.target ? evt.target.value : '';
  if (this.table) {
    this.table.setGlobalFilter(value);
    return;
  }
  this.writeGlobalFilter(value);
};

  onColumnFilterInput = (colId: any, evt: any) => {
  const value = evt && evt.target ? evt.target.value : '';
  this.setColumnFilter(colId, value);
};

  globalFilterValue = () => {
  const v = this.currentState().globalFilter;
  return v != null ? v : '';
};

  pageIndex = () => {
  if (this.tick() >= 0 && this.table) return this.table.getState().pagination.pageIndex;
  const p = this.currentState().pagination;
  return p && p.pageIndex != null ? p.pageIndex : 0;
};

  pageSize = () => {
  if (this.tick() >= 0 && this.table) return this.table.getState().pagination.pageSize;
  const p = this.currentState().pagination;
  return p && p.pageSize != null ? p.pageSize : 10;
};

  displayPageCount = () => {
  if (this.tick() < 0 || !this.table) return 1;
  const c = this.table.getPageCount();
  return c != null && c > 0 ? c : 1;
};

  canPrevPage = () => !!(this.tick() >= 0 && this.table && this.table.getCanPreviousPage());

  canNextPage = () => !!(this.tick() >= 0 && this.table && this.table.getCanNextPage());

  onPrevPage = () => {
  if (this.table) this.table.previousPage();
};

  onNextPage = () => {
  if (this.table) this.table.nextPage();
};

  onPageSizeChange = (evt: any) => {
  if (!this.table) return;
  const v = evt && evt.target ? evt.target.value : '';
  const n = parseInt(v, 10);
  this.table.setPageSize(Number.isFinite(n) && n > 0 ? n : 10);
};

  isSelectColumn = (colId: any) => colId === this.SELECT_COL_ID;

  isExpanderColumn = (colId: any) => colId === this.EXPANDER_COL_ID;

  rowCanExpand = (row: any) => !!(this.tick() >= 0 && row && row.getCanExpand && row.getCanExpand() && !(row.getIsGrouped && row.getIsGrouped()));

  rowIsExpanded = (row: any) => !!(this.tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());

  rowShowsDetail = (row: any) => this.getSubRows == null && !this.rowIsGrouped(row) && this.rowIsExpanded(row);

  onToggleExpand = (row: any, evt: any) => {
  if (!row || !row.toggleExpanded) return;
  // Capture the owning row element BEFORE the toggle so DOM focus can be restored after the
  // expanded-state re-render. This guards a focus-drop that USED to happen on Solid: when the
  // cell loop reconciled by reference (bare <For>), table-core's fresh cell instances each
  // pull rebuilt the expander <td>/<button> (the <tr> persisted but its cells were rebuilt),
  // dropping DOM focus to <body> and breaking keyboard activation (Enter/Space on the focused
  // expander left nothing focused). Since the emitter now emits `<Key>` for the
  // `:key="cellCtx.id"` cell loop, Solid keeps the cell node on a stable key too — so the
  // expander is no longer recreated and this re-focus is now a defensive no-op on ALL six
  // targets (re-focusing the SAME kept element — the focusActiveCell imperative-refocus
  // precedent). Kept for safety; it costs nothing when the node is unchanged. The rAF defers
  // past the synchronous reactive flush so any (re)created node exists first.
  const ownerRow = evt && evt.currentTarget && evt.currentTarget.closest ? evt.currentTarget.closest('tr') : null;
  row.toggleExpanded();
  if (ownerRow && typeof requestAnimationFrame === 'function') {
    requestAnimationFrame(() => {
      const btn = ownerRow.querySelector('[data-expander]');
      if (btn) btn.focus();
    });
  }
};

  bodyCellStyle = (row: any, colId: any) => {
  const base = this.pinStyle(colId);
  if (this.isExpanderColumn(colId) && row && row.depth) {
    // Only the expander column indents (the tree affordance lives here).
    const pad = 'padding-left:' + (0.5 + row.depth * 1.25) + 'rem';
    return base ? base + pad : pad;
  }
  return base;
};

  rowIsGrouped = (row: any) => !!(this.tick() >= 0 && row && row.getIsGrouped && row.getIsGrouped());

  groupingActive = () => this.tick() >= 0 && (this.currentState().grouping || []).length > 0;

  cellIsGrouped = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsGrouped && cellCtx.getIsGrouped());

  cellIsAggregated = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsAggregated && cellCtx.getIsAggregated());

  cellIsPlaceholder = (cellCtx: any) => !!(this.tick() >= 0 && cellCtx && cellCtx.getIsPlaceholder && cellCtx.getIsPlaceholder());

  groupSubRowCount = (row: any) => row && row.getLeafRows ? row.getLeafRows().length : row && row.subRows ? row.subRows.length : 0;

  groupingKeys = () => this.currentState().grouping || [];

  groupableColumns = () => {
  const out = [];
  const defs = this.columnDefs();
  for (const d of defs as any) {
    if (!d || d.groupable === false) continue;
    out.push({
      id: d.id,
      label: d.header != null ? d.header : d.id
    });
  }
  return out;
};

  stopEvent = (evt: any) => {
  if (evt && evt.stopPropagation) evt.stopPropagation();
};

  isAllRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsAllRowsSelected());

  isSomeRowsSelected = () => !!(this.tick() >= 0 && this.table && this.table.getIsSomeRowsSelected());

  onToggleAllRows = (evt: any) => {
  if (!this.table) return;
  this.table.toggleAllRowsSelected(!!(evt && evt.target && evt.target.checked));
};

  rowIsSelected = (row: any) => {
  if (!row) return false;
  const id = row.id;
  const sel = this.currentState().rowSelection || {};
  if (id != null && Object.prototype.hasOwnProperty.call(sel, id)) return !!sel[id];
  return !!(row.getIsSelected && row.getIsSelected());
};

  onToggleRow = (row: any, evt: any) => {
  if (!row || !row.toggleSelected) return;
  row.toggleSelected(!!(evt && evt.target && evt.target.checked));
};

  onHideColumn = (colId: any, evt: any) => {
  if (evt && evt.stopPropagation) evt.stopPropagation();
  if (!this.table) return;
  const col = this.table.getColumn(colId);
  if (col && col.toggleVisibility) col.toggleVisibility(false);
};

  hasAnyFilterableColumn = () => {
  const cols = this.allLeafColumns();
  for (const c of cols as any) {
    if (c && this.columnIsFilterable(c.id)) return true;
  }
  return false;
};

  selectAllBox: any = null;

  syncIndeterminate = () => {
  if (!this._ref__rozieRoot || !this._ref__rozieRoot.querySelector) return;
  this.selectAllBox = this._ref__rozieRoot.querySelector('.rdt-select-all');
  if (this.selectAllBox) this.selectAllBox.indeterminate = this.isSomeRowsSelected() && !this.isAllRowsSelected();
};

  sortColumn = (colId: any, desc: any) => {
  if (this.table) this.table.getColumn(colId) && this.table.getColumn(colId).toggleSorting(desc, false);
};

  clearSorting = () => {
  if (this.table) this.table.resetSorting(true);
};

  getColumnDefs = () => this.columnDefs();

  toggleAllRows = (value: any) => {
  if (this.table) this.table.toggleAllRowsSelected(value);
};

  clearSelection = () => {
  if (this.table) this.table.resetRowSelection(true);
};

  getSelectedRows = () => this.table ? this.table.getSelectedRowModel().rows.map((r: any) => r.original) : [];

  setPage = (idx: any) => {
  if (this.table) this.table.setPageIndex(idx);
};

  setRowsPerPage = (size: any) => {
  if (this.table) this.table.setPageSize(size);
};

  toggleColumnVisibility = (colId: any) => {
  if (this.table) {
    const c = this.table.getColumn(colId);
    if (c && c.toggleVisibility) c.toggleVisibility();
  }
};

  applyColumnOrder = (order: any) => {
  if (this.table) this.table.setColumnOrder(order);
};

  resetColumnSizing = () => {
  if (this.table) this.table.resetColumnSizing(true);
};

  pinColumn = (colId: any, side: any) => {
  if (this.table) {
    const c = this.table.getColumn(colId);
    if (c && c.pin) c.pin(side);
  }
};

  getRowIndexRelativeToPage = (absRow: any) => {
  const abs = absRow == null ? this.toAbsRow(this._activeRow.value) : Math.trunc(Number(absRow)) || 0;
  if (this.virtual) return abs;
  return abs - this.pageRowOffset();
};

  cut = () => this.cutRange();

  isGrid = () => this.interactionMode === 'grid';

  tableRole = () => this.isGrid() ? 'grid' : 'table';

  cellRole = () => this.isGrid() ? 'gridcell' : 'cell';

  rowIndexOf = (row: any) => this.tick() >= 0 ? (this._rows.value || []).indexOf(row) : -1;

  colIndexOf = (row: any, cellCtx: any) => this.tick() >= 0 ? this.visibleCellsFor(row).indexOf(cellCtx) : -1;

  headerColIndexOf = (hg: any, header: any) => (hg && hg.headers ? hg.headers : []).indexOf(header);

  pageRowOffset = () => {
  if (!this.isGrid() || this.virtual) return 0;
  return this.pageIndex() * this.pageSize();
};

  toAbsRow = (localRow: any) => localRow + this.pageRowOffset();

  prePaginationRowCount = () => {
  if (!this.table || this.virtual) return this.bodyRowCount();
  const pm = this.table.getPrePaginationRowModel();
  return pm && pm.rows ? pm.rows.length : this.bodyRowCount();
};

  cellTabindex = (rowKey: any, colIndex: any, level = null) => {
  if (!this.isGrid()) return null;
  // B6: an empty / all-filtered grid (no body rows) must STILL be keyboard-reachable. Fall
  // the single roving tab-stop back to the FIRST leaf-header cell so the grid never has ZERO
  // tab-stops (a keyboard trap). Only the leaf-level header col 0 carries the tab-stop.
  if (this.bodyRowCount() === 0) {
    return rowKey === '__header' && colIndex === 0 && level === this.headerLeafLevel() ? 0 : -1;
  }
  // B12: when a header cell is active, address it by BOTH its level AND its colIndex so a
  // grouped multi-level header carries exactly ONE tab-stop. The pre-fix level-blind compare
  // lit BOTH the parent (level 0) and the leaf (level 1) at the same colIndex → multiple
  // tab-stops (the roving invariant broke under grouped headers).
  if (this._activeIsHeader.value) {
    if (rowKey !== '__header') return -1;
    return colIndex === this._activeColIndex.value && level === this._activeHeaderLevel.value ? 0 : -1;
  }
  const isActive = rowKey === String(this._activeRow.value) && colIndex === this._activeColIndex.value;
  return isActive ? 0 : -1;
};

  isActiveCell = (rowKey: any, colIndex: any, level = null) => {
  if (!this.isGrid()) return false;
  if (this._activeIsHeader.value) {
    if (rowKey !== '__header') return false;
    return colIndex === this._activeColIndex.value && level === this._activeHeaderLevel.value;
  }
  if (rowKey === '__header') return false;
  return rowKey === String(this._activeRow.value) && colIndex === this._activeColIndex.value;
};

  resolveCellEl = (rowKey: any, colIndex: any, level = null) => {
  if (!this.gridRoot) return null;
  // B12: a grouped multi-level header has MULTIPLE cells sharing data-row="__header" at the
  // same data-col-index across levels (parent vs leaf). Disambiguate header lookups by the
  // integer data-header-level so resolveCellEl('__header', 0) no longer returns the FIRST DOM
  // match (the parent) when the leaf is meant. level is an integer (NO consumer string is
  // interpolated — T-49-01 stays safe); body lookups pass level=null → the selector is
  // byte-unchanged.
  let sel = '[data-grid-cell][data-row="' + rowKey + '"][data-col-index="' + colIndex + '"]';
  if (rowKey === '__header' && level != null) sel = sel + '[data-header-level="' + level + '"]';
  return this.gridRoot.querySelector(sel);
};

  focusActiveCell = (nextRow = null, nextCol = null, nextIsHeader = null, nextLevel = null) => {
  if (!this.isGrid() || !this.gridRoot) return;
  // #9 focus-intent epoch: focusActiveCell is THE single seam every keyboard nav re-asserts
  // focus through, so it establishes a fresh "where focus should be" on every call — bump the
  // epoch here (BEFORE arming the virtual-scroll focusWhenReady poll below). A SUBSEQUENT
  // focusActiveCell (the next user nav) bumps again → any pending focusWhenReady captured the
  // OLD value → aborts instead of yanking focus back. The poll captures the POST-bump value so
  // a lone scroll-to-focus with no later nav still lands (epoch stable across its own frames).
  this.focusIntentEpoch = this.focusIntentEpoch + 1;
  const r = nextRow == null ? this._activeRow.value : nextRow;
  const c = nextCol == null ? this._activeColIndex.value : nextCol;
  // B12: thread the FRESH post-write header level (the grouped-header analog of the
  // nextIsHeader threading) so a leaf↔parent header move resolves the cell at the correct
  // level, never the async-stale $data.activeHeaderLevel re-read (React ROZ138 / Angular signal).
  const lvl = nextLevel == null ? this._activeHeaderLevel.value : nextLevel;
  // Thread the FRESH post-write isHeader flag (the plan-01-PROVEN contract): a header
  // crossing sets $data.activeIsHeader inside moveRow, but React's setState (ROZ138) and
  // Angular's signal write are async within one handler — re-reading $data.activeIsHeader
  // here returns the PRE-write value, resolving focus to the BODY cell instead of the
  // header. Callers pass the fresh isHeader local; falls back to $data when omitted.
  const header = nextIsHeader == null ? this._activeIsHeader.value : nextIsHeader;
  // ── phase 53 scroll-then-focus (D-12): when windowing AND the target body row is OUTSIDE the
  // rendered window, scroll it in first, then defer focus to AFTER the new window commits (the
  // double-rAF — a single rAF can fire before React's async commit, Pitfall 4). Header cells and
  // in-window rows keep the synchronous path below (table-mode / non-windowed stay byte-stable).
  // The guard reads the resolved `header` (NOT the raw `nextIsHeader`) so an omitted-arg call
  // while a header cell is active falls back to $data.activeIsHeader and skips the scroll path.
  if (this.virtual && this.virtualizer && !header && this.rowIsOutsideWindow(r)) {
    this.virtualizer.scrollToIndex(r, {
      align: 'center'
    });
    // Bounded rAF-poll-until-cell-present (D-12): scrollToIndex → virtual-core onChange → windowVer
    // bump → the framework commits the scrolled-in row. On React that commit is async (setState →
    // reconcile) and for a far scroll (e.g. row 4000) spans several frames — a one-shot double-rAF
    // fires BEFORE resolveCellEl can find the cell, so focus is silently lost (the deterministic
    // React off-window-focus failure). Poll resolveCellEl for up to ~30 frames: the five
    // fast-committing targets resolve on the first attempt (behavior unchanged), React retries
    // across the few frames its async commit needs. The poll ONLY focuses (never measures), so it
    // cannot re-introduce the remeasure-vs-scroll fight. Inside the $props.virtual guard only.
    let focusAttempts = 0;
    // #9: capture the epoch AFTER this call's own bump (above) so the poll never aborts itself
    // (its captured value equals the current epoch). A LATER focusActiveCell / focusCell /
    // active-cell-moving focusin bumps the epoch → the check below aborts this stale poll.
    const myEpoch = this.focusIntentEpoch;
    const focusWhenReady = () => {
      // A newer focus intent superseded this poll — abort WITHOUT focusing (the user has since
      // navigated / clicked elsewhere; re-focusing this off-window target would yank focus back).
      if (this.focusIntentEpoch !== myEpoch) return;
      const el = this.resolveCellEl(String(r), c);
      if (el) {
        el.focus();
        return;
      }
      focusAttempts = focusAttempts + 1;
      if (focusAttempts >= 30) return;
      if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 16);
    };
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusWhenReady);else setTimeout(focusWhenReady, 0);
    return;
  }
  const rowKey = header ? '__header' : String(r);
  const el = this.resolveCellEl(rowKey, c, header ? lvl : null);
  if (el) el.focus();
};

  totalRowCount = () => {
  if (!this.table) return (this._rows.value || []).length;
  const fm = this.table.getFilteredRowModel();
  return fm && fm.rows ? fm.rows.length : (this._rows.value || []).length;
};

  headerRowCount = () => (this._headerGroups.value || []).length;

  gridAriaRowCount = () => this.headerRowCount() + this.totalRowCount();

  ariaPageOffset = () => this.table ? this.pageIndex() * this.pageSize() : 0;

  bodyAriaRowIndex = (row: any) => this.headerRowCount() + this.rowIndexOf(row) + this.ariaPageOffset() + 1;

  visibleColCount = () => {
  // NB: local is `rowList` (NOT `rows`) — the React emitter lowers `$data.rows` to the bare
  // state binding `rows`, so a `const rows = $data.rows` self-shadows it (TS2448 TDZ). Same
  // self-shadow class as the deconflictPropShadows finding; avoid the $data-key name as a local.
  const rowList = this._rows.value || [];
  if (rowList.length) return rowList[0].getVisibleCells().length;
  const hg = this._headerGroups.value || [];
  return hg.length ? (hg[hg.length - 1].headers || []).length : 0;
};

  bodyRowCount = () => (this._rows.value || []).length;

  clamp = (v: any, lo: any, hi: any) => v < lo ? lo : v > hi ? hi : v;

  headerLeafLevel = () => {
  const hg = this._headerGroups.value || [];
  return hg.length ? hg.length - 1 : 0;
};

  headerCountAtLevel = (level: any) => {
  const hg = this._headerGroups.value || [];
  if (!hg.length) return this.visibleColCount();
  const grp = level >= 0 && level < hg.length ? hg[level] : null;
  if (!grp || !grp.headers) return this.visibleColCount();
  return grp.headers.length;
};

  headerAt = (level: any, colIndex: any) => {
  const hg = this._headerGroups.value || [];
  const grp = hg[level];
  if (!grp || !grp.headers) return null;
  return grp.headers[colIndex] || null;
};

  parentHeaderColIndex = (level: any, colIndex: any) => {
  if (level <= 0) return -1;
  const h = this.headerAt(level, colIndex);
  if (!h || !h.column || !h.column.parent) return -1;
  const parentId = h.column.parent.id;
  const hg = this._headerGroups.value || [];
  const pg = hg[level - 1];
  if (!pg || !pg.headers) return -1;
  for (let i = 0; i < pg.headers.length; i++) {
    const ph = pg.headers[i];
    if (ph && ph.column && ph.column.id === parentId) return i;
  }
  return -1;
};

  firstChildHeaderColIndex = (level: any, colIndex: any) => {
  const h = this.headerAt(level, colIndex);
  if (!h || !h.column) return -1;
  const kids = h.column.columns || [];
  if (!kids.length) return -1;
  const childId = kids[0].id;
  const hg = this._headerGroups.value || [];
  const cg = hg[level + 1];
  if (!cg || !cg.headers) return -1;
  for (let i = 0; i < cg.headers.length; i++) {
    const ch = cg.headers[i];
    if (ch && ch.column && ch.column.id === childId) return i;
  }
  return -1;
};

  moveCol = (delta: any) => {
  // #10: when a grouped PARENT header is active, clamp against the header count AT THE ACTIVE
  // LEVEL (which may be fewer than the leaf-column count) so ArrowRight never overruns onto a
  // phantom cell past that level's headers. Body cells + the leaf header level keep visibleColCount().
  const count = this._activeIsHeader.value ? this.headerCountAtLevel(this._activeHeaderLevel.value) : this.visibleColCount();
  const max = count - 1;
  const nextCol = this.clamp(this._activeColIndex.value + delta, 0, max < 0 ? 0 : max);
  this._activeColIndex.value = nextCol;
  return nextCol;
};

  moveRow = (delta: any) => {
  const lastRow = this.bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const leafLevel = this.headerLeafLevel();
  if (this._activeIsHeader.value) {
    if (delta > 0) {
      // B12 — Down: from a PARENT header level, descend to its FIRST child leaf header (one
      // level down); from the LEAF header level, drop into the body (row 0). A header-level
      // move re-targets activeColIndex (parent↔child column indices differ), so the fresh
      // col is RETURNED for the caller to thread into the focus seam (NOT re-read from $data).
      if (this._activeHeaderLevel.value < leafLevel) {
        const childCol = this.firstChildHeaderColIndex(this._activeHeaderLevel.value, this._activeColIndex.value);
        if (childCol >= 0) {
          const nextLevel = this._activeHeaderLevel.value + 1;
          this._activeHeaderLevel.value = nextLevel;
          this._activeColIndex.value = childCol;
          return {
            row: this._activeRow.value,
            col: childCol,
            isHeader: true,
            level: nextLevel
          };
        }
      }
      // At the leaf header: an empty grid has no body to drop into → stay put.
      if (this.bodyRowCount() === 0) return {
        row: this._activeRow.value,
        col: this._activeColIndex.value,
        isHeader: true,
        level: this._activeHeaderLevel.value
      };
      // B17: crossing from the leaf header INTO the body consumes ONE step; the REMAINING
      // (delta-1) continues the descent, so PageDown (delta=GRID_PAGE_STEP) lands a real
      // page-down body row, NOT row 0 (== ArrowDown). ArrowDown (delta=1) still lands row 0
      // (delta-1 = 0); clamped to the page-last body row.
      const landRow = this.clamp(delta - 1, 0, maxRow);
      this._activeIsHeader.value = false;
      this._activeRow.value = landRow;
      return {
        row: landRow,
        col: this._activeColIndex.value,
        isHeader: false,
        level: 0
      };
    }
    // B12 — Up: from the leaf (or any non-top) header level, ascend to the PARENT header that
    // spans the active column; at the top level (or no real parent) stay put. The parent col
    // index differs from the leaf's, so the fresh col is RETURNED (threaded into focus).
    const parentCol = this.parentHeaderColIndex(this._activeHeaderLevel.value, this._activeColIndex.value);
    if (parentCol >= 0) {
      const nextLevel = this._activeHeaderLevel.value - 1;
      this._activeHeaderLevel.value = nextLevel;
      this._activeColIndex.value = parentCol;
      return {
        row: this._activeRow.value,
        col: parentCol,
        isHeader: true,
        level: nextLevel
      };
    }
    return {
      row: this._activeRow.value,
      col: this._activeColIndex.value,
      isHeader: true,
      level: this._activeHeaderLevel.value
    };
  }
  // In the body: an upward move from row 0 crosses into the LEAF header level (the header row
  // adjacent to the body). The body col index aligns 1:1 with the leaf header col index, so
  // activeColIndex carries over unchanged.
  if (delta < 0 && this._activeRow.value === 0) {
    this._activeIsHeader.value = true;
    this._activeHeaderLevel.value = leafLevel;
    return {
      row: this._activeRow.value,
      col: this._activeColIndex.value,
      isHeader: true,
      level: leafLevel
    };
  }
  const nextRow = this.clamp(this._activeRow.value + delta, 0, maxRow);
  this._activeRow.value = nextRow;
  this._activeIsHeader.value = false;
  return {
    row: nextRow,
    col: this._activeColIndex.value,
    isHeader: false,
    level: 0
  };
};

  gotoColEdge = (toEnd: any) => {
  // #10: End on a grouped PARENT header lands on that level's LAST header (headerCountAtLevel-1),
  // not the leaf-column max — otherwise the ring strands on a phantom cell past the level's
  // headers. Home is index 0 either way. Body cells + the leaf header level keep visibleColCount().
  const count = this._activeIsHeader.value ? this.headerCountAtLevel(this._activeHeaderLevel.value) : this.visibleColCount();
  const max = count - 1;
  const nextCol = toEnd ? max < 0 ? 0 : max : 0;
  this._activeColIndex.value = nextCol;
  return nextCol;
};

  gotoRowEdge = (toEnd: any) => {
  const lastRow = this.bodyRowCount() - 1;
  const nextRow = toEnd ? lastRow < 0 ? 0 : lastRow : 0;
  this._activeRow.value = nextRow;
  this._activeIsHeader.value = false;
  return nextRow;
};

  gotoStart = () => {
  this._activeIsHeader.value = false;
  this._activeRow.value = 0;
  this._activeColIndex.value = 0;
  return {
    row: 0,
    col: 0
  };
};

  gotoEnd = () => {
  const lastRow = this.bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const max = this.visibleColCount() - 1;
  const maxCol = max < 0 ? 0 : max;
  this._activeIsHeader.value = false;
  this._activeRow.value = maxRow;
  this._activeColIndex.value = maxCol;
  return {
    row: maxRow,
    col: maxCol
  };
};

  currentCellEl = () => {
  const rowKey = this._activeIsHeader.value ? '__header' : String(this._activeRow.value);
  return this.resolveCellEl(rowKey, this._activeColIndex.value, this._activeIsHeader.value ? this._activeHeaderLevel.value : null);
};

  focusables = (cellEl: any) => {
  if (!cellEl || !cellEl.querySelectorAll) return [];
  const list = Array.prototype.slice.call(cellEl.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'));
  return list.filter((n: any) => !n.disabled);
};

  enterControl = () => {
  const cellEl = this.currentCellEl();
  const list = this.focusables(cellEl);
  if (!list.length) return;
  this._activeInControl.value = true;
  list[0].focus();
};

  cycleWithinCell = (cellEl: any, forward: any) => {
  const list = this.focusables(cellEl);
  if (!list.length) return;
  const active = this.gridRoot ? this.gridRoot.getRootNode().activeElement : null;
  const cur = list.indexOf(active);
  let i = cur < 0 ? 0 : forward ? cur + 1 : cur - 1;
  if (i >= list.length) i = 0;
  if (i < 0) i = list.length - 1;
  list[i].focus();
};

  onGridKeyDown = (e: any) => {
  if (!this.isGrid() || !e) return;
  const key = e.key;
  // Editing mode (phase 51, Pitfall 5): an OPEN editor owns Tab/Enter/Escape (+ caret keys)
  // via its local onEditorKeyDown handler. This top check (BEFORE activeInControl) returns
  // early so the grid nav keymap never hijacks an arrow/Tab/Enter while editing — the three
  // modes (editing / in-control / navigation) stay mutually exclusive and ordered.
  if (this._editingRow.value >= 0) return;
  // Full-row edit (phase 51 req-6): an OPEN row editor owns Enter/Escape/Tab via the cell
  // editors' local onEditorKeyDown. Return early (before activeInControl) so the grid nav
  // keymap never hijacks while a row is in edit — the three modes stay mutually exclusive.
  if (this._editingRowIndex.value != null) return;
  // Interaction mode (D-08): Tab cycles within the cell, Escape exits. Focus containment.
  if (this._activeInControl.value) {
    if (key === 'Escape') {
      e.preventDefault();
      this._activeInControl.value = false;
      // Return focus to the OWNING cell (no move happened) — pass the current indices
      // explicitly (the React-emitted seam types both params as required; a zero-arg call
      // is TS2554). Reading $data here is safe: no write to activeRow/activeColIndex precedes it.
      this.focusActiveCell(this._activeRow.value, this._activeColIndex.value);
    } else if (key === 'Tab') {
      e.preventDefault();
      this.cycleWithinCell(this.currentCellEl(), !e.shiftKey);
    }
    return;
  }
  // WR-05: in navigation mode, only hijack arrow/Home/End/Page keys when focus is ON a
  // grid cell. An inner control reached WITHOUT Enter (e.g. a header filter <input> the
  // user clicked into directly, or a per-cell control tabbed/clicked to) must keep its
  // NATIVE key behavior — caret movement, option cycling, etc. e.target is the deepest
  // focused node; if it is not itself a [data-grid-cell], let the event pass through.
  const tgt = e.target;
  if (!tgt || !tgt.hasAttribute || !tgt.hasAttribute('data-grid-cell')) return;
  // Navigation mode — compute fresh locals, write $data inside the helper, thread them out.
  // nextIsHeader is threaded alongside nextRow/nextCol so the focus seam never re-reads the
  // async-stale $data.activeIsHeader after a header crossing (React ROZ138 / Angular signal —
  // plan-01 Pitfall 2). moveRow returns the fresh { row, isHeader }; every other branch lands
  // in the body (isHeader = false). WR-06: snapshot the PRE-move indices so the emit below
  // fires ONLY on a real move (a clamped no-op edge move leaves them identical).
  const prevRow = this._activeRow.value;
  const prevCol = this._activeColIndex.value;
  const prevIsHeader = this._activeIsHeader.value;
  const prevLevel = this._activeHeaderLevel.value;
  let nextRow = prevRow;
  let nextCol = prevCol;
  let nextIsHeader = prevIsHeader;
  // B12: the fresh post-write header LEVEL (the grouped-header analog of nextIsHeader) is
  // threaded into the focus seam so a leaf↔parent header move lands focus at the correct
  // level. moveRow returns it; the non-vertical branches keep the pre-move level.
  let nextLevel = prevLevel;
  // ── Cell-range extend (phase 51 req-7 / D-07) — Shift+Arrow extends the rectangle from
  // the active cell's leading edge. Tested BEFORE the plain arrows (a Shift+Arrow must NOT
  // fall through to a plain navigation move). Body cells only (no range from a header). The
  // extendRange call owns focus + the range-change emit, so return immediately. ──────────
  // ── §8 (260709-3qt) Ctrl/Cmd+Arrow — jump the active cell to the data-region edge (plain
  // Ctrl) or EXTEND the range to that edge (Ctrl+Shift). Body cells only (a header-active
  // Ctrl+Arrow falls through to the plain-arrow branches unchanged). Tested BEFORE the
  // Shift+Arrow / plain-arrow cascade so the modifier combo is matched first. preventDefault
  // suppresses the browser's native Ctrl+Arrow scroll/word-jump. The Ctrl+Shift branch owns
  // extendRange's focus + range-change emit (returns); the plain-Ctrl branch sets the fresh
  // nextRow/nextCol locals and FALLS THROUGH to the shared focus seam (like Ctrl+Home/End). ──
  if ((e.ctrlKey || e.metaKey) && e.shiftKey && !this._activeIsHeader.value && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
    e.preventDefault();
    if (key === 'ArrowUp') this.extendRange(-this._activeRow.value, 0);else if (key === 'ArrowDown') this.extendRange(this.bodyRowCount() - 1 - this._activeRow.value, 0);else if (key === 'ArrowLeft') this.extendRange(0, -this._activeColIndex.value);else this.extendRange(0, this.visibleColCount() - 1 - this._activeColIndex.value);
    return;
  } else if ((e.ctrlKey || e.metaKey) && !this._activeIsHeader.value && (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight')) {
    e.preventDefault();
    this.clearRange();
    if (key === 'ArrowUp') {
      nextRow = this.gotoRowEdge(false);
      nextIsHeader = false;
    } else if (key === 'ArrowDown') {
      nextRow = this.gotoRowEdge(true);
      nextIsHeader = false;
    } else if (key === 'ArrowLeft') {
      nextCol = this.gotoColEdge(false);
    } else {
      nextCol = this.gotoColEdge(true);
    }
  } else if (key === 'ArrowRight' && e.shiftKey && !this._activeIsHeader.value) {
    e.preventDefault();
    this.extendRange(0, 1);
    return;
  } else if (key === 'ArrowLeft' && e.shiftKey && !this._activeIsHeader.value) {
    e.preventDefault();
    this.extendRange(0, -1);
    return;
  } else if (key === 'ArrowDown' && e.shiftKey && !this._activeIsHeader.value) {
    e.preventDefault();
    this.extendRange(1, 0);
    return;
  } else if (key === 'ArrowUp' && e.shiftKey && !this._activeIsHeader.value) {
    e.preventDefault();
    this.extendRange(-1, 0);
    return;
  } else if (key === 'ArrowRight') {
    e.preventDefault();
    this.clearRange();
    nextCol = this.moveCol(1);
  } else if (key === 'ArrowLeft') {
    e.preventDefault();
    this.clearRange();
    nextCol = this.moveCol(-1);
  } else if (key === 'ArrowDown') {
    e.preventDefault();
    this.clearRange();
    const m = this.moveRow(1);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'ArrowUp') {
    e.preventDefault();
    this.clearRange();
    const m = this.moveRow(-1);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'PageDown') {
    e.preventDefault();
    const m = this.moveRow(this.GRID_PAGE_STEP);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'PageUp') {
    e.preventDefault();
    const m = this.moveRow(-this.GRID_PAGE_STEP);
    nextRow = m.row;
    nextCol = m.col;
    nextIsHeader = m.isHeader;
    nextLevel = m.level;
  } else if (key === 'Home') {
    e.preventDefault();
    if (e.ctrlKey || e.metaKey) {
      const s = this.gotoStart();
      nextRow = s.row;
      nextCol = s.col;
      nextIsHeader = false;
    } else {
      nextCol = this.gotoColEdge(false);
    }
  } else if (key === 'End') {
    e.preventDefault();
    if (e.ctrlKey || e.metaKey) {
      const en = this.gotoEnd();
      nextRow = en.row;
      nextCol = en.col;
      nextIsHeader = false;
    } else {
      nextCol = this.gotoColEdge(true);
    }
  }
  // ── Clipboard (phase 51 req-8 / D-03) — Ctrl/Cmd+C copies the range as TSV; Ctrl/Cmd+V
  // pastes TSV into the range under the D-03 skip rule. Placed BEFORE the printable-key
  // edit-entry branch (which excludes ctrl/meta) so the shortcuts are never swallowed as a
  // type-to-edit char. Copy/paste act on the whole range (or the single active cell). B11:
  // gated by clipboardActiveAllowed() (== !activeIsHeader) so a header-active Ctrl+C/Ctrl+V
  // falls through to NATIVE behavior — never preventDefault'd, never a silent body mutation
  // (copyRange/pasteRange also self-guard; the verb guard is what plan 63-09's Cut reuses). ──
  else if ((key === 'c' || key === 'C') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
    e.preventDefault();
    this.copyRange();
    return;
  } else if ((key === 'v' || key === 'V') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
    e.preventDefault();
    this.pasteRange();
    return;
  }
  // ── C3 (phase 63 wave-9) — Ctrl/Cmd+X CUTS the range: copy the range as TSV then clear the
  // source cells through the SAME write-funnel as paste (one writeData). Same B11 gate as
  // Ctrl+C/Ctrl+V (clipboardActiveAllowed) so a header-active Ctrl+X falls through to NATIVE cut
  // and never silently clears a body cell (cutRange also self-guards). Placed beside the C/V
  // shortcuts, BEFORE the printable-key edit-entry branch (which excludes ctrl/meta). ──
  else if ((key === 'x' || key === 'X') && (e.ctrlKey || e.metaKey) && this.clipboardActiveAllowed()) {
    e.preventDefault();
    this.cutRange();
    return;
  }
  // ── 260709-8ct (grid-wide undo/redo) — Ctrl/Cmd+Z undoes; Ctrl/Cmd+Y OR Ctrl/Cmd+Shift+Z
  // redoes. Undoable-gated (`$props.undoable`) — when off, neither preventDefault nor
  // undo()/redo() runs, so a shipped grid with undoable unset is byte-behaviorally unchanged
  // (the browser's own native undo/redo, if any, still fires). NOT clipboardActiveAllowed-
  // gated (unlike Ctrl+C/V/X/Delete above): undo/redo is GRID-WIDE and must work regardless of
  // whether a header or body cell is active. Tested the Ctrl+Shift+Z (redo) combo BEFORE the
  // plain Ctrl+Z (undo) branch so a Shift+Z never falls into undo.
  else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey) && e.shiftKey) {
    if (this.undoable) {
      e.preventDefault();
      this.redo();
      return;
    }
  } else if ((key === 'y' || key === 'Y') && (e.ctrlKey || e.metaKey)) {
    if (this.undoable) {
      e.preventDefault();
      this.redo();
      return;
    }
  } else if ((key === 'z' || key === 'Z') && (e.ctrlKey || e.metaKey)) {
    if (this.undoable) {
      e.preventDefault();
      this.undo();
      return;
    }
  }
  // ── §7 (260709-3qt) — Delete/Backspace CLEARS the active cell / range through the SAME
  // write-funnel as Cut (applyGridToRange of an empty grid), MINUS the clipboard copy. B11-gated
  // by clipboardActiveAllowed so a header-active Delete/Backspace falls through to NATIVE behavior
  // (never a silent body mutation). The top-of-handler editing early-returns + the line-39
  // data-grid-cell guard keep this to navigation mode; applyGridToRange skips read-only/non-editable
  // cells. Reversible via Ctrl+Z when `undoable` is on (260709-8ct) — clearActiveRange funnels
  // through the SAME writeData seam undo/redo replay through, so no separate inverse machinery
  // is needed here.
  else if ((key === 'Delete' || key === 'Backspace') && this.clipboardActiveAllowed()) {
    e.preventDefault();
    this.clearActiveRange();
    return;
  }
  // ── §8 (260709-3qt) — Ctrl/Cmd+A selects the WHOLE BODY range (drives the same range corners
  // shift+arrow uses). preventDefault ALWAYS so the page is never selected in grid mode; only a
  // body-active Ctrl+A builds the range (a header-active Ctrl+A is a no-op — selects nothing). ──
  else if ((key === 'a' || key === 'A') && (e.ctrlKey || e.metaKey)) {
    e.preventDefault();
    if (!this._activeIsHeader.value) this.selectAllBody();
    return;
  }
  // ── Full-row edit entry (phase 51 req-6 / D-06) — Shift+F2 on an editable active cell puts
  // EVERY editable cell in the active row into edit at once. Tested BEFORE the plain F2 branch
  // (a Shift+F2 must NOT fall through to single-cell F2). Shift+F2 was chosen for the lowest
  // collision risk against the Phase-49 keymap. Gated by isActiveCellEditable() (the row has
  // at least the active editable column); a non-editable active cell falls through unchanged.
  else if (key === 'F2' && e.shiftKey && this.isActiveCellEditable()) {
    e.preventDefault();
    this.beginRowEdit((this._rows.value || [])[this._activeRow.value]);
    return;
  }
  // ── Boolean in-place toggle (design doc 2026-07-05, Change 1) — a built-in
  // editor:'checkbox' cell toggles + commits INSTANTLY on Space/Enter/F2, no editor opens
  // (the spreadsheet-standard shape for a two-state value). Tested BEFORE the generic
  // Enter/F2 edit-entry branch below (a checkbox cell must never fall into the open-an-
  // editor ceremony) and gated the SAME way (isActiveCellEditable) plus editorTypeOf ===
  // 'checkbox'. Full-row edit mode is unaffected — the editingRowIndex early return at the
  // top of onGridKeyDown already excludes it.
  else if ((key === 'Enter' || key === 'F2' || key === ' ') && this.isActiveCellEditable() && this.editorTypeOf(this.activeCellColumnId()) === 'checkbox') {
    e.preventDefault();
    this.toggleActiveBooleanCell();
    return;
  }
  // ── Edit-entry (phase 51 req-1/3, D-05) — BEFORE the reserved enterControl branch.
  // Gated by isActiveCellEditable(): a non-editable active cell falls through to
  // enterControl (the Phase-49 behavior is unchanged). F2/Enter seed the EXISTING value
  // (in-place edit); a single printable char (no Ctrl/Meta/Alt) REPLACES the value.
  else if ((key === 'Enter' || key === 'F2') && this.isActiveCellEditable()) {
    e.preventDefault();
    this.beginEdit(this._activeRow.value, this._activeColIndex.value, null);
    return;
  } else if (this.isActiveCellEditable() && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && this.editorTypeOf(this.activeCellColumnId()) !== 'checkbox') {
    // B24: a printable key only SEEDS a draft on a free-text editor (text/number). A
    // checkbox/select/date editor must NOT take the typed char as its value (it would
    // force-check the checkbox, seed a garbage select option, or corrupt the date) — open
    // those with the EXISTING value (seed=null), identical to the F2/Enter in-place entry.
    // Checkbox is excluded entirely (type-to-edit disabled — the branch above already
    // handles Space/Enter/F2; any OTHER printable key on a checkbox cell is a no-op).
    e.preventDefault();
    const editType = this.editorTypeOf(this.activeCellColumnId());
    const seed = editType === 'text' || editType === 'number' ? key : null;
    this.beginEdit(this._activeRow.value, this._activeColIndex.value, seed);
    return;
  }
  // ── C2 (phase 63 wave-8): Enter on a GROUP-HEADER cell toggles that group's collapse/
  // expand (APG treegrid). A group cell is NON-editable (isActiveCellEditable=false, the
  // verified invariant) so it never hits the edit branches above and would otherwise fall to
  // enterControl() — which merely FOCUSES the group-toggle button (requiring a second key).
  // Route it to the SAME onToggleExpand path the chevron uses (group rows ride the expand
  // model) so one Enter toggles the group. Body cells only (a header-active Enter is unchanged);
  // ($data.rows || [])[$data.activeRow] is the active flattened row (page-relative non-virtual /
  // full-model virtual — both index $data.rows). Placed BEFORE the reserved enterControl branch.
  else if (key === 'Enter' && !this._activeIsHeader.value && this.rowIsGrouped((this._rows.value || [])[this._activeRow.value])) {
    e.preventDefault();
    // C2 (phase 63 wave-11) — re-seat focus after the group collapse/expand re-render so the
    // active cell never drops focus OUT of the grid. onToggleExpand flips the expand model →
    // the tbody re-renders (the group's leaf rows appear/disappear). The active GROUP-HEADER
    // row index is UNCHANGED (a group header is never hidden by its OWN collapse), but on the
    // fine-grained-reactive targets (Solid especially) that re-render REPLACES the active cell's
    // DOM node, dropping keyboard focus into <body> — the active STATE stays on the group header
    // while DOM focus is lost (the treegrid collapsed-coherence gap; the 63-07 Solid grouping-
    // settling fragility class). Capture the active coords BEFORE the toggle (React-stale-safe —
    // onToggleExpand's expand-model write is an async setState on React) and re-seat focus via the
    // SAME deferred rAF-poll recovery B25 uses (resolveCellEl retries across the async re-render
    // until the group-header cell re-commits). The 5 sync targets resolve on attempt 1 (focus is
    // already there → a harmless no-op re-focus); Solid retries until its grouping graph settles.
    const grpRow = this._activeRow.value;
    const grpCol = this._activeColIndex.value;
    this.onToggleExpand((this._rows.value || [])[this._activeRow.value], e);
    // guardMoved=true: the group header row is UNCHANGED by its own collapse, so a stale late
    // rAF poll must not steal focus back after the user has already ArrowDown'd to another row.
    this.recoverGridFocus(String(grpRow), grpCol, null, true);
    return;
  } else if (key === 'Enter' || key === 'F2') {
    e.preventDefault();
    this.enterControl();
    return;
  } else return;
  // THE seam — built from the SAME fresh post-write locals (Pitfall 2). Always re-assert
  // focus on the resolved cell (harmless on a no-op clamp; corrects any drift otherwise).
  this.focusActiveCell(nextRow, nextCol, nextIsHeader, nextLevel);
  // WR-06: the D-02 activecell-change event fires ONLY when the resolved cell actually
  // changed. A clamped no-op edge move (ArrowLeft at col 0, ArrowDown at the page-last
  // row, …) leaves the indices identical → no spurious emit (a no-op is not a navigation).
  // B12: a header-LEVEL move (leaf↔parent, same colIndex) is a real navigation too.
  // C1 (phase 63 wave-6): the emitted rowIndex is the ABSOLUTE display-order index (toAbsRow) —
  // keyboard nav never crosses a page (D-06), so nextRow is in the current page slice and
  // toAbsRow adds the live page offset (0 in virtual mode where activeRow is already absolute).
  // The change-detection comparison stays in the PAGE-RELATIVE space (nextRow vs prevRow).
  if (nextRow !== prevRow || nextCol !== prevCol || nextIsHeader !== prevIsHeader || nextLevel !== prevLevel) {
    // Mirror getActiveCell's shape (this payload + getActiveCell are documented to speak the
    // SAME language): a header cell has no body-row index, so emit rowIndex:null + isHeader:true
    // rather than a bogus toAbsRow(nextRow) — which would compute a real body-row absolute index
    // for a HEADER move, misleading a consumer into thinking that body row is the active cell.
    this.dispatchEvent(new CustomEvent("activecell-change", {
      detail: nextIsHeader ? {
        rowIndex: null,
        colIndex: nextCol,
        isHeader: true
      } : {
        rowIndex: this.toAbsRow(nextRow),
        colIndex: nextCol,
        isHeader: false
      },
      bubbles: true,
      composed: true
    }));
  }
};

  syncActiveFromEvent = (e: any) => {
  if (!this.isGrid() || !e) return;
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null) return;
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(col)) return;
  // #9: snapshot the PRE-write active position so we can bump the focus-intent epoch ONLY when
  // this focusin genuinely MOVES the active cell (a click landing on a NEW cell). A no-op focusin
  // — focus arriving on the ALREADY-active cell, e.g. a scroll/page-switch poll's own el.focus()
  // or focusActiveCell's synchronous re-seat — must NOT bump, or it would abort a legitimate
  // in-flight recovery on its own settling frames (the poll would see a changed epoch and quit).
  const prevIsHeader = this._activeIsHeader.value;
  const prevRow = this._activeRow.value;
  const prevCol = this._activeColIndex.value;
  const prevLevel = this._activeHeaderLevel.value;
  const isHeader = rowAttr === '__header';
  this._activeIsHeader.value = isHeader;
  let movedRow = prevRow;
  let movedLevel = prevLevel;
  if (isHeader) {
    // B12: a click/focus onto a grouped header cell must capture its header LEVEL too, so the
    // roving model + a subsequent ArrowUp/ArrowDown resolve from the correct level (not a stale
    // one). data-header-level is an integer marker on the <th>; fall back to the leaf level.
    const lvlAttr = cellEl.getAttribute('data-header-level');
    const lvl = lvlAttr != null ? parseInt(lvlAttr, 10) : this.headerLeafLevel();
    movedLevel = Number.isFinite(lvl) ? lvl : this.headerLeafLevel();
    this._activeHeaderLevel.value = movedLevel;
  } else {
    const row = parseInt(rowAttr, 10);
    if (Number.isFinite(row)) {
      movedRow = row;
      this._activeRow.value = row;
    }
  }
  this._activeColIndex.value = col;
  // #9: a genuine active-cell MOVE is a fresh focus intent — supersede any pending async focus
  // poll (scroll-to / page-switch). Compare against the PRE-write snapshot: bump only when the
  // header-flag, column, or (per mode) the header LEVEL / body ROW actually changed.
  if (isHeader !== prevIsHeader || col !== prevCol || (isHeader ? movedLevel !== prevLevel : movedRow !== prevRow)) {
    this.focusIntentEpoch = this.focusIntentEpoch + 1;
  }
  // A plain focus collapses any range back to the single active cell — EXCEPT (a) the
  // programmatic settle of an in-flight extendRange (rangeTransition): that focus move lands
  // ON the new range-focus corner and must NOT wipe the range we just set; and (b) the
  // focusin that follows a Shift+Click (rangeClickPending): @mousedown already set the range
  // BEFORE this focusin fires, and a focusin carries no reliable shiftKey, so the @mousedown
  // path owns the shift case and flags it here so the collapse is skipped.
  if (this.rangeTransition) {
    this.rangeTransition = false;
  } else if (this.rangeClickPending) {
    this.rangeClickPending = false;
  } else {
    this.clearRange();
  }
  // The cell box (not an inner control) receiving focus = navigation mode.
  if (tgt === cellEl) this._activeInControl.value = false;
};

  onGridMouseDown = (e: any) => {
  if (!this.isGrid() || !e) return;
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  // §6: a plain mousedown inside the fill handle is owned by the handle's own pointerdown drag —
  // never begin a range paint from it (the shift path never lands on the 8px handle).
  if (!e.shiftKey && tgt.closest('[data-fill-handle]')) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
  const row = parseInt(rowAttr, 10);
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(row) || !Number.isFinite(col)) return;
  if (e.shiftKey) {
    // Shift+Click: set the moving corner (keeping the anchor) and flag rangeClickPending so the
    // follow-up focusin does not collapse the range (a focusin carries no reliable shiftKey).
    this.setRangeFocus(row, col);
    this._activeIsHeader.value = false;
    this._activeRow.value = row;
    this._activeColIndex.value = col;
    this.rangeClickPending = true;
    return;
  }
  // §6 plain mousedown → begin a document-level drag-select anchored at this cell. The mousedown's
  // native focusin commits the ACTIVE cell to (row,col); beginRangeDrag's first cross-cell
  // pointermove paints the range via setRangeFocus (anchored at the active cell). A mousedown with
  // no move collapses to a single active cell (no range).
  this.beginRangeDrag(row, col);
};

  onGridDblClick = (e: any) => {
  if (!this.isGrid() || !e) return;
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
  const row = parseInt(rowAttr, 10);
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(row) || !Number.isFinite(col)) return;
  // NB the local is `rowObj` (NOT `activeRow`): $data.activeRow lowers to the bare React state
  // binding `activeRow`, so a `const activeRow = …` local self-shadows it (TS2448 TDZ — the
  // visibleColCount `rowList` self-shadow class). ($data.rows || [])[row] is the active flattened
  // row (page-relative non-virtual / full-model virtual — both index $data.rows, matching the C2
  // Enter-on-group path + syncActiveFromEvent's row parse).
  const rowObj = (this._rows.value || [])[row];
  if (this.rowIsGrouped(rowObj)) {
    // Group-header cell → toggle its collapse/expand through the SAME onToggleExpand funnel the
    // chevron uses (mirrors the C2 Enter-on-group path verbatim), then re-seat focus after the
    // re-render (guardMoved=true — the group-header row is unchanged by its own collapse, so a
    // stale late rAF must not steal focus back after a subsequent nav).
    e.preventDefault();
    this.onToggleExpand(rowObj, e);
    this.recoverGridFocus(String(row), col, null, true);
    return;
  }
  // Editable body cell → open its editor (seed=null → seed the EXISTING value, the in-place F2/
  // Enter entry). A non-editable body cell is a no-op: the cell stays active (focusin already set
  // it + the §1 ring), matching the spreadsheet display-vs-edit convention.
  const colId = this.columnIdAt(row, col);
  if (colId != null && this.columnEditable(colId)) {
    e.preventDefault();
    this.beginEdit(row, col, null);
  }
};

  onGridClick = (e: any) => {
  if (!this.isGrid() || !e) return;
  if (!this.singleClickEdit) return;
  if (e.shiftKey) return;
  // §6 (260709-3qt): a drag-select that MOVED must never open the editor — the editor opens only
  // on a genuine mouseup-no-drag click. beginRangeDrag resets rangeDragMoved=false per gesture, so
  // the flag is always fresh; consume it here so a subsequent plain click still edits.
  if (this.rangeDragMoved) {
    this.rangeDragMoved = false;
    return;
  }
  const tgt = e.target;
  if (!tgt || !tgt.closest) return;
  const cellEl = tgt.closest('[data-grid-cell]');
  if (!cellEl) return;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return;
  const row = parseInt(rowAttr, 10);
  const col = parseInt(colAttr, 10);
  if (!Number.isFinite(row) || !Number.isFinite(col)) return;
  // Already editing THIS exact cell → no-op (a click inside an open editor must not re-open it).
  if (this._editingRow.value === row && this._editingCol.value === col) return;
  const colId = this.columnIdAt(row, col);
  if (colId != null && this.columnEditable(colId)) this.beginEdit(row, col, null);
};

  onGridFocusOut = (e: any) => {
  if (!this.isGrid() || !this._activeInControl.value) return;
  const next = e ? e.relatedTarget : null;
  const cellEl = this.currentCellEl();
  if (!cellEl || !next || !cellEl.contains(next)) this._activeInControl.value = false;
};

  recoverGridFocus = (rowKey: any, col: any, level: any, guardMoved = false) => {
  if (!this.gridRoot) return;
  let attempts = 0;
  const tryFocus = () => {
    if (guardMoved) {
      const ae = this.gridRoot && this.gridRoot.getRootNode ? this.gridRoot.getRootNode().activeElement : null;
      const aeCell = ae && ae.closest ? ae.closest('[data-grid-cell]') : null;
      if (aeCell && this.gridRoot.contains(aeCell)) {
        const aeRow = aeCell.getAttribute('data-row');
        if (aeRow != null && aeRow !== rowKey) return;
      }
    }
    const el = this.resolveCellEl(rowKey, col, level);
    if (el) {
      el.focus();
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};

  clampActiveCell = (rowCount: any, colCount: any) => {
  if (!this.isGrid()) return;
  // B8/B23 React-stale guard: the bounds come from the FRESH model the caller (refreshRowModel)
  // just derived and passes in — NEVER re-read $data.rows here. `$data.rows = nextRows` is an
  // async useState on React, so bodyRowCount()/visibleColCount() would see the PRE-change model
  // and SKIP a legitimate shrink-clamp (a filter-to-fewer left the active cell / range corners
  // out of bounds on React only). Falls back to the live helpers when called without bounds.
  const colN = colCount != null ? colCount : this.visibleColCount();
  const rowN = rowCount != null ? rowCount : this.bodyRowCount();
  // B25: BEFORE re-indexing, detect whether DOM focus currently rests on a BODY cell that the
  // shrink will REMOVE (its row index exceeds the new bounds). We run synchronously BEFORE the
  // framework commits the new tbody (refreshRowModel calls us right after `$data.rows = nextRows`
  // — true on all six, incl React's async setState), so the doomed cell + its focus are still
  // observable in the OLD DOM. Only then do we arm a focus RECOVERY (after the re-render), so a
  // programmatic shrink (collapseAll/pageSize/data swap) never drops keyboard focus to <body>.
  // Focus elsewhere — a header sort button, an external control, an unfocused grid — is NOT a
  // doomed body cell, so recovery never STEALS focus on a routine re-sort/filter.
  // The recovery TARGET is derived from the doomed cell's OWN DOM coords (doomedRow/doomedCol),
  // NOT $data.activeRow/activeColIndex — those are React-stale (ROZ138) when a focusCell + the
  // shrink run inside one synchronous handler (focusCell's setActiveRow has not committed). The
  // DOM coords are always fresh.
  let recoverFocus = false;
  let doomedRow = -1;
  let doomedCol = 0;
  if (this.gridRoot) {
    const rootNode = this.gridRoot.getRootNode ? this.gridRoot.getRootNode() : null;
    const focusedEl = rootNode ? rootNode.activeElement : null;
    const focusedCell = focusedEl && focusedEl.closest ? focusedEl.closest('[data-grid-cell]') : null;
    if (focusedCell && this.gridRoot.contains(focusedCell)) {
      const fRowAttr = focusedCell.getAttribute('data-row');
      const fColAttr = focusedCell.getAttribute('data-col-index');
      if (fRowAttr != null && fRowAttr !== '__header') {
        const fr = parseInt(fRowAttr, 10);
        const fc = parseInt(fColAttr, 10);
        if (Number.isFinite(fr) && fr > rowN - 1) {
          recoverFocus = true;
          doomedRow = fr;
          doomedCol = Number.isFinite(fc) ? fc : 0;
        }
      }
    }
  }
  const maxCol = colN - 1;
  const col = this.clamp(this._activeColIndex.value, 0, maxCol < 0 ? 0 : maxCol);
  if (col !== this._activeColIndex.value) this._activeColIndex.value = col;
  // B6: an empty / all-filtered grid has NO body cell to hold the active cell. Park the active
  // cell on the leaf-header fallback (col 0) so the roving tab-stop stays on a REAL cell (never
  // an absent body cell → focus lost into <body>), and flag it so the next non-empty refresh
  // re-seats a body cell. The cellTabindex empty-fallback keeps exactly one header tab-stop.
  if (rowN <= 0) {
    this._activeIsHeader.value = true;
    this._activeHeaderLevel.value = this.headerLeafLevel();
    this._activeColIndex.value = 0;
    // B6 — `gridEmptyFallback` is a plain component-scope `let` (NOT $data): clampActiveCell is
    // reached through the mount-time refreshRowModel closure, so a `$data` READ here binds the
    // async-stale mount-time value on React (setState is async — the rangeActive / B23-nextRows
    // class). A synchronously-written plain `let` is read FRESH on all six so the empty→non-empty
    // recovery branch below actually runs on React too.
    this.gridEmptyFallback = true;
    this.clampRange(rowN - 1, colN - 1);
    // B25 does NOT actively focus in the EMPTY-grid case: B6 already keeps the grid keyboard-
    // reachable via the roving tab-stop on the header fallback (a tabindex=0, not a focus grab).
    // Moving DOM focus here would steal focus AND — on React — the fallback's @focusin
    // (setActiveIsHeader true) races the next clear-filter re-seat, leaving the tab-stop stuck on
    // the header. Focus recovery is for a shrink that leaves a VALID BODY cell to land on (below).
    return;
  }
  // B6 recovery: the body model returned. If we were parked on the empty-grid header fallback,
  // re-seat a valid BODY active cell (row 0) so the roving tab-stop lands back on a real body
  // cell. A user-driven header position (not the empty fallback) is left untouched.
  if (this.gridEmptyFallback) {
    this.gridEmptyFallback = false;
    this._activeIsHeader.value = false;
    this._activeRow.value = 0;
  }
  if (!this._activeIsHeader.value) {
    const lastRow = rowN - 1;
    const maxRow = lastRow < 0 ? 0 : lastRow;
    const row = this.clamp(this._activeRow.value, 0, maxRow);
    if (row !== this._activeRow.value) this._activeRow.value = row;
  }
  // B8: clamp the range-selection corners to the same FRESH bounds (a sort/filter/paginate that
  // shrank the model would otherwise leave a stale rectangle → phantom copy rows + an
  // out-of-bounds getSelectedRange). Reconcile-only (no range-change emit here, B18/B19).
  this.clampRange(rowN - 1, colN - 1);
  // B25: recover DOM focus onto the re-indexed valid cell (deferred until the new model renders)
  // when the shrink removed the focused cell. The target is the DOOMED cell's own coords clamped
  // into the fresh bounds (React-stale-safe — see the doomedRow/doomedCol note above).
  if (recoverFocus) {
    const recRow = this.clamp(doomedRow, 0, rowN - 1);
    const recCol = this.clamp(doomedCol, 0, maxCol < 0 ? 0 : maxCol);
    this.recoverGridFocus(String(recRow), recCol, null);
  }
};

  gridEmptyFallback = false;

  rangeTransition = false;

  rangeClickPending = false;

  rangeActive = false;

  inRange = (rIdx: any, cIdx: any) => {
  const a = this._rangeAnchor.value;
  const f = this._rangeFocus.value;
  if (!a || !f) return false;
  const r0 = a.rowIndex < f.rowIndex ? a.rowIndex : f.rowIndex;
  const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
  const c0 = a.colIndex < f.colIndex ? a.colIndex : f.colIndex;
  const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
  return rIdx >= r0 && rIdx <= r1 && cIdx >= c0 && cIdx <= c1;
};

  getSelectedRange = () => {
  // B8: clamp the corners to the CURRENT bounds ON READ so the verb (and the range-change emit
  // payload) never reports a corner past a shrunken model — React-stale-safe (the eager
  // refreshRowModel clamp is async-defeated on React; this read-time clamp is the guarantee).
  const a = this._rangeAnchor.value;
  const f = this._rangeFocus.value;
  if (!a && !f) return {
    anchor: null,
    focus: null
  };
  const maxRow = this.bodyRowCount() - 1;
  const maxCol = this.visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return {
    anchor: null,
    focus: null
  };
  const clampCorner = (c: any) => c == null ? null : {
    rowIndex: this.clamp(c.rowIndex, 0, maxRow),
    colIndex: this.clamp(c.colIndex, 0, maxCol)
  };
  return {
    anchor: clampCorner(a),
    focus: clampCorner(f)
  };
};

  isFillHandleCell = (rIdx: any, cIdx: any) => {
  const a = this._rangeAnchor.value;
  const f = this._rangeFocus.value;
  if (!a || !f) return false;
  const r1 = a.rowIndex > f.rowIndex ? a.rowIndex : f.rowIndex;
  const c1 = a.colIndex > f.colIndex ? a.colIndex : f.colIndex;
  return rIdx === r1 && cIdx === c1;
};

  emitRangeChange = (anchor: any, focus: any) => {
  this.dispatchEvent(new CustomEvent("range-change", {
    detail: {
      anchor,
      focus
    },
    bubbles: true,
    composed: true
  }));
};

  extendRange = (dRow: any, dCol: any) => {
  if (this._activeIsHeader.value) return;
  const maxRow = this.bodyRowCount() - 1;
  const maxCol = this.visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return;
  // Seed the anchor + focus from the active cell on the FIRST extend (no range yet).
  let anchor = this._rangeAnchor.value;
  let focus = this._rangeFocus.value;
  const hadRange = !!(anchor && focus);
  if (!anchor || !focus) {
    anchor = {
      rowIndex: this._activeRow.value,
      colIndex: this._activeColIndex.value
    };
    focus = {
      rowIndex: this._activeRow.value,
      colIndex: this._activeColIndex.value
    };
  }
  const nextRow = this.clamp(focus.rowIndex + dRow, 0, maxRow);
  const nextCol = this.clamp(focus.colIndex + dCol, 0, maxCol);
  const nextFocus = {
    rowIndex: nextRow,
    colIndex: nextCol
  };
  this._rangeAnchor.value = anchor;
  this._rangeFocus.value = nextFocus;
  this.rangeActive = true;
  // Keep the active cell tracking the moving focus corner (so a follow-up F2 / arrow acts
  // from the range's leading edge, the spreadsheet convention).
  this._activeRow.value = nextRow;
  this._activeColIndex.value = nextCol;
  // Suppress the focus-move's @focusin clearRange (no shiftKey on a programmatic focus): the
  // settle on the new focus corner is part of THIS range extension, not a fresh navigation.
  this.rangeTransition = true;
  this.focusActiveCell(nextRow, nextCol, false);
  // B18: emit range-change ONLY on an actual change. A clamped no-op (a range already exists
  // and the focus corner did not move — Shift+Arrow into the grid boundary) is not a selection
  // change → no emit. Seeding a brand-new range (no prior range) is always a change (the
  // rectangle came into existence) even if its first corner is a degenerate 1×1.
  if (!hadRange || nextRow !== focus.rowIndex || nextCol !== focus.colIndex) {
    this.emitRangeChange(anchor, nextFocus);
  }
};

  setRangeFocus = (rIdx: any, cIdx: any) => {
  const maxRow = this.bodyRowCount() - 1;
  const maxCol = this.visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return;
  let anchor = this._rangeAnchor.value;
  if (!anchor) anchor = {
    rowIndex: this._activeRow.value,
    colIndex: this._activeColIndex.value
  };
  const r = this.clamp(Math.trunc(Number(rIdx)) || 0, 0, maxRow);
  const c = this.clamp(Math.trunc(Number(cIdx)) || 0, 0, maxCol);
  const nextFocus = {
    rowIndex: r,
    colIndex: c
  };
  this._rangeAnchor.value = anchor;
  this._rangeFocus.value = nextFocus;
  this.rangeActive = true;
  this.emitRangeChange(anchor, nextFocus);
};

  selectAllBody = () => {
  const maxRow = this.bodyRowCount() - 1;
  const maxCol = this.visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return;
  const anchor = {
    rowIndex: 0,
    colIndex: 0
  };
  const focus = {
    rowIndex: maxRow,
    colIndex: maxCol
  };
  this._rangeAnchor.value = anchor;
  this._rangeFocus.value = focus;
  this.rangeActive = true;
  this.emitRangeChange(anchor, focus);
};

  clearRange = () => {
  // B19: gate on the SYNCHRONOUS rangeActive mirror, NOT a $data re-read. clearRange runs twice
  // in one plain-arrow keydown (explicit collapse + the focusin after the programmatic focus
  // move); on React `$data.rangeAnchor = null` is async, so a `$data.rangeAnchor == null` guard
  // would let the SECOND call through and emit a duplicate range-change. rangeActive flips
  // synchronously → the second call returns here.
  if (!this.rangeActive) return;
  this.rangeActive = false;
  this._rangeAnchor.value = null;
  this._rangeFocus.value = null;
  this.emitRangeChange(null, null);
};

  clampRange = (maxRowArg: any, maxColArg: any) => {
  const a = this._rangeAnchor.value;
  const f = this._rangeFocus.value;
  if (!a && !f) return;
  // Bounds passed from the FRESH model (clampActiveCell → refreshRowModel's nextRows) so the
  // shrink-clamp is React-stale-safe; fall back to the live helpers for a direct call.
  const maxRow = maxRowArg != null ? maxRowArg : this.bodyRowCount() - 1;
  const maxCol = maxColArg != null ? maxColArg : this.visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) {
    this._rangeAnchor.value = null;
    this._rangeFocus.value = null;
    this.rangeActive = false;
    return;
  }
  if (a) {
    const ar = this.clamp(a.rowIndex, 0, maxRow);
    const ac = this.clamp(a.colIndex, 0, maxCol);
    if (ar !== a.rowIndex || ac !== a.colIndex) this._rangeAnchor.value = {
      rowIndex: ar,
      colIndex: ac
    };
  }
  if (f) {
    const fr = this.clamp(f.rowIndex, 0, maxRow);
    const fc = this.clamp(f.colIndex, 0, maxCol);
    if (fr !== f.rowIndex || fc !== f.colIndex) this._rangeFocus.value = {
      rowIndex: fr,
      colIndex: fc
    };
  }
};

  announce = (msg: any) => {
  this._pasteAnnounce.value = msg != null ? msg : '';
};

  clipboardActiveAllowed = () => !this._activeIsHeader.value;

  fieldOfColId = (colId: any) => {
  const d = this.defFor(colId);
  return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};

  normalizedRange = () => {
  const a = this._rangeAnchor.value;
  const f = this._rangeFocus.value;
  if (!a || !f) return null;
  const maxRow = this.bodyRowCount() - 1;
  const maxCol = this.visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return null;
  const ar = this.clamp(a.rowIndex, 0, maxRow);
  const ac = this.clamp(a.colIndex, 0, maxCol);
  const fr = this.clamp(f.rowIndex, 0, maxRow);
  const fc = this.clamp(f.colIndex, 0, maxCol);
  return {
    r0: ar < fr ? ar : fr,
    r1: ar > fr ? ar : fr,
    c0: ac < fc ? ac : fc,
    c1: ac > fc ? ac : fc
  };
};

  escapeTsvField = (s: any) => {
  if (s.indexOf('\t') >= 0 || s.indexOf('\n') >= 0 || s.indexOf('\r') >= 0 || s.indexOf('"') >= 0) {
    return '"' + s.replace(/"/g, '""') + '"';
  }
  return s;
};

  rangeToTsv = () => {
  const box = this.normalizedRange();
  const r0 = box ? box.r0 : this._activeRow.value;
  const r1 = box ? box.r1 : this._activeRow.value;
  const c0 = box ? box.c0 : this._activeColIndex.value;
  const c1 = box ? box.c1 : this._activeColIndex.value;
  const lines = [];
  for (let r = r0; r <= r1; r++) {
    const cells = [];
    for (let c = c0; c <= c1; c++) {
      const v = this.cellValueAt(r, c);
      cells.push(this.escapeTsvField(v == null ? '' : String(v)));
    }
    lines.push(cells.join('\t'));
  }
  return lines.join('\n');
};

  parseTsv = (text: any) => {
  const str = text != null ? String(text) : '';
  // CR-03: length guard BEFORE the parse — an empty string is a no-op, and a pathologically
  // large clipboard payload (>2M chars) is rejected outright (DoS-shaped input) before the
  // single-pass scan allocates a cell-per-character grid.
  if (str === '' || str.length > 2000000) return [];
  // B10: a quote-aware single-pass state machine (replaces the naive split, which corrupted a
  // cell containing a tab/newline). A field that OPENS with a double-quote is "quoted": tabs,
  // newlines, and doubled quotes ("") inside it are literal content until the closing quote;
  // an unquoted field ends at the next tab/newline. CR/LF and CRLF all delimit a row.
  const rows = [];
  let row = [];
  let field = '';
  let inQuotes = false;
  let i = 0;
  const n = str.length;
  while (i < n) {
    const ch = str[i];
    if (inQuotes) {
      if (ch === '"') {
        if (i + 1 < n && str[i + 1] === '"') {
          field = field + '"';
          i = i + 2;
          continue;
        }
        inQuotes = false;
        i = i + 1;
        continue;
      }
      field = field + ch;
      i = i + 1;
      continue;
    }
    if (ch === '"' && field === '') {
      inQuotes = true;
      i = i + 1;
      continue;
    }
    if (ch === '\t') {
      row.push(field);
      field = '';
      i = i + 1;
      continue;
    }
    if (ch === '\r') {
      if (i + 1 < n && str[i + 1] === '\n') i = i + 1;
      row.push(field);
      field = '';
      rows.push(row);
      row = [];
      i = i + 1;
      continue;
    }
    if (ch === '\n') {
      row.push(field);
      field = '';
      rows.push(row);
      row = [];
      i = i + 1;
      continue;
    }
    field = field + ch;
    i = i + 1;
  }
  // Flush the trailing field + row.
  row.push(field);
  rows.push(row);
  // Drop a single trailing empty row (a TSV that ends with a newline → a phantom [''] row).
  if (rows.length > 1) {
    const last = rows[rows.length - 1];
    if (last.length === 1 && last[0] === '') rows.pop();
  }
  return rows;
};

  copyRange = () => {
  // B11: never copy from a header-active state (the reusable clipboard guard).
  if (!this.clipboardActiveAllowed()) return;
  if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText) return;
  try {
    const p = navigator.clipboard.writeText(this.rangeToTsv());
    if (p && p.catch) p.catch(() => {});
  } catch (err: any) {/* best-effort copy */}
};

  applyGridToRange = (grid: any, originRow: any, originCol: any) => {
  const maxRow = this.bodyRowCount() - 1;
  const maxCol = this.visibleColCount() - 1;
  if (maxRow < 0 || maxCol < 0) return {
    wrote: 0,
    total: 0
  };
  let total = 0;
  let wrote = 0;
  const committed = [];
  // Build the fresh data array incrementally so the whole paste is ONE writeData.
  let next = this.currentData();
  for (let gr = 0; gr < grid.length; gr++) {
    const r = originRow + gr;
    if (r > maxRow) break;
    const cols = grid[gr] || [];
    for (let gc = 0; gc < cols.length; gc++) {
      const c = originCol + gc;
      if (c > maxCol) break;
      total = total + 1;
      const colId = this.columnIdAt(r, c);
      if (colId == null || !this.columnEditable(colId)) continue;
      const rowObj = this.rowOriginalAt(r);
      // B9: coerce the raw TSV string to the target column's type at commit (mirrors B3's
      // single-cell commit coercion) — a numeric column commits a real Number, an empty cell
      // commits null; every other editor type passes through verbatim. No mixed/garbage types
      // ever reach the model (T-63-03-01). Validation then runs on the COERCED value.
      const value = this.coerceCellValue(colId, cols[gc]);
      // T-51-01: validate the pasted value as plain DATA before any write.
      if (this.runValidator(colId, value, rowObj) !== true) continue;
      const field = this.fieldOfColId(colId);
      const srcIndex = this.sourceIndexOfRow(r);
      const oldValue = rowObj ? rowObj[field] : null;
      next = this.replaceRowValue(next, srcIndex, field, value);
      committed.push({
        rowId: this.rowIdAt(r),
        columnId: colId,
        oldValue,
        newValue: value
      });
      wrote = wrote + 1;
    }
  }
  if (wrote > 0) {
    this.editTransition = true;
    this.writeData(next);
    this.editTransition = false;
    // One cell-edit-commit per COMMITTED cell (the per-cell event contract, D-03).
    for (let i = 0; i < committed.length; i++) this.dispatchEvent(new CustomEvent("cell-edit-commit", {
      detail: committed[i],
      bubbles: true,
      composed: true
    }));
  }
  // WR-02: announce the N-of-M summary only when at least one cell was written. When the paste
  // targeted real cells but every one was skipped (validation-failed / non-editable), announce a
  // distinct validation-failed message instead of a misleading "0 of M cells pasted".
  if (wrote > 0) this.announce(wrote + ' of ' + total + ' cells pasted');else if (total > 0) this.announce('No cells pasted — ' + total + ' cells were invalid or read-only');
  return {
    wrote,
    total
  };
};

  rowOriginalAt = (rowIndex: any) => {
  const rowList = this._rows.value || [];
  const row = rowList[rowIndex];
  return row ? row.original : null;
};

  rowIdAt = (rowIndex: any) => {
  const rowList = this._rows.value || [];
  const row = rowList[rowIndex];
  return row ? row.id : null;
};

  tileGridToBox = (grid: any, box: any) => {
  const srcRows = grid.length;
  // srcCols is the MAX row width across ALL rows (not grid[0].length): a RAGGED clipboard
  // (a later row WIDER than the first, e.g. TSV "a\tb\nc\td\te") would otherwise never read
  // the extra column and silently drop those cells. A row SHORTER than srcCols tiles its
  // missing cells as '' (the `v != null ? v : ''` coercion below), never undefined.
  let srcCols = 0;
  for (let i = 0; i < srcRows; i++) {
    const w = grid[i] && grid[i].length ? grid[i].length : 0;
    if (w > srcCols) srcCols = w;
  }
  if (srcRows <= 0 || srcCols <= 0) return grid;
  const boxRows = box.r1 - box.r0 + 1;
  const boxCols = box.c1 - box.c0 + 1;
  const rows = boxRows > srcRows ? boxRows : srcRows;
  const cols = boxCols > srcCols ? boxCols : srcCols;
  const out = [];
  for (let r = 0; r < rows; r++) {
    const srcLine = grid[r % srcRows] || [];
    const line = [];
    for (let c = 0; c < cols; c++) {
      const v = srcLine[c % srcCols];
      line.push(v != null ? v : '');
    }
    out.push(line);
  }
  return out;
};

  pasteRange = () => {
  // B11: never paste into a header-active state (the reusable clipboard guard) — a header
  // anchor would silently write body row 0 at the header's column.
  if (!this.clipboardActiveAllowed()) return;
  if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) return;
  // CR-02 (ROZ138): SNAPSHOT the destination SYNCHRONOUSLY, before the clipboard read resolves.
  // C3: the destination is the SELECTED RANGE (the tiling target) when one exists, else the
  // single active cell. $data.rangeAnchor/rangeFocus + activeRow/activeColIndex are useState-backed
  // on React; re-reading them inside the async .then() returns the mount-render stale value, so a
  // selection/cell move between Ctrl+V and the read resolving would anchor the paste wrong. Capture
  // the box + anchor now and pass them into tileGridToBox / applyGridToRange.
  const box = this.normalizedRange();
  const anchorRow = box ? box.r0 : this._activeRow.value;
  const anchorCol = box ? box.c0 : this._activeColIndex.value;
  const destBox = box || {
    r0: anchorRow,
    r1: anchorRow,
    c0: anchorCol,
    c1: anchorCol
  };
  let p: any = null;
  try {
    p = navigator.clipboard.readText();
  } catch (err: any) {
    return;
  }
  if (!p || !p.then) return;
  p.then((text: any) => {
    const grid = this.parseTsv(text);
    if (!grid.length) return;
    // C3: tile the clipboard block to fill the destination range (single→range fill,
    // smaller-tiles-into-larger); a clipboard larger than the box pastes its full block.
    const tiled = this.tileGridToBox(grid, destBox);
    this.applyGridToRange(tiled, anchorRow, anchorCol);
  }).catch(() => {});
};

  cutRange = () => {
  if (!this.clipboardActiveAllowed()) return;
  // Snapshot the source rectangle synchronously (same ROZ138 concern as pasteRange).
  const box = this.normalizedRange();
  const r0 = box ? box.r0 : this._activeRow.value;
  const r1 = box ? box.r1 : this._activeRow.value;
  const c0 = box ? box.c0 : this._activeColIndex.value;
  const c1 = box ? box.c1 : this._activeColIndex.value;
  // Copy first (best-effort) — rangeToTsv() reads the CURRENT range/active cell NOW, before the clear.
  if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
    try {
      const cp = navigator.clipboard.writeText(this.rangeToTsv());
      if (cp && cp.catch) cp.catch(() => {});
    } catch (err: any) {/* best-effort copy */}
  }
  // Clear the source: a grid of empty strings sized to the range, applied at the top-left.
  const grid = [];
  for (let r = r0; r <= r1; r++) {
    const cols = [];
    for (let c = c0; c <= c1; c++) cols.push('');
    grid.push(cols);
  }
  this.applyGridToRange(grid, r0, c0);
};

  clearActiveRange = () => {
  if (!this.clipboardActiveAllowed()) return;
  // Snapshot the source rectangle synchronously (the ROZ138 concern cutRange/pasteRange share).
  const box = this.normalizedRange();
  const r0 = box ? box.r0 : this._activeRow.value;
  const r1 = box ? box.r1 : this._activeRow.value;
  const c0 = box ? box.c0 : this._activeColIndex.value;
  const c1 = box ? box.c1 : this._activeColIndex.value;
  const grid = [];
  for (let r = r0; r <= r1; r++) {
    const cols = [];
    for (let c = c0; c <= c1; c++) cols.push('');
    grid.push(cols);
  }
  this.applyGridToRange(grid, r0, c0);
};

  tileIndex = (i: any, lo: any, hi: any) => {
  const span = hi - lo + 1;
  if (span <= 1) return lo;
  let k = (i - lo) % span;
  if (k < 0) k = k + span;
  return lo + k;
};

  fillRange = (sourceBox: any, endCell: any) => {
  // B7 (React-stale-safe): compute the EXTENDED rectangle from the gesture's FRESH endpoints —
  // the pre-drag sourceBox (∪) the drag's final end cell — NOT a $data.rangeFocus re-read. On
  // React the `up` closure captured at pointerdown reads the PRE-move range (the rectangle never
  // grows), so deriving the box from the threaded endpoints is what makes the fill cover the
  // dragged cells on React. Falls back to normalizedRange() for a no-gesture (programmatic) call.
  let box;
  if (sourceBox && sourceBox.r0 != null && endCell) {
    let r0 = sourceBox.r0;
    let r1 = sourceBox.r1;
    let c0 = sourceBox.c0;
    let c1 = sourceBox.c1;
    if (endCell.r < r0) r0 = endCell.r;
    if (endCell.r > r1) r1 = endCell.r;
    if (endCell.c < c0) c0 = endCell.c;
    if (endCell.c > c1) c1 = endCell.c;
    box = {
      r0,
      r1,
      c0,
      c1
    };
  } else {
    box = this.normalizedRange();
  }
  if (!box) return;
  const src = sourceBox && sourceBox.r0 != null ? sourceBox : {
    r0: box.r0,
    r1: box.r0,
    c0: box.c0,
    c1: box.c0
  };
  const grid = [];
  for (let r = box.r0; r <= box.r1; r++) {
    const cols = [];
    for (let c = box.c0; c <= box.c1; c++) {
      const sr = this.tileIndex(r, src.r0, src.r1);
      const sc = this.tileIndex(c, src.c0, src.c1);
      const v = this.cellValueAt(sr, sc);
      cols.push(v == null ? '' : String(v));
    }
    grid.push(cols);
  }
  this.applyGridToRange(grid, box.r0, box.c0);
};

  fillDragging = false;

  fillDragMove: any = null;

  fillDragUp: any = null;

  teardownFillDrag = () => {
  if (typeof document !== 'undefined') {
    if (this.fillDragMove) document.removeEventListener('pointermove', this.fillDragMove);
    if (this.fillDragUp) document.removeEventListener('pointerup', this.fillDragUp);
  }
  this.fillDragMove = null;
  this.fillDragUp = null;
  this.fillDragging = false;
};

  cellIndexFromPoint = (clientX: any, clientY: any) => {
  if (typeof document === 'undefined' || !document.elementFromPoint) return null;
  let el = document.elementFromPoint(clientX, clientY);
  // Pierce OPEN shadow roots (Lit): document.elementFromPoint retargets to the shadow HOST, so
  // a drag over the Lit data-table's shadow content would otherwise resolve the host (no cell)
  // and the fill never extends. Descend into each shadowRoot's own elementFromPoint until the
  // deepest element. No-op on the 5 light-DOM targets (el.shadowRoot is null).
  while (el && el.shadowRoot && el.shadowRoot.elementFromPoint) {
    const inner = el.shadowRoot.elementFromPoint(clientX, clientY);
    if (!inner || inner === el) break;
    el = inner;
  }
  if (!el || !el.closest) return null;
  const cellEl = el.closest('[data-grid-cell]');
  if (!cellEl) return null;
  const rowAttr = cellEl.getAttribute('data-row');
  const colAttr = cellEl.getAttribute('data-col-index');
  if (rowAttr == null || colAttr == null || rowAttr === '__header') return null;
  const r = parseInt(rowAttr, 10);
  const c = parseInt(colAttr, 10);
  if (!Number.isFinite(r) || !Number.isFinite(c)) return null;
  return {
    r,
    c
  };
};

  onFillHandlePointerDown = (e: any) => {
  if (!e) return;
  if (e.preventDefault) e.preventDefault();
  if (e.stopPropagation) e.stopPropagation();
  // #leak: tear down any orphaned PRIOR gesture BEFORE reassigning the module-let handlers. If a
  // pointerup was missed (pointer released off-window, context menu, alt-tab), the prior fillDrag's
  // document pointermove/pointerup stay attached; overwriting fillDragMove/fillDragUp below would
  // strand them (removeEventListener could never reach the old refs) → a permanent global
  // pointermove leak. teardownFillDrag is idempotent (no-op when nothing is attached).
  this.teardownFillDrag();
  this.fillDragging = true;
  // B7: snapshot the PRE-DRAG rectangle (the fill SOURCE) NOW, before pointermove grows the
  // range via setRangeFocus. fillRange reads each source column's own value off THIS box, so an
  // up/left drag copies from the real origin (not the post-drag corner that would flip to a
  // target cell). Captured per-gesture in the closure (no module-let needed).
  const sourceBox = this.normalizedRange();
  // B7: track the LAST cell the drag reached so fillRange computes the extended rectangle from
  // the gesture's fresh endpoint (React's `up` closure can't re-read the grown $data range).
  let lastCell = sourceBox ? {
    r: sourceBox.r1,
    c: sourceBox.c1
  } : null;
  const move = (ev: any) => {
    if (!this.fillDragging) return;
    const cell = this.cellIndexFromPoint(ev.clientX, ev.clientY);
    // B20: dedup by target cell. setRangeFocus emits range-change, so calling it on EVERY
    // pointermove (the pointer fires many per cell) spams the event with identical payloads.
    // Only extend (and emit) when the pointer enters a DIFFERENT cell than the last — lastCell
    // seeds from the pre-drag bottom-right corner, so a move that stays on the source corner
    // or re-enters the same cell is suppressed (the range is unchanged).
    if (cell && (!lastCell || cell.r !== lastCell.r || cell.c !== lastCell.c)) {
      lastCell = cell;
      this.setRangeFocus(cell.r, cell.c);
    }
  };
  const up = () => {
    // teardownFillDrag clears fillDragging + removes both listeners (CR-04 shared path).
    this.teardownFillDrag();
    // A plain click on the fill handle (pointerdown+up with NO intervening drag) leaves lastCell
    // at the source box's own origin corner (r1,c1), so fillRange(sourceBox, corner) would
    // recommit the source range onto ITSELF — a no-op write that pollutes undo history and fires
    // spurious per-cell cell-edit-commit events (oldValue === newValue). Only fill when the drag
    // actually reached a cell past the source origin.
    if (lastCell && sourceBox && (lastCell.r !== sourceBox.r1 || lastCell.c !== sourceBox.c1)) {
      this.fillRange(sourceBox, lastCell);
    }
  };
  // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
  this.fillDragMove = move;
  this.fillDragUp = up;
  if (typeof document !== 'undefined') {
    document.addEventListener('pointermove', move);
    document.addEventListener('pointerup', up);
  }
};

  rangeDragging = false;

  rangeDragMove: any = null;

  rangeDragUp: any = null;

  rangeDragMoved = false;

  teardownRangeDrag = () => {
  if (typeof document !== 'undefined') {
    if (this.rangeDragMove) document.removeEventListener('pointermove', this.rangeDragMove);
    if (this.rangeDragUp) document.removeEventListener('pointerup', this.rangeDragUp);
  }
  this.rangeDragMove = null;
  this.rangeDragUp = null;
  this.rangeDragging = false;
};

  beginRangeDrag = (anchorR: any, anchorC: any) => {
  // #leak: tear down any orphaned PRIOR range gesture BEFORE reassigning the module-let handlers.
  // A missed pointerup (off-window release, context menu, alt-tab) leaves the prior drag's document
  // pointermove/pointerup attached; overwriting rangeDragMove/rangeDragUp below would strand them
  // (removeEventListener could never reach the old refs) → a permanent global pointermove leak.
  // teardownRangeDrag is idempotent (no-op when nothing is attached) and does NOT touch
  // rangeDragMoved, which is reset per-gesture immediately below.
  this.teardownRangeDrag();
  this.rangeDragging = true;
  this.rangeDragMoved = false;
  let lastCell = {
    r: anchorR,
    c: anchorC
  };
  const move = (ev: any) => {
    if (!this.rangeDragging) return;
    const cell = this.cellIndexFromPoint(ev.clientX, ev.clientY);
    if (cell && (cell.r !== lastCell.r || cell.c !== lastCell.c)) {
      lastCell = cell;
      this.rangeDragMoved = true;
      this.setRangeFocus(cell.r, cell.c);
    }
  };
  const up = () => {
    // teardownRangeDrag clears rangeDragging + removes both listeners (the fill-drag CR-04 path).
    this.teardownRangeDrag();
  };
  // Track the live handlers so $onUnmount can remove them on a mid-drag unmount (CR-04).
  this.rangeDragMove = move;
  this.rangeDragUp = up;
  if (typeof document !== 'undefined') {
    document.addEventListener('pointermove', move);
    document.addEventListener('pointerup', up);
  }
};

  activeCellColumnId = () => {
  if (this._activeIsHeader.value) return null;
  const rowList = this._rows.value || [];
  const row = rowList[this._activeRow.value];
  if (!row) return null;
  const cells = this.visibleCellsFor(row);
  const cell = cells[this._activeColIndex.value];
  return cell && cell.column ? cell.column.id : null;
};

  isActiveCellEditable = () => {
  const colId = this.activeCellColumnId();
  return colId != null && this.columnEditable(colId);
};

  isEditing = (rowIndex: any, colIndex: any) => {
  if (this._editVer.value < 0) return false;
  if (this._editingRowIndex.value != null && this._editingRowIndex.value === rowIndex) {
    const colId = this.columnIdAt(rowIndex, colIndex);
    return colId != null && this.columnEditable(colId);
  }
  return this._editingRow.value === rowIndex && this._editingCol.value === colIndex;
};

  cellAriaInvalid = (rowIndex: any, colIndex: any): 'true' | null => this.isEditing(rowIndex, colIndex) && !!this._invalidMsg.value ? 'true' : null;

  runValidator = (colId: any, value: any, row: any) => {
  const m = this.editMetaOf(colId);
  const v = m ? m.validate : null;
  if (typeof v !== 'function') return true;
  let r: any = null;
  try {
    r = v(value, row);
  } catch (err: any) {
    return 'Invalid value';
  }
  if (r === true) return true;
  if (typeof r === 'string') return r;
  return 'Invalid value';
};

  setInvalid = (msg: any) => {
  this._invalidMsg.value = msg != null ? msg : '';
};

  replaceRowValue = (rows: any, rowIndex: any, field: any, value: any) => {
  const src = rows || [];
  const out = [];
  for (let i = 0; i < src.length; i++) {
    if (i === rowIndex) {
      // WR-03: own-property spread, NOT `for (const k in orig)` which walks the prototype chain
      // and would copy inherited enumerable props of typed/class-instance row objects.
      out.push({
        ...(src[i] || {}),
        [field]: value
      });
    } else {
      out.push(src[i]);
    }
  }
  return out;
};

  sourceIndexOfRow = (visibleRowIndex: any) => {
  const rowList = this._rows.value || [];
  const row = rowList[visibleRowIndex];
  if (!row) return visibleRowIndex;
  const orig = row.original;
  const data = this.currentData() || [];
  const idx = data.indexOf(orig);
  return idx >= 0 ? idx : visibleRowIndex;
};

  editingColumnId = () => {
  const rowList = this._rows.value || [];
  const row = rowList[this._editingRow.value];
  if (!row) return null;
  const cells = this.visibleCellsFor(row);
  const cell = cells[this._editingCol.value];
  return cell && cell.column ? cell.column.id : null;
};

  editingColumnField = () => {
  const colId = this.editingColumnId();
  if (colId == null) return null;
  const d = this.defFor(colId);
  return d ? d.accessorKey != null ? d.accessorKey : colId : colId;
};

  editingCellValue = () => {
  const rowList = this._rows.value || [];
  const row = rowList[this._editingRow.value];
  if (!row) return null;
  const cells = this.visibleCellsFor(row);
  const cell = cells[this._editingCol.value];
  return cell ? cell.getValue() : null;
};

  editingRowOriginal = () => {
  const rowList = this._rows.value || [];
  const row = rowList[this._editingRow.value];
  return row ? row.original : null;
};

  editingRowId = () => {
  const rowList = this._rows.value || [];
  const row = rowList[this._editingRow.value];
  return row ? row.id : null;
};

  focusEditorWhenReady = (selectAll = true) => {
  if (!this.gridRoot) return;
  // Editor-owns-focus contract: when the CURRENT focus target is a #editor drop-in, the host
  // does NOT reach into its DOM — the drop-in self-focuses via its own autofocus prop.
  if (this._editFocusColId.value != null && this.hasEditorSlot(this._editFocusColId.value)) return;
  let attempts = 0;
  const tryFocus = () => {
    const el = this.gridRoot ? this.gridRoot.querySelector('[data-editing-cell]') : null;
    // Do NOT stomp focus a later interaction already placed in a DIFFERENT column's editor of
    // this row: focusEditorWhenReady only needs to get focus INTO the (first) freshly-mounted
    // editor; if focus already sits in another editable cell, a late rAF re-focus would steal it
    // back to the first editor and break row-mode Tab containment (the non-deterministic B21
    // focus-theft). Compare the OWNING cell's data-col-index (NOT node identity) so a stale
    // SAME-column editor node on Solid's node-replacing re-render still resolves as the target —
    // a genuinely dropped focus is still recovered.
    const ae = this.gridRoot && this.gridRoot.getRootNode ? this.gridRoot.getRootNode().activeElement : null;
    if (ae && el && ae !== el && ae.closest && this.gridRoot.contains(ae) && ae.hasAttribute && ae.hasAttribute('data-editing-cell')) {
      const aeCell = ae.closest('[data-grid-cell]');
      const elCell = el.closest ? el.closest('[data-grid-cell]') : null;
      const aeCol = aeCell ? aeCell.getAttribute('data-col-index') : null;
      const elCol = elCell ? elCell.getAttribute('data-col-index') : null;
      if (aeCol != null && aeCol !== elCol) return;
    }
    if (el) {
      el.focus();
      if (selectAll && el.select) {
        try {
          el.select();
        } catch (e: any) {}
      }
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};

  columnIdAt = (rowIndex: any, colIndex: any) => {
  const rowList = this._rows.value || [];
  const row = rowList[rowIndex];
  if (!row) return null;
  const cells = this.visibleCellsFor(row);
  const cell = cells[colIndex];
  return cell && cell.column ? cell.column.id : null;
};

  cellValueAt = (rowIndex: any, colIndex: any) => {
  const rowList = this._rows.value || [];
  const row = rowList[rowIndex];
  if (!row) return null;
  const cells = this.visibleCellsFor(row);
  const cell = cells[colIndex];
  return cell ? cell.getValue() : null;
};

  beginEdit = (rowIndex: any, colIndex: any, seed: any) => {
  const colId = this.columnIdAt(rowIndex, colIndex);
  if (colId == null || !this.columnEditable(colId)) return;
  // A new edit session starts — reset the sync idempotency latch so THIS session's eventual
  // commit is not silently no-op'd by a PRIOR session's already-set latch.
  this.committedThisSession = false;
  this.setInvalid('');
  // Single-cell and full-row edit are mutually exclusive (D-06): entering a single-cell
  // editor clears any row-edit state so isEditing never resolves both modes for one cell.
  this._editingRowIndex.value = null;
  this._rowDraft.value = {};
  this._editingRow.value = rowIndex;
  this._editingCol.value = colIndex;
  this._draftValue.value = seed != null ? seed : this.cellValueAt(rowIndex, colIndex);
  this._activeInControl.value = true;
  this._editVer.value = this._editVer.value + 1;
  // Editor-owns-focus contract (quick 260711-i5m): THIS cell's column is the current
  // focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope prop
  // from it. Cleared on endEdit.
  this._editFocusColId.value = colId;
  // B2: a seeded (type-to-edit) entry must NOT select-all — keep the caret after the
  // seeded char so subsequent typing appends instead of replacing it.
  this.focusEditorWhenReady(seed == null);
};

  focusCellWhenReady = (row: any, col: any) => {
  if (!this.gridRoot) return;
  let attempts = 0;
  const tryFocus = () => {
    const el = this.resolveCellEl(String(row), col);
    if (el) {
      el.focus();
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};

  indexOfRowIn = (rows: any, rowOriginal: any, rowId: any) => {
  const list = rows || [];
  for (let i = 0; i < list.length; i++) {
    const r = list[i];
    if (!r) continue;
    if (rowId != null && r.id === rowId) return i;
    if (rowOriginal != null && r.original === rowOriginal) return i;
  }
  return -1;
};

  endEdit = () => {
  this._editingRow.value = -1;
  this._editingCol.value = -1;
  this._draftValue.value = null;
  this._invalidMsg.value = '';
  this._activeInControl.value = false;
  this._editVer.value = this._editVer.value + 1;
  this._editFocusColId.value = null;
};

  endRowEdit = () => {
  this._editingRowIndex.value = null;
  this._rowDraft.value = {};
  this._invalidMsg.value = '';
  this._activeInControl.value = false;
  this._editVer.value = this._editVer.value + 1;
  this._editFocusColId.value = null;
};

  editorAutofocusFor = (colId: any, rowIndex: any) => {
  if (this._editVer.value < 0) return false;
  if (this._editingRowIndex.value != null) {
    if (this._editingRowIndex.value !== rowIndex) return false;
  } else {
    if (this._editingRow.value !== rowIndex) return false;
  }
  return this._editFocusColId.value != null && this._editFocusColId.value === colId;
};

  coerceCellValue = (colId: any, raw: any) => {
  if (this.editorTypeOf(colId) !== 'number') return raw;
  if (raw == null) return null;
  if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw;
  const s = String(raw).trim();
  if (s === '') return null;
  const n = Number(s);
  return Number.isNaN(n) ? null : n;
};

  commitEdit = (overrideValue = undefined, skipFocusReturn = false) => {
  if (this._editingRow.value < 0) return false;
  // Sync idempotency latch (drop-in double cell-edit-commit fix): a second commitEdit call
  // within the SAME edit session — the deferred drop-in's unmount-blur re-entry, which on
  // React fires while $data.editingRow is still async-stale ≥ 0 — no-ops here instead of
  // re-validating/re-writing/re-emitting. Reset by beginEdit/beginRowEdit/editCell.
  if (this.committedThisSession) return false;
  const colId = this.editingColumnId();
  if (colId == null) {
    this.endEdit();
    return false;
  }
  const field = this.editingColumnField();
  const oldValue = this.editingCellValue();
  const rowOriginal = this.editingRowOriginal();
  const rowId = this.editingRowId();
  // B3: coerce by the column's editor type BEFORE validation + write so the validator
  // and the model both see the typed value (number/null), not the raw draft string.
  const rawValue = overrideValue !== undefined ? overrideValue : this._draftValue.value;
  const newValue = this.coerceCellValue(colId, rawValue);
  const err = this.runValidator(colId, newValue, rowOriginal);
  if (err !== true) {
    // D-01: reject — keep the editor open, announce, re-trap focus, NEVER write the model.
    this.setInvalid(err);
    this.focusEditorWhenReady();
    return false;
  }
  this.setInvalid('');
  // #5: a no-op commit (the coerced value is UNCHANGED — a bare Enter/Tab/blur that edited
  // nothing) must do NO model write, NO history record, and NO commit event: writeData →
  // recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row identity, so an
  // unconditional write on a no-op would destroy redo + spuriously re-render + emit a no-op
  // cell-edit-commit. Compute `changed` and gate the write/emit on it; ALWAYS close the editor.
  const changed = !Object.is(newValue, oldValue);
  // Snapshot the EDITING cell to return focus to BEFORE endEdit clears editing state.
  const focusRow = this._editingRow.value;
  const focusCol = this._editingCol.value;
  // Guard the teardown blur: writeData/endEdit re-render unmounts the editor → its blur
  // must NOT re-enter commitEdit (double cell-edit-commit). Cleared after the focus return.
  this.editTransition = true;
  // Sync idempotency latch: flip BEFORE writeData/endEdit so the async unmount-blur re-entry
  // (which fires AFTER this call returns, once editTransition is already back to false) finds
  // it set at the top-of-function guard above and no-ops. Set on BOTH paths so a no-op commit
  // is just as re-entry-safe as a real one.
  this.committedThisSession = true;
  if (changed) {
    const srcIndex = this.sourceIndexOfRow(this._editingRow.value);
    const next = this.replaceRowValue(this.currentData(), srcIndex, field, newValue);
    this.writeData(next);
    // Exactly one emit per commit, from this single call site (writeData does NOT emit).
    this.dispatchEvent(new CustomEvent("cell-edit-commit", {
      detail: {
        rowId,
        columnId: colId,
        oldValue,
        newValue
      },
      bubbles: true,
      composed: true
    }));
  }
  this.endEdit();
  this.editTransition = false;
  if (changed) {
    // Defer the focus return so the display↔editor re-render commits first (async on
    // React/Solid/Lit) — the cell is focusable with its roving tabindex only after the
    // editor unmounts and the display branch (+ tabindex) re-renders. Skipped on a
    // Tab-advance (the caller immediately opens the next editor and focuses THAT).
    // B23: do NOT focus the FIXED old index here — under an active sort/filter the committed row
    // RELOCATES, and focusCellWhenReady(oldRow,col) would land on whatever row now sits at the old
    // index (or drop to <body>). Instead record a pending follow-request the refreshRowModel pass
    // consumes AFTER the row model re-derives: it resolves the row's NEW display index from the
    // fresh model (React-stale-safe) and focuses THAT cell; the @focusin sync then re-seats the
    // active-cell state so it and DOM focus stay coherent. With no sort/filter the row keeps its
    // index → byte-behaviorally identical to before.
    if (skipFocusReturn !== true) this.pendingEditFollow = {
      rowOriginal,
      rowId,
      col: focusCol
    };
  } else if (skipFocusReturn !== true) {
    // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume
    // a pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
    // relocate (no write), so the B23 relocation hazard that forces the pendingEditFollow path on
    // a real commit does not apply here: the fixed (focusRow, focusCol) is correct and safe.
    this.focusCellWhenReady(focusRow, focusCol);
  }
  return true;
};

  toggleActiveBooleanCell = () => {
  const colId = this.columnIdAt(this._activeRow.value, this._activeColIndex.value);
  if (colId == null || !this.columnEditable(colId)) return;
  const rowList = this._rows.value || [];
  const row = rowList[this._activeRow.value];
  if (!row) return;
  const rowOriginal = row.original;
  const rowId = row.id;
  const oldValue = this.cellValueAt(this._activeRow.value, this._activeColIndex.value);
  const newValue = !oldValue;
  // D-01: same discipline as commitEdit — a rejecting validator blocks the toggle. There is
  // no editor to keep open here, so the toggle simply does not apply (no model write).
  const err = this.runValidator(colId, newValue, rowOriginal);
  if (err !== true) {
    this.setInvalid(err);
    return;
  }
  this.setInvalid('');
  const def = this.defFor(colId);
  const field = def && def.accessorKey != null ? def.accessorKey : colId;
  const srcIndex = this.sourceIndexOfRow(this._activeRow.value);
  // Sync idempotency latch: this toggle is a commit-equivalent (mirrors commitEdit's D-07
  // single-emit discipline) — flip it too so a stray re-entry after this toggle no-ops.
  this.committedThisSession = true;
  this.writeData(this.replaceRowValue(this.currentData(), srcIndex, field, newValue));
  // Exactly one emit per toggle, from this single call site (writeData does NOT emit) —
  // mirrors commitEdit's D-07 single-emit discipline.
  this.dispatchEvent(new CustomEvent("cell-edit-commit", {
    detail: {
      rowId,
      columnId: colId,
      oldValue,
      newValue
    },
    bubbles: true,
    composed: true
  }));
  // Follow the toggled row's focus through a boolean sort/filter relocation AND a
  // fine-grained keyed-row replace (Solid) — the SAME recovery commitEdit relies on; even
  // with no editor to unmount, writeData's re-render can still drop focus.
  this.pendingEditFollow = {
    rowOriginal,
    rowId,
    col: this._activeColIndex.value
  };
};

  cancelEdit = () => {
  if (this._editingRow.value < 0) return;
  // CR-01: capture from the EDITING pair (authoritative), NOT the active-cell indices — a
  // Tab-advance writes activeRow/activeColIndex to the NEXT cell BEFORE opening its editor, so
  // an Escape on the just-opened editor would otherwise return focus to the Tab-target cell
  // instead of the cell being cancelled. commitEdit already snapshots editingRow/editingCol.
  const focusRow = this._editingRow.value;
  const focusCol = this._editingCol.value;
  this.editTransition = true;
  this.endEdit();
  this.editTransition = false;
  this.focusCellWhenReady(focusRow, focusCol);
};

  editableColumnsForRow = (rowIndex: any) => {
  const rowList = this._rows.value || [];
  const row = rowList[rowIndex];
  if (!row) return [];
  const cells = this.visibleCellsFor(row);
  const out = [];
  for (let c = 0; c < cells.length; c++) {
    const cell = cells[c];
    const colId = cell && cell.column ? cell.column.id : null;
    if (colId == null || !this.columnEditable(colId)) continue;
    const d = this.defFor(colId);
    const field = d ? d.accessorKey != null ? d.accessorKey : colId : colId;
    // colIndex = the VISIBLE-cell index (the data-col-index the editor cell renders under).
    // Carried so the row-mode Tab containment (B21) + the validation-failure focus (B22)
    // can address a SPECIFIC editor by column, not just the first [data-editing-cell].
    out.push({
      colId,
      field,
      colIndex: c
    });
  }
  return out;
};

  focusRowEditorAt = (rowIndex: any, colIndex: any) => {
  if (!this.gridRoot) return;
  const colId = this.columnIdAt(rowIndex, colIndex);
  if (colId != null && this.hasEditorSlot(colId)) return;
  let attempts = 0;
  const tryFocus = () => {
    const cellEl = this.resolveCellEl(String(rowIndex), colIndex);
    const ed = cellEl && cellEl.querySelector ? cellEl.querySelector('[data-editing-cell]') : null;
    if (ed) {
      ed.focus();
      if (ed.select) {
        try {
          ed.select();
        } catch (e: any) {}
      }
      return;
    }
    attempts = attempts + 1;
    if (attempts >= 30) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};

  beginRowEdit = (row: any) => {
  const rowIndex = this.rowIndexOf(row);
  if (rowIndex < 0) return;
  const editable = this.editableColumnsForRow(rowIndex);
  if (editable.length === 0) return;
  // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
  this.committedThisSession = false;
  // Clear any single-cell editor first (mutual exclusivity).
  this._editingRow.value = -1;
  this._editingCol.value = -1;
  this._draftValue.value = null;
  this.setInvalid('');
  // Seed each editable cell's draft from its current value.
  const draft = {};
  const rowList = this._rows.value || [];
  const r = rowList[rowIndex];
  const orig = r ? r.original : null;
  for (let i = 0; i < editable.length; i++) {
    const ec = editable[i];
    draft[ec.colId] = orig ? orig[ec.field] : null;
  }
  this._rowDraft.value = draft;
  this._editingRowIndex.value = rowIndex;
  this._activeInControl.value = true;
  this._editVer.value = this._editVer.value + 1;
  // Editor-owns-focus contract (quick 260711-i5m): the row's FIRST editable column is the
  // initial focus target — editorAutofocusFor derives the reactive `autofocus` #editor scope
  // prop from it (a built-in column is also host-focused below via focusEditorWhenReady; a
  // drop-in column self-focuses via its own $onMount, gated off the host reach-in in Task 3).
  this._editFocusColId.value = editable[0].colId;
  this.focusEditorWhenReady();
};

  commitRow = () => {
  if (this._editingRowIndex.value == null) return false;
  const rowIndex = this._editingRowIndex.value;
  const editable = this.editableColumnsForRow(rowIndex);
  if (editable.length === 0) {
    this.endRowEdit();
    return false;
  }
  const rowList = this._rows.value || [];
  const r = rowList[rowIndex];
  const rowOriginal = r ? r.original : null;
  const rowId = r ? r.id : null;
  const draft = this._rowDraft.value || {};
  // Validate every edited column FIRST (D-01: a single failure blocks the whole row commit).
  // B3 (Rule 1): coerce each draft by the column's editor type BEFORE validation + write — a
  // 'number' editor must commit a real Number/null, never the raw editor STRING (the single-cell
  // commitEdit already coerces via coerceCellValue; the row path silently committed strings →
  // a number column ended up holding '99'). Coerce once here so the validator and the model both
  // see the typed value, identical to the single-cell funnel.
  for (let i = 0; i < editable.length; i++) {
    const ec = editable[i];
    const err = this.runValidator(ec.colId, this.coerceCellValue(ec.colId, draft[ec.colId]), rowOriginal);
    if (err !== true) {
      this.setInvalid(err);
      // Editor-owns-focus contract (quick 260711-i5m): the OFFENDING column becomes the new
      // reactive focus target BEFORE the host-focus call below — a #editor drop-in already
      // mounted (full-row edit opens every editable cell at once) picks this up via its own
      // lazy $watch on the `autofocus` scope prop flipping false→true. Bump editVer so the
      // coarse-render targets (React/Vue/Angular/Svelte) re-derive the slot binding (Solid's
      // fine-grained accessor re-runs without the bump, but the bump keeps all 6 in lockstep).
      this._editFocusColId.value = ec.colId;
      this._editVer.value = this._editVer.value + 1;
      // B22: focus the OFFENDING column's editor (the one whose validator rejected), NOT
      // unconditionally the first editor (focusEditorWhenReady resolves the first
      // [data-editing-cell] in DOM order). ec.colIndex is the offending cell's visible col.
      // Gated (Task 3) so a #editor drop-in self-focuses instead of a host DOM reach-in.
      this.focusRowEditorAt(rowIndex, ec.colIndex);
      return false;
    }
  }
  this.setInvalid('');
  // Build the changes payload (only the columns whose value actually changed) + the field→
  // value map for the single row-object replace.
  const changes = [];
  const fieldValues = {};
  for (let i = 0; i < editable.length; i++) {
    const ec = editable[i];
    // B3 (Rule 1): commit the TYPE-COERCED value (number editor → Number/null), not the raw draft
    // string — matches the single-cell commitEdit funnel so a row column never holds a stray string.
    const newValue = this.coerceCellValue(ec.colId, draft[ec.colId]);
    const oldValue = rowOriginal ? rowOriginal[ec.field] : null;
    fieldValues[ec.field] = newValue;
    if (oldValue !== newValue) changes.push({
      columnId: ec.colId,
      oldValue,
      newValue
    });
  }
  // Snapshot the active cell to return focus to (the whole row is in edit, so the active-cell
  // row/column is the roving focus target), BEFORE endRowEdit clears editing state.
  const focusRow = this._activeRow.value;
  const focusCol = this._activeColIndex.value;
  // #5: a no-op row commit (NO column's value actually changed — a bare Enter/save/outside-click
  // that edited nothing) must do NO model write, NO history record, NO row-edit-commit event:
  // writeData → recordSnapshot UNCONDITIONALLY clears the redo stack and mints a fresh row
  // identity, so an unconditional write on a no-op destroys redo + spuriously re-renders + emits
  // a no-op row-edit-commit. Gate the write/emit on `changes.length`; ALWAYS close the editor.
  const changed = changes.length > 0;
  this.editTransition = true;
  if (changed) {
    // ONE fresh-array replace of the SINGLE row object with all field values applied at once.
    const srcIndex = this.sourceIndexOfRow(rowIndex);
    const next = this.replaceRowValues(this.currentData(), srcIndex, fieldValues);
    this.writeData(next);
    // EXACTLY ONE emit per row commit, from THIS single call site (React multi-emit dedup, D-07).
    this.dispatchEvent(new CustomEvent("row-edit-commit", {
      detail: {
        rowId,
        changes
      },
      bubbles: true,
      composed: true
    }));
  }
  this.endRowEdit();
  this.editTransition = false;
  if (changed) {
    // WR-01/B23 (review): a FULL-ROW commit can RELOCATE its row under an active sort/filter, exactly
    // like the single-cell commitEdit. Do NOT focus the FIXED old index — focusCellWhenReady(rowIndex,
    // col) would land on whatever DIFFERENT row now occupies the old index (or drop to <body>) AND leave
    // $data.activeRow stale, so the @focusin sync writes the WRONG activeRow (IN-02 — roving model +
    // DOM focus incoherent on the next keystroke). Instead record a pending follow-request the
    // refreshRowModel pass consumes AFTER the row model re-derives: it resolves the committed row's NEW
    // display index by IDENTITY (rowId FIRST — stable across a re-sort; rowOriginal as fallback, since
    // the fresh-spread replace changes the row object) and re-seats focus on THAT cell via the DOM-only
    // poll (React-stale-safe). With no sort/filter the row keeps its index → byte-behaviorally identical.
    this.pendingEditFollow = {
      rowOriginal,
      rowId,
      col: focusCol
    };
  } else {
    // #5 no-op path: nothing was written, so refreshRowModel never runs and would never consume a
    // pendingEditFollow — focus would drop to <body>. Return focus DIRECTLY. The row does NOT
    // relocate (no write), so the B23 relocation hazard does not apply: (focusRow, focusCol) is safe.
    this.focusCellWhenReady(focusRow, focusCol);
  }
  return true;
};

  cancelRow = () => {
  if (this._editingRowIndex.value == null) return;
  const focusRow = this._activeRow.value;
  const focusCol = this._activeColIndex.value;
  this.editTransition = true;
  this.endRowEdit();
  this.editTransition = false;
  this.focusCellWhenReady(focusRow, focusCol);
};

  replaceRowValues = (rows: any, rowIndex: any, fieldValues: any) => {
  const src = rows || [];
  const fv = fieldValues || {};
  const out = [];
  for (let i = 0; i < src.length; i++) {
    if (i === rowIndex) {
      // WR-03: own-property spread (orig then the field→value map), NOT a `for..in`
      // prototype-walking copy. Spread copies own enumerable props only.
      out.push({
        ...(src[i] || {}),
        ...fv
      });
    } else {
      out.push(src[i]);
    }
  }
  return out;
};

  nextEditableCell = (fromRow: any, fromCol: any) => {
  const rowList = this._rows.value || [];
  const rowCount = rowList.length;
  if (rowCount === 0) return null;
  let r = fromRow;
  let c = fromCol + 1;
  while (r < rowCount) {
    const row = rowList[r];
    const cells = row ? this.visibleCellsFor(row) : [];
    while (c < cells.length) {
      const cell = cells[c];
      const cid = cell && cell.column ? cell.column.id : null;
      if (cid != null && this.columnEditable(cid)) return {
        row: r,
        col: c
      };
      c = c + 1;
    }
    r = r + 1;
    c = 0;
  }
  return null;
};

  prevEditableCell = (fromRow: any, fromCol: any) => {
  const rowList = this._rows.value || [];
  const rowCount = rowList.length;
  if (rowCount === 0) return null;
  let r = fromRow;
  let c = fromCol - 1;
  while (r >= 0) {
    const row = rowList[r];
    const cells = row ? this.visibleCellsFor(row) : [];
    while (c >= 0) {
      const cell = cells[c];
      const cid = cell && cell.column ? cell.column.id : null;
      if (cid != null && this.columnEditable(cid)) return {
        row: r,
        col: c
      };
      c = c - 1;
    }
    r = r - 1;
    if (r >= 0) {
      const prow = rowList[r];
      const pcells = prow ? this.visibleCellsFor(prow) : [];
      c = pcells.length - 1;
    }
  }
  return null;
};

  editTransition = false;

  pendingEditFollow: any = null;

  committedThisSession = false;

  inRowEdit = () => this._editingRowIndex.value != null;

  editorValueFor = (colId: any) => this.inRowEdit() ? this._rowDraft.value ? this._rowDraft.value[colId] : null : this._draftValue.value;

  editorCheckedFor = (colId: any) => !!(this.inRowEdit() ? this._rowDraft.value ? this._rowDraft.value[colId] : null : this._draftValue.value);

  editorCommitFor = (colId: any) => (value: any) => {
  if (this.inRowEdit()) {
    this.setRowDraft(colId, value);
    return;
  }
  this.commitEdit(value);
};

  editorCancelFor = () => () => {
  if (this.inRowEdit()) {
    this.cancelRow();
    return;
  }
  this.cancelEdit();
};

  onCellEditorInput = (colId: any, evt: any) => {
  const v = evt && evt.target ? evt.target.value : '';
  if (this.inRowEdit()) {
    this.setRowDraft(colId, v);
    return;
  }
  this._draftValue.value = v;
};

  onCellEditorCheckbox = (colId: any, evt: any) => {
  const v = !!(evt && evt.target && evt.target.checked);
  if (this.inRowEdit()) {
    this.setRowDraft(colId, v);
    return;
  }
  this._draftValue.value = v;
};

  setRowDraft = (colId: any, value: any) => {
  const src = this._rowDraft.value || {};
  const next = {};
  for (const k in src) next[k] = src[k];
  next[colId] = value;
  this._rowDraft.value = next;
};

  rowEditTab = (target: any, backward: any) => {
  const rowIndex = this._editingRowIndex.value;
  if (rowIndex == null) return;
  const editable = this.editableColumnsForRow(rowIndex);
  if (editable.length === 0) return;
  const cols = editable.map((ec: any) => ec.colIndex);
  const cell = target && target.closest ? target.closest('[data-grid-cell]') : null;
  const curAttr = cell ? cell.getAttribute('data-col-index') : null;
  const cur = curAttr != null ? parseInt(curAttr, 10) : -1;
  let pos = cols.indexOf(cur);
  if (pos < 0) pos = 0;
  const len = cols.length;
  const nextPos = backward ? (pos - 1 + len) % len : (pos + 1) % len;
  // Editor-owns-focus contract (quick 260711-i5m): the Tab target becomes the new reactive
  // focus target BEFORE the host-focus call below, so Tab onto an already-mounted #editor
  // drop-in (row mode) also refocuses it via its own lazy $watch.
  this._editFocusColId.value = editable[nextPos].colId;
  this._editVer.value = this._editVer.value + 1;
  this.focusRowEditorAt(rowIndex, cols[nextPos]);
};

  onEditorKeyDown = (e: any) => {
  if (!e) return;
  const key = e.key;
  // Full-row mode (req-6): Enter from ANY cell editor commits the WHOLE row at once (ONE
  // model write + ONE row-edit-commit); Escape reverts the whole row. Tab moves between the
  // row's editors NATIVELY (no commit-per-cell) — let the browser advance focus, so we don't
  // preventDefault it here.
  if (this.inRowEdit()) {
    if (key === 'Enter') {
      e.preventDefault();
      this.commitRow();
    } else if (key === 'Escape') {
      e.preventDefault();
      this.cancelRow();
    }
    // B21: CONTAIN Tab within the editing row. Native Tab escapes the row at its first/last
    // editor (leaving editingRowIndex set so onGridKeyDown stays frozen → keyboard trap). Take
    // Tab over entirely and cycle between the row's editors WITH WRAP (forward off the last →
    // first; Shift+Tab off the first → last). Cross-target-safe (no reliance on the native DOM
    // tab order across a Lit shadow boundary).
    else if (key === 'Tab') {
      e.preventDefault();
      this.rowEditTab(e.target, e.shiftKey);
    }
    return;
  }
  if (key === 'Enter') {
    e.preventDefault();
    this.commitEdit(undefined);
  } else if (key === 'Tab') {
    e.preventDefault();
    // Resolve the advance target from the EDITING pair (the cell that is open), not the
    // active cell (they match here, but the editing pair is authoritative). B4: Shift+Tab
    // moves BACKWARD (prevEditableCell), a plain Tab FORWARD (nextEditableCell). Snapshot
    // the editing pair BEFORE commit (commitEdit resets it to -1).
    const fromRow = this._editingRow.value;
    const fromCol = this._editingCol.value;
    const target = e.shiftKey ? this.prevEditableCell(fromRow, fromCol) : this.nextEditableCell(fromRow, fromCol);
    // skipFocusReturn=true: don't bounce focus back to the committed cell — we advance
    // straight into the next editable cell's editor below. Use the RETURN value (not a
    // re-read of $data.editingRow — async-stale on React) to gate the advance: a validation
    // failure returns false and keeps the editor open (the user must fix the value first).
    const committed = this.commitEdit(undefined, true);
    if (committed && target) {
      this._activeRow.value = target.row;
      this._activeColIndex.value = target.col;
      this.beginEdit(target.row, target.col, null);
    } else if (committed) {
      // B5: no editable cell in the Tab direction (grid start/end) — keep focus INSIDE the
      // grid by returning it to the just-committed cell instead of letting it drop to <body>.
      this.focusCellWhenReady(fromRow, fromCol);
    }
  } else if (key === 'Escape') {
    e.preventDefault();
    this.cancelEdit();
  }
};

  onEditorBlur = (e: any) => {
  // Full-row mode (req-6): a blur that stays WITHIN the row editor — Tab/click between the
  // row's OWN fields — is a normal focus move and must NOT commit (a per-cell blur-commit
  // would split the row into N writes + N events, violating the one-write/one-event contract).
  // But an OUTSIDE-click blur (#7) MUST commit the row: otherwise the model is never written
  // AND editingRowIndex stays set, freezing onGridKeyDown's editingRowIndex early-return so
  // arrow-nav is dead the moment the user clicks back into the grid. Mirror the single-cell
  // branch's relatedTarget shape to tell an in-row focus move from a genuine click-away.
  if (this.inRowEdit()) {
    // Guard the teardown blur: commitRow's writeData/endRowEdit re-render unmounts the row's
    // editors → a same-tick re-render blur must NOT re-enter commitRow (double row-edit-commit).
    // commitRow sets editTransition synchronously BEFORE writeData, so it is set here during the
    // teardown window (the async unmount-blur that fires after endRowEdit finds editingRowIndex
    // already null → inRowEdit() false → the single-cell tail's editingRow<0 guard returns).
    if (this.editTransition) return;
    const rowNext = e ? e.relatedTarget : null;
    const rowNextCell = rowNext && rowNext.closest ? rowNext.closest('[data-grid-cell]') : null;
    const rowNextRow = rowNextCell ? rowNextCell.getAttribute('data-row') : null;
    // Focus landing on a cell of the SAME editing row (Tab/click between the row's own fields) →
    // controlled in-row move, do NOT commit. Anything else — a null relatedTarget, another row,
    // a toolbar/widget, or outside the grid entirely — is an outside-click → commit the row as a
    // unit. commitRow clears editingRowIndex, releasing onGridKeyDown's early-return so nav
    // resumes; a no-op row (nothing changed) takes commitRow's clean #5 no-write/no-emit path.
    if (rowNextRow != null && rowNextRow === String(this._editingRowIndex.value)) return;
    this.commitRow();
    return;
  }
  if (this._editingRow.value < 0 || this.editTransition) return;
  const next = e ? e.relatedTarget : null;
  // A null relatedTarget is an unmount-blur (the editor left the DOM) or a focus drop the
  // keyboard path owns; committing here would double-count (WR-04: the OLD editor's blur on
  // a Tab-advance fires with a TRANSIENT null relatedTarget while it unmounts). Keep the
  // conservative null=skip behavior.
  if (next == null) return;
  // Focus moving OUTSIDE the grid (a click into another widget) → commit (D-01 reject keeps
  // the editor open on an invalid value).
  if (!(this.gridRoot && this.gridRoot.contains && this.gridRoot.contains(next))) {
    this.commitEdit(undefined);
    return;
  }
  // Focus stays INSIDE the grid. B1: distinguish a controlled keyboard transition (the
  // keyboard handler already committed) from a genuine click-away to ANOTHER grid cell
  // (which must commit + close so the grid is not wedged with an open editor).
  const nextCell = next.closest ? next.closest('[data-grid-cell]') : null;
  const fromCell = e && e.target && e.target.closest ? e.target.closest('[data-grid-cell]') : null;
  // Same cell (an inner control / the editing cell itself on an Enter focus-return) → a
  // controlled move; skip. Also skip when either cell can't be resolved (an unmounting
  // editor has no owning cell — the Tab-advance remount-blur path, never a click-away).
  if (!nextCell || !fromCell || nextCell === fromCell) return;
  // A Tab-advance already committed the old editor and opened the next one, so the live
  // editing pair has MOVED off the blurring editor's cell; only a click-away leaves the
  // editing pair still ON fromCell. Skip when they differ (the keyboard path owns it — no
  // double commit, WR-04).
  const fromRow = fromCell.getAttribute('data-row');
  const fromCol = fromCell.getAttribute('data-col-index');
  if (fromRow !== String(this._editingRow.value) || fromCol !== String(this._editingCol.value)) return;
  // Genuine click-away to another grid cell → commit + close. skipFocusReturn=true so the
  // commit does NOT bounce focus back to the just-committed editing cell (which would fight
  // the click destination). The commit's writeData re-renders the table and can DROP DOM
  // focus on the fine-grained targets (Solid keyed-row replace). Re-seat focus on the CLICK
  // DESTINATION cell ONLY IF the re-render actually dropped it — a single deferred check
  // (not a 30-frame poll) so a target whose click-focus SURVIVED (Lit) is never re-focused
  // late, which would steal focus back from a subsequent navigation.
  const destRow = nextCell.getAttribute('data-row');
  const destCol = nextCell.getAttribute('data-col-index');
  this.commitEdit(undefined, true);
  const reseatDestFocus = () => {
    if (!this.gridRoot || destRow == null || destCol == null || destRow === '__header') return;
    const root = this.gridRoot.getRootNode ? this.gridRoot.getRootNode() : null;
    const act = root && root.activeElement ? root.activeElement : null;
    // Focus already landed inside the grid (the click-focus survived the re-render) — leave it.
    if (act && this.gridRoot.contains && this.gridRoot.contains(act)) return;
    const el = this.resolveCellEl(destRow, parseInt(destCol, 10));
    if (el) el.focus();
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reseatDestFocus);else setTimeout(reseatDestFocus, 0);
};

  editCell = (rowIndex: any, colIndex: any) => {
  const lastRow = this.bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const maxCol = this.visibleColCount() - 1;
  const r = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
  const c = this.clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
  // A new edit session starts — reset the sync idempotency latch (see editCellLifecycle.rzts).
  this.committedThisSession = false;
  this._activeIsHeader.value = false;
  this._activeRow.value = r;
  this._activeColIndex.value = c;
  this.beginEdit(r, c, null);
};

  commitEditing = () => {
  if (this.inRowEdit()) {
    this.commitRow();
    return;
  }
  if (this._editingRow.value >= 0) this.commitEdit(undefined);
};

  editRow = (rowIndex: any) => {
  const lastRow = this.bodyRowCount() - 1;
  const maxRow = lastRow < 0 ? 0 : lastRow;
  const r = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, maxRow);
  const rowList = this._rows.value || [];
  const row = rowList[r];
  if (!row) return;
  this._activeIsHeader.value = false;
  this._activeRow.value = r;
  this.beginRowEdit(row);
};

  focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
  if (!this.gridRoot) return;
  let attempts = 0;
  const want = String(this.headerRowCount() + absRow + 1);
  // #9: capture the focus-intent epoch at arm time (AFTER focusCell's own bump at its top, so
  // this poll never aborts itself). A LATER focus intent — a click landing on a new cell
  // (syncActiveFromEvent) or another focusCell / keyboard nav — bumps the epoch, so this
  // paginated page-switch poll aborts instead of grabbing focus frames after the user moved on.
  const myEpoch = this.focusIntentEpoch;
  const tryFocus = () => {
    if (this.focusIntentEpoch !== myEpoch) return;
    const el = this.resolveCellEl(String(localRow), col);
    if (el) {
      const rowEl = el.closest ? el.closest('[role="row"]') : null;
      const ari = rowEl ? rowEl.getAttribute('aria-rowindex') : null;
      if (ari === want) {
        el.focus();
        return;
      }
    }
    attempts = attempts + 1;
    if (attempts >= 60) return;
    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 16);
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(tryFocus);else setTimeout(tryFocus, 0);
};

  focusCell = (rowIndex: any, colIndex: any) => {
  // B16: isGrid()-gate the verb. In 'table' mode there is no roving active cell, so focusCell
  // is a NO-OP (never an activecell-change emit) — the keyboard path (onGridKeyDown) is already
  // isGrid-gated; the exposed verb must mirror that so a consumer's focusCell on a table-mode
  // instance does not leak a spurious activecell-change.
  if (!this.isGrid()) return;
  // #9: focusCell is a focus-INTENT entry point — bump the epoch BEFORE arming any poll (the
  // switched-page focusAbsCellWhenReady captures the post-bump value; the same-page / virtual
  // branches route through focusActiveCell, which bumps again — harmless). A subsequent focusCell
  // or user nav bumps again → a pending focusAbsCellWhenReady from THIS call aborts.
  this.focusIntentEpoch = this.focusIntentEpoch + 1;
  const maxCol = this.visibleColCount() - 1;
  const c = this.clamp(Math.trunc(Number(colIndex)) || 0, 0, maxCol < 0 ? 0 : maxCol);
  // C1: clamp the ABSOLUTE row index to the full filtered+sorted (pre-pagination) bounds.
  const absLast = this.prePaginationRowCount() - 1;
  const absRow = this.clamp(Math.trunc(Number(rowIndex)) || 0, 0, absLast < 0 ? 0 : absLast);
  // B14: snapshot the PRE-write ABSOLUTE position so the activecell-change emit fires ONLY on a
  // real move (mirrors the keyboard path's WR-06 suppression). A no-op focusCell to the already-
  // active cell must NOT emit; a header→body landing (prevIsHeader) is a real move.
  const prevAbs = this.toAbsRow(this._activeRow.value);
  const prevIsHeader = this._activeIsHeader.value;
  if (this.virtual) {
    // Virtual mode: $data.activeRow IS the full pre-pagination index (the wr.vi.index space), so
    // the absolute index maps 1:1. focusActiveCell already runs the D-12 off-window scroll-then-
    // focus path (scrollToIndex(absRow) → deferred-rAF focus) when the row is outside the window.
    this._activeIsHeader.value = false;
    this._activeInControl.value = false;
    this._activeRow.value = absRow;
    this._activeColIndex.value = c;
    this.focusActiveCell(absRow, c, false);
  } else {
    // Paginated mode: resolve the page that HOLDS the absolute row, switch to it, then focus the
    // in-page cell. The page-relative local row = absRow - page*pageSize is what the non-virtual
    // body's data-row markers (and the roving tabindex) address.
    const size = this.pageSize();
    const targetPage = size > 0 ? Math.floor(absRow / size) : 0;
    const localRow = absRow - targetPage * size;
    const switched = targetPage !== this.pageIndex();
    if (switched) this.setPage(targetPage);
    this._activeIsHeader.value = false;
    this._activeInControl.value = false;
    this._activeRow.value = localRow;
    this._activeColIndex.value = c;
    if (switched) {
      // The switched-in page renders ASYNC — poll until the (localRow, c) cell carries the
      // TARGET page's body aria-rowindex (headerRowCount + absRow + 1, #13) before focusing, so
      // the OLD page's same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only.
      this.focusAbsCellWhenReady(absRow, localRow, c);
    } else {
      // Same page: re-seat focus synchronously (the REQ-5 idiom — re-focus after a button click).
      // Thread isHeader=false explicitly (focusActiveCell would otherwise re-read the React/Angular
      // async-stale $data.activeIsHeader, landing on a header when a sort button was last clicked).
      this.focusActiveCell(localRow, c, false);
    }
  }
  if (absRow !== prevAbs || prevIsHeader) {
    this.dispatchEvent(new CustomEvent("activecell-change", {
      detail: {
        rowIndex: absRow,
        colIndex: c
      },
      bubbles: true,
      composed: true
    }));
  }
};

  getActiveCell = () => this._activeIsHeader.value ? {
  rowIndex: null,
  colIndex: this._activeColIndex.value,
  isHeader: true
} : {
  rowIndex: this.toAbsRow(this._activeRow.value),
  colIndex: this._activeColIndex.value,
  isHeader: false
};

  clearActiveCell = () => {
  if (!this.isGrid()) return;
  this._activeIsHeader.value = false;
  this._activeInControl.value = false;
  this._activeRow.value = 0;
  this._activeColIndex.value = 0;
};

  toggleRowExpanded = (rowId: any) => {
  if (!this.table) return;
  const target = String(rowId);
  const flat = this.table.getCoreRowModel().flatRows;
  for (const r of flat as any) {
    if (r.id === target || r.original && String(r.original.id) === target) {
      r.toggleExpanded();
      return;
    }
  }
};

  expandAll = () => {
  if (!this.table) return;
  this.table.toggleAllRowsExpanded(true);
};

  collapseAll = () => {
  if (!this.table) return;
  this.table.resetExpanded(true);
};

  getExpandedRows = () => {
  if (!this.table) return [];
  const out = [];
  const flat = this.table.getCoreRowModel().flatRows;
  for (const r of flat as any) if (r.getIsExpanded && r.getIsExpanded()) out.push(r.original);
  return out;
};

  applyGrouping = (cols: any) => {
  if (this.table) this.table.setGrouping(cols);
};

  clearGrouping = () => {
  if (this.table) this.table.setGrouping([]);
};

  getFacetedUniqueValues = (colId: any) => {
  if (this.tick() < 0 || !this.table) return [];
  const col = this.table.getColumn(colId);
  if (!col || !col.getFacetedUniqueValues) return [];
  const map = col.getFacetedUniqueValues(); // Map<any, number>
  return map ? Array.from(map.keys()) : []; // KEYS only — counts deferred (D-03)
};

  getFacetedMinMaxValues = (colId: any) => {
  if (this.tick() < 0 || !this.table) return null;
  const col = this.table.getColumn(colId);
  if (!col || !col.getFacetedMinMaxValues) return null;
  return col.getFacetedMinMaxValues() || null; // [number, number] | null
};

  get data(): any[] { return this._dataControllable.read(); }
  set data(v: any[]) { this._dataControllable.notifyPropertyWrite(v); }
  get sorting(): any[] { return this._sortingControllable.read(); }
  set sorting(v: any[]) { this._sortingControllable.notifyPropertyWrite(v); }
  get globalFilter(): string { return this._globalFilterControllable.read(); }
  set globalFilter(v: string) { this._globalFilterControllable.notifyPropertyWrite(v); }
  get columnFilters(): any[] { return this._columnFiltersControllable.read(); }
  set columnFilters(v: any[]) { this._columnFiltersControllable.notifyPropertyWrite(v); }
  get pagination(): any { return this._paginationControllable.read(); }
  set pagination(v: any) { this._paginationControllable.notifyPropertyWrite(v); }
  get expanded(): any | boolean { return this._expandedControllable.read(); }
  set expanded(v: any | boolean) { this._expandedControllable.notifyPropertyWrite(v); }
  get grouping(): any[] { return this._groupingControllable.read(); }
  set grouping(v: any[]) { this._groupingControllable.notifyPropertyWrite(v); }
  get rowSelection(): any { return this._rowSelectionControllable.read(); }
  set rowSelection(v: any) { this._rowSelectionControllable.notifyPropertyWrite(v); }
  get columnVisibility(): any { return this._columnVisibilityControllable.read(); }
  set columnVisibility(v: any) { this._columnVisibilityControllable.notifyPropertyWrite(v); }
  get columnSizing(): any { return this._columnSizingControllable.read(); }
  set columnSizing(v: any) { this._columnSizingControllable.notifyPropertyWrite(v); }
  get columnOrder(): any[] { return this._columnOrderControllable.read(); }
  set columnOrder(v: any[]) { this._columnOrderControllable.notifyPropertyWrite(v); }
  get columnPinning(): any { return this._columnPinningControllable.read(); }
  set columnPinning(v: any) { this._columnPinningControllable.notifyPropertyWrite(v); }
}

Each is a real component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same twelve two-way slices, same fourteen change events, same <Column> API, same scoped slots, same imperative handle — identical on every target, built on @tanstack/table-core with no per-framework adapter behind it.

See also

Pre-1.0 — APIs may change between minor versions.