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. Everything is driven by the same DataTable.rozie source that compiles to all six frameworks, 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 on all six targets 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).

One source, six outputs

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">

<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).',
    },
  },

  // ── 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.',
    },
  },

  // FORWARD-COMPAT SEAM (D-02): 'table' (default, row-oriented) | 'grid' (cell
  // keyboard navigation). RESERVED only — grid cell-nav is NOT implemented in this
  // plan. Declaring it now keeps the public prop shape stable for the later wave.
  interactionMode: {
    type: String,
    default: 'table',
    docs: {
      description:
        "`'table'` (default, row-oriented) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role=\"grid\"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.",
      deprecated:
        'Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.',
    },
  },

  // ── 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,

  // ── 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: '',
}
</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

// 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'

// 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'

$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,
    // 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)
    // 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)
  }
})

// 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()
})

// 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,
    $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,
    $data.colReg,
  ],
  () => { reFeed() },
)

// 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
  table.setOptions((prev) => ({
    ...prev,
    data: currentData(),
    columns: tableColumns(),
    state: currentState(),
    enableRowSelection: $props.selectionMode !== 'none',
    enableMultiRowSelection: $props.selectionMode === 'multiple',
    // 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.
let lastData = null
let lastDataLen = -1
$onUpdate(() => {
  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, columnIsFilterable, headerLabel, headerWidth, onResizeStart, findHeader, columnIsResizing, columnIsVisible, onToggleVisibility, allLeafColumns, columnPinSide, onPinColumn, pinStyle, thStyle } from './columnChrome.rzts'

import { onGlobalFilterInput, onColumnFilterInput, globalFilterValue, pageIndex, pageSize, pageCount, canPrevPage, canNextPage, onPrevPage, onNextPage, onPageSizeChange, isSelectColumn, isExpanderColumn, rowCanExpand, rowIsExpanded, rowShowsDetail, onToggleExpand, bodyCellStyle, rowIsGrouped, groupingActive, cellIsGrouped, cellIsAggregated, groupSubRowCount, groupingKeys, groupableColumns, stopEvent, isAllRowsSelected, isSomeRowsSelected, onToggleAllRows, rowIsSelected, onToggleRow } 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, absRowIndexOf, prePaginationRowCount, cellTabindex, resolveCellEl, focusActiveCell, totalRowCount, visibleColCount, bodyRowCount, clamp, moveCol, moveRow, gotoColEdge, gotoStart, gotoEnd, currentCellEl, focusables, enterControl, cycleWithinCell } from './gridFocusNav.rzts'

import { onGridKeyDown, syncActiveFromEvent, onGridMouseDown, 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, clearRange, clampRange } from './rangeSelection.rzts'

import { announce, clipboardActiveAllowed, fieldOfColId, normalizedRange, escapeTsvField, rangeToTsv, parseTsv, copyRange, applyGridToRange, rowOriginalAt, rowIdAt, tileGridToBox, pasteRange, cutRange, 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'

import { activeCellColumnId, isActiveCellEditable, isEditing, cellAriaInvalid, runValidator, setInvalid, replaceRowValue, sourceIndexOfRow, editingColumnId, editingColumnField, editingCellValue, editingRowOriginal, editingRowId, focusEditorWhenReady, columnIdAt, cellValueAt, beginEdit, focusCellWhenReady, indexOfRowIn, endEdit, endRowEdit, coerceCellValue, commitEdit, 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

// ── 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,
})
</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
  `cellCtx` (NEVER `cell` — a `#cell`-named child slot would shadow it → Svelte mount
  throw, the embla/rete lesson). 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>
<!-- 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="$data.rows.length"
  @keydown="onGridKeyDown($event)"
  @focusin="syncActiveFromEvent($event)"
  @focusout="onGridFocusOut($event)"
  @mousedown="onGridMouseDown($event)"
>
  <thead class="rdt-thead" role="rowgroup">
    <tr
      class="rdt-tr"
      role="row"
      r-for="(hg, hgLevel) in $data.headerGroups"
      :key="hg.id"
    >
      <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-th-resizing': columnIsResizing(header.column.id) }"
        :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>
        <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>
          <input
            r-if="columnIsFilterable(header.column.id)"
            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)"
          />
          <!-- HEADLESS facet slot (phase 50 reqs 8-9, D-03) — the #filter scoped slot, mirroring
               the #cell/#colHeader plain-scoped-slot lowering (no portal/projection). Gated on
               the column's filterable flag (faceted filtering requires the column be filterable in
               table-core), wrapped in a display:contents host so an UNFACETED column emits nothing
               extra. NO fallback content → the component renders NO built-in facet control (the
               headless line, #groupBar precedent); a consumer builds a category-checkbox list +
               a numeric range slider purely from the slot props. Slot props: the column id, the
               CROSS-FILTERED unique-value list (keys only — counts deferred, D-03) + the [min,max]
               range. ⚠️ #filter → a Svelte $props() snippet named `filter` (the #header→`const
               header` collision class) — re-grepped: no `filter` local in the script. -->
          <span style="display:contents" r-if="columnIsFilterable(header.column.id)">
            <slot
              name="filter"
              :columnId="header.column.id"
              :uniqueValues="getFacetedUniqueValues(header.column.id)"
              :minMax="getFacetedMinMaxValues(header.column.id)"
              :setFilter="setColumnFilter"
            />
          </span>
          <span class="rdt-pin-controls" role="group" :aria-label="'Pin ' + headerLabel(header.column.id)">
            <button
              type="button"
              class="rdt-pin-btn rdt-pin-left"
              :aria-label="'Pin ' + headerLabel(header.column.id) + ' to left'"
              :aria-pressed="columnPinSide(header.column.id) === 'left'"
              @click="onPinColumn(header.column.id, 'left', $event)"
            >⇤</button>
            <button
              type="button"
              class="rdt-pin-btn rdt-pin-none"
              :aria-label="'Unpin ' + headerLabel(header.column.id)"
              :aria-pressed="!columnPinSide(header.column.id)"
              @click="onPinColumn(header.column.id, false, $event)"
            >⇔</button>
            <button
              type="button"
              class="rdt-pin-btn rdt-pin-right"
              :aria-label="'Pin ' + headerLabel(header.column.id) + ' to right'"
              :aria-pressed="columnPinSide(header.column.id) === 'right'"
              @click="onPinColumn(header.column.id, 'right', $event)"
            >⇥</button>
          </span>
          <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>
  </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="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-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="cellCtx in visibleCellsFor(wr.row)"
        :key="cellCtx.id"
        :data-col="cellCtx.column.id"
        data-grid-cell
        :data-row="wr.vi.index"
        :data-col-index="colIndexOf(wr.row, cellCtx)"
        :tabindex="cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx))"
        :class="{ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) }"
        :style="bodyCellStyle(wr.row, cellCtx.column.id)"
        :aria-invalid="cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx))"
        :data-in-range="inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : null"
        :data-agg-cell="cellIsAggregated(cellCtx) ? cellCtx.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(cellCtx.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(cellCtx.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(cellCtx)">
          <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="cellCtx.column.id"
              :column="cellCtx.column"
              :row="wr.row.original"
              :value="cellCtx.getValue()"
            >{{ cellCtx.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, cellCtx))">
          <span style="display:contents" r-if="hasEditorSlot(cellCtx.column.id)">
            <slot
              name="editor"
              :columnId="cellCtx.column.id"
              :column="cellCtx.column"
              :row="wr.row.original"
              :value="editorValueFor(cellCtx.column.id)"
              :commit="editorCommitFor(cellCtx.column.id)"
              :cancel="editorCancelFor()"
            />
          </span>
          <input
            r-else-if="editorTypeOf(cellCtx.column.id) === 'number'"
            class="rdt-cell-editor"
            type="number"
            data-editing-cell
            :value="editorValueFor(cellCtx.column.id)"
            @input="onCellEditorInput(cellCtx.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
          <select
            r-else-if="editorTypeOf(cellCtx.column.id) === 'select'"
            class="rdt-cell-editor"
            data-editing-cell
            :value="editorValueFor(cellCtx.column.id)"
            @change="onCellEditorInput(cellCtx.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          >
            <option r-for="opt in editorOptionsOf(cellCtx.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
          </select>
          <input
            r-else-if="editorTypeOf(cellCtx.column.id) === 'checkbox'"
            class="rdt-cell-editor"
            type="checkbox"
            data-editing-cell
            :checked="editorCheckedFor(cellCtx.column.id)"
            @change="onCellEditorCheckbox(cellCtx.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
          <input
            r-else
            class="rdt-cell-editor"
            type="text"
            data-editing-cell
            :value="editorValueFor(cellCtx.column.id)"
            @input="onCellEditorInput(cellCtx.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
        </span>
        <span r-else class="rdt-cell-value">
          <slot
            name="cell"
            :columnId="cellCtx.column.id"
            :column="cellCtx.column"
            :row="wr.row.original"
            :value="cellCtx.getValue()"
          >{{ cellCtx.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, cellCtx))"
          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="totalRowCount()"
  @keydown="onGridKeyDown($event)"
  @focusin="syncActiveFromEvent($event)"
  @focusout="onGridFocusOut($event)"
  @mousedown="onGridMouseDown($event)"
>
  <thead class="rdt-thead" role="rowgroup">
    <tr
      class="rdt-tr"
      role="row"
      r-for="(hg, hgLevel) in $data.headerGroups"
      :key="hg.id"
    >
      <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-th-resizing': columnIsResizing(header.column.id) }"
        :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>
        <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>
          <!-- per-column filter input (req-5) — gated by the column's filterable flag.
               Bound value, NOT eval'd (T-48-XSS). -->
          <input
            r-if="columnIsFilterable(header.column.id)"
            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)"
          />
          <!-- HEADLESS facet slot (phase 50 reqs 8-9, D-03) — the #filter scoped slot (see the
               virtual-branch twin above for the full rationale). Gated on the column's filterable
               flag, display:contents host (unfaceted column emits nothing extra), NO fallback →
               NO built-in facet control. Slot props: column id + cross-filtered unique-value list
               (keys only, D-03) + [min,max] range so a consumer builds the facet UI themselves. -->
          <span style="display:contents" r-if="columnIsFilterable(header.column.id)">
            <slot
              name="filter"
              :columnId="header.column.id"
              :uniqueValues="getFacetedUniqueValues(header.column.id)"
              :minMax="getFacetedMinMaxValues(header.column.id)"
              :setFilter="setColumnFilter"
            />
          </span>
          <!-- Pin control (req-11) — three native buttons (left / unpin / right), each
               keyboard-reachable with an accessible name. Drives column.pin(side|false)
               → pin-change. The active side is reflected with aria-pressed. -->
          <span class="rdt-pin-controls" role="group" :aria-label="'Pin ' + headerLabel(header.column.id)">
            <button
              type="button"
              class="rdt-pin-btn rdt-pin-left"
              :aria-label="'Pin ' + headerLabel(header.column.id) + ' to left'"
              :aria-pressed="columnPinSide(header.column.id) === 'left'"
              @click="onPinColumn(header.column.id, 'left', $event)"
            >⇤</button>
            <button
              type="button"
              class="rdt-pin-btn rdt-pin-none"
              :aria-label="'Unpin ' + headerLabel(header.column.id)"
              :aria-pressed="!columnPinSide(header.column.id)"
              @click="onPinColumn(header.column.id, false, $event)"
            >⇔</button>
            <button
              type="button"
              class="rdt-pin-btn rdt-pin-right"
              :aria-label="'Pin ' + headerLabel(header.column.id) + ' to right'"
              :aria-pressed="columnPinSide(header.column.id) === 'right'"
              @click="onPinColumn(header.column.id, 'right', $event)"
            >⇥</button>
          </span>
          <!-- 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>
  </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="isGrid() ? (absRowIndexOf(row) + 1) : null"
      :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-level="groupingActive() ? (row.depth + 1) : null"
    >
      <td
        class="rdt-td"
        :role="cellRole()"
        r-for="cellCtx in visibleCellsFor(row)"
        :key="cellCtx.id"
        :data-col="cellCtx.column.id"
        data-grid-cell
        :data-row="rowIndexOf(row)"
        :data-col-index="colIndexOf(row, cellCtx)"
        :tabindex="cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx))"
        :class="{ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) }"
        :style="bodyCellStyle(row, cellCtx.column.id)"
        :aria-invalid="cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx))"
        :data-in-range="inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : null"
        :data-agg-cell="cellIsAggregated(cellCtx) ? cellCtx.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(cellCtx.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(cellCtx.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(cellCtx)">
          <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="cellCtx.column.id"
              :column="cellCtx.column"
              :row="row.original"
              :value="cellCtx.getValue()"
            >{{ cellCtx.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, cellCtx))">
          <span style="display:contents" r-if="hasEditorSlot(cellCtx.column.id)">
            <slot
              name="editor"
              :columnId="cellCtx.column.id"
              :column="cellCtx.column"
              :row="row.original"
              :value="editorValueFor(cellCtx.column.id)"
              :commit="editorCommitFor(cellCtx.column.id)"
              :cancel="editorCancelFor()"
            />
          </span>
          <input
            r-else-if="editorTypeOf(cellCtx.column.id) === 'number'"
            class="rdt-cell-editor"
            type="number"
            data-editing-cell
            :value="editorValueFor(cellCtx.column.id)"
            @input="onCellEditorInput(cellCtx.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
          <select
            r-else-if="editorTypeOf(cellCtx.column.id) === 'select'"
            class="rdt-cell-editor"
            data-editing-cell
            :value="editorValueFor(cellCtx.column.id)"
            @change="onCellEditorInput(cellCtx.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          >
            <option r-for="opt in editorOptionsOf(cellCtx.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
          </select>
          <input
            r-else-if="editorTypeOf(cellCtx.column.id) === 'checkbox'"
            class="rdt-cell-editor"
            type="checkbox"
            data-editing-cell
            :checked="editorCheckedFor(cellCtx.column.id)"
            @change="onCellEditorCheckbox(cellCtx.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
          <input
            r-else
            class="rdt-cell-editor"
            type="text"
            data-editing-cell
            :value="editorValueFor(cellCtx.column.id)"
            @input="onCellEditorInput(cellCtx.column.id, $event)"
            @keydown="onEditorKeyDown($event)"
            @blur="onEditorBlur($event)"
          />
        </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="cellCtx.column.id"
            :column="cellCtx.column"
            :row="row.original"
            :value="cellCtx.getValue()"
          >{{ cellCtx.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, cellCtx))"
          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 ' + pageCount() }}
  </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));
}
/* 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);
  background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.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. */
.rozie-data-table.rdt-sticky .rdt-thead .rdt-th {
  position: sticky;
  top: var(--rdt-sticky-top, 0);
  z-index: var(--rdt-sticky-z, 2);
  background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
/* 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 .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);
}
/* 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 .rdt-col-filter {
  font: inherit;
  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 .rdt-col-filter {
  display: block;
  margin-top: var(--rdt-col-filter-gap, 0.25rem);
  width: 100%;
  font-weight: normal;
}
.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));
}
/* Pin controls (req-11) — compact button group. */
.rozie-data-table .rdt-pin-controls {
  display: inline-flex;
  gap: var(--rdt-pin-gap, 0.1em);
  margin-left: var(--rdt-pin-margin, 0.35em);
}
.rozie-data-table .rdt-pin-btn {
  font: inherit;
  font-size: var(--rdt-pin-btn-size, 0.8em);
  line-height: 1;
  cursor: pointer;
  padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
  border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-pin-btn-radius, 3px);
  background: var(--rdt-pin-btn-bg, transparent);
  color: inherit;
}
.rozie-data-table .rdt-pin-btn[aria-pressed='true'] {
  background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
  font-weight: 700;
}
/* 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. */
.rozie-data-table .rdt-select-th,
.rozie-data-table .rdt-select-td {
  width: var(--rdt-select-col-width, 1%);
  text-align: var(--rdt-select-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 idiomatic, 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, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, parseInlineStyle, rozieAttr, rozieContext, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './DataTable.css';
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).
// 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; 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; }

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;
  /**
   * 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) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
   * @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
   */
  interactionMode?: string;
  /**
   * 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;
  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;
}

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' | 'expandable' | 'getSubRows' | 'groupable' | 'stickyHeader' | 'interactionMode' | 'virtual' | 'estimateRowHeight' | 'maxHeight'> & { columns: any[]; selectionMode: string; manual: boolean; expandable: boolean; getSubRows: ((...args: any[]) => any) | null; groupable: boolean; stickyHeader: boolean; interactionMode: string; virtual: boolean; estimateRowHeight: number; maxHeight: string } = {
    ..._props,
    columns: _props.columns ?? __defaultColumns,
    selectionMode: _props.selectionMode ?? 'none',
    manual: _props.manual ?? false,
    expandable: _props.expandable ?? false,
    getSubRows: _props.getSubRows ?? null,
    groupable: _props.groupable ?? false,
    stickyHeader: _props.stickyHeader ?? false,
    interactionMode: _props.interactionMode ?? 'table',
    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 lastData = useRef<any>(null);
  const lastDataLen = useRef(-1);
  const editTransition = 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 _selectionModeRef = useRef(props.selectionMode);
  _selectionModeRef.current = props.selectionMode;
  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 [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 __rozieRoot = useRef<HTMLDivElement | null>(null);
  const _watch0First = 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).
  function groupingActiveDefault() {
    return ((grouping != null ? grouping : groupingDefault) || []).length > 0;
  }
  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: columnPinning != null ? columnPinning : columnPinningDefault,
    // 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, columnPinning, columnPinningDefault, columnSizing, columnSizingDefault, columnSizingInfo, columnVisibility, columnVisibilityDefault, 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 gid = c.id != null ? c.id : c.header;
      if (gid == null) return null;
      const id = String(gid);
      if (!isSafeKey(id)) return null;
      const kids = [];
      for (const child of c.columns as any) {
        const cd = buildConfigDef(child);
        if (cd) kids.push(cd);
      }
      if (!kids.length) 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: ''
      };
      withExpander = [expanderCol].concat(cols);
    }
    if (selectionEnabled()) {
      const selectCol = {
        id: SELECT_COL_ID,
        enableSorting: false,
        enableColumnFilter: false,
        filterable: false,
        isSelectColumn: true,
        pinned: '',
        width: ''
      };
      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--;
  }
  function writePagination(next: any) {
    if (programmatic.current) return;
    programmatic.current++;
    setPaginationDefault(next);
    setPagination(next);
    props.onPageChange && props.onPageChange(next);
    programmatic.current--;
  }
  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;
    programmatic.current++;
    setColumnPinningDefault(next);
    setColumnPinning(next);
    props.onPinChange && props.onPinChange(next);
    programmatic.current--;
  }
  function writeData(next: any) {
    if (programmatic.current) return;
    programmatic.current++;
    setDataDefault(next); // fresh array only (never in-place)
    setData(next); // 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);
  }
  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 reFeed = useCallback(() => {
    if (!table.current) return;
    table.current.setOptions((prev: any) => ({
      ...prev,
      data: currentData(),
      columns: tableColumns(),
      state: currentState(),
      enableRowSelection: props.selectionMode !== 'none',
      enableMultiRowSelection: props.selectionMode === 'multiple',
      // 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.selectionMode, tableColumns]);
  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 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) 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) {
    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:1;';
    }
    if (side === 'right') {
      const right = col.getAfter ? col.getAfter('right') : 0;
      return 'position:sticky;right:' + right + 'px;z-index:1;';
    }
    return '';
  }
  function thStyle(colId: any) {
    let s = '';
    const w = headerWidth(colId);
    if (w) s += 'width:' + w + ';';
    s += pinStyle(colId);
    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 pageCount() {
    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());
  }
  function rowIsExpanded(row: any) {
    return !!(tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
  }
  function rowShowsDetail(row: any) {
    return props.getSubRows == null && 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. On Solid the expander <td>/<button> is RECREATED on that
    // re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
    // pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
    // breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
    // Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
    // the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
    // reactive flush so the fresh node exists. Harmless on the targets that keep the node
    // (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
    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) {
      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 groupSubRowCount(row: any) {
    return 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 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 absRowIndexOf(row: any) {
    return rowIndexOf(row) + 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 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;
    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;
      const focusWhenReady = () => {
        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 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 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) {
    const max = visibleColCount() - 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) {
    const max = visibleColCount() - 1;
    const nextCol = toEnd ? max < 0 ? 0 : max : 0;
    setActiveColIndex(nextCol);
    return nextCol;
  }
  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. ──────────
    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;
    }
    // ── 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;
    }
    // ── 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) {
      // 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.
      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);
      recoverGridFocus(String(grpRow), grpCol, null);
      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) {
      _rozieProp_onActivecellChange && _rozieProp_onActivecellChange({
        rowIndex: toAbsRow(nextRow),
        colIndex: nextCol
      });
    }
  }, [_rozieProp_onActivecellChange, activeCellColumnId, activeColIndex, activeHeaderLevel, activeInControl, activeIsHeader, activeRow, beginEdit, beginRowEdit, clearRange, clipboardActiveAllowed, copyRange, currentCellEl, cutRange, cycleWithinCell, editingRow, editingRowIndex, editorTypeOf, enterControl, extendRange, focusActiveCell, gotoColEdge, gotoEnd, gotoStart, isActiveCellEditable, isGrid, moveCol, moveRow, onToggleExpand, pasteRange, recoverGridFocus, rowIsGrouped, rows, toAbsRow]);
  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;
    const isHeader = rowAttr === '__header';
    setActiveIsHeader(isHeader);
    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();
      setActiveHeaderLevel(Number.isFinite(lvl) ? lvl : headerLeafLevel());
    } else {
      const row = parseInt(rowAttr, 10);
      if (Number.isFinite(row)) setActiveRow(row);
    }
    setActiveColIndex(col);
    // 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);
  }, [clearRange, headerLeafLevel, isGrid]);
  const onGridMouseDown = useCallback((e: any) => {
    if (!isGrid() || !e || !e.shiftKey) 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;
    setRangeFocus$local(row, col);
    setActiveIsHeader(false);
    setActiveRow(row);
    setActiveColIndex(col);
    rangeClickPending = true;
  }, [isGrid, setRangeFocus$local]);
  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) {
    if (!gridRoot.current) return;
    let attempts = 0;
    const tryFocus = () => {
      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]);
  // ══ 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.
  // 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.
  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 = 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 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;
    const srcCols = srcRows > 0 ? grid[0].length : 0;
    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 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();
    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();
      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]);
  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;
    let attempts = 0;
    const tryFocus = () => {
      const el = gridRoot.current ? gridRoot.current.querySelector('[data-editing-cell]') : null;
      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;
    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);
    // 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);
  }
  function endRowEdit() {
    setEditingRowIndex(null);
    setRowDraft({});
    setInvalidMsg('');
    setActiveInControl(false);
    setEditVer(prev => prev + 1);
  }
  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;
    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('');
    const srcIndex = sourceIndexOfRow(editingRow);
    const next = replaceRowValue(currentData(), srcIndex, field, newValue);
    // 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;
    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;
    // 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
    };
    return true;
  }
  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;
    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;
    // 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);
    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);
        // 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.
        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
      });
    }
    // 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);
    // Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
    // active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
    const focusCol = activeColIndex;
    editTransition.current = true;
    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;
    // 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
    };
    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;
    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): blur NEVER commits — the row commits as a UNIT only on an
    // explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
    // into N writes + N events, violating the one-write/one-event contract). Tabbing between
    // the row's own editors is a normal focus move, not a commit.
    if (inRowEdit()) 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, editingCol, editingRow, 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);
    setActiveIsHeader(false);
    setActiveRow(r);
    setActiveColIndex(c);
    beginEdit(r, c, null);
  }
  function commitEditing() {
    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(absRow + 1);
    const tryFocus = () => {
      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;
    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 absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
        // same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
        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
  }

  useEffect(() => {
    // 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: 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: _expandableRef.current === 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,
      // 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: 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.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 = windowSource().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 (props.virtual && virtualizer.current) {
        virtualizer.current.setOptions(virtualizerOptions());
        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;
      clampActiveCell(nextRowCount, nextColCount);
      // 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 && isGrid()) {
        const follow = pendingEditFollow.current;
        pendingEditFollow.current = 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.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 (props.virtual) {
      gridScrollEl.current = __rozieRoot.current ? __rozieRoot.current!.querySelector('.rdt-scroll') : null;
      virtualizer.current = new Virtualizer(virtualizerOptions());
      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 (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);
    }
  }, []); // 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();
    };
  }, []);
  useEffect(() => {
    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, reFeed, table]);
  useEffect(() => {
    if (_watch0First.current) { _watch0First.current = false; return; }
    reFeed();
  }, [colReg, columnFilters, columnOrder, columnPinning, columnSizing, columnVisibility, data, dataDefault, expanded, globalFilter, grouping, pagination, props.expandable, props.groupable, props.selectionMode, rowSelection, sorting]); // 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 });
  _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 };
  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) }), []);

  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>}<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={rows.length} onKeyDown={($event) => { onGridKeyDown($event); }} onFocus={($event) => { syncActiveFromEvent($event); }} onBlur={($event) => { onGridFocusOut($event); }} onMouseDown={($event) => { onGridMouseDown($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" data-rozie-s-d5dcab4c="">
          {hg.headers.map((header) => <th key={header.id} className={clsx("rdt-th", { "rdt-select-th": isSelectColumn(header.column.id), "rdt-th-resizing": columnIsResizing(header.column.id) })} 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> : <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>}{(columnIsFilterable(header.column.id)) && <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, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}
              </span>}<span className={"rdt-pin-controls"} role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c="">
                <button type="button" className={"rdt-pin-btn rdt-pin-left"} aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} onClick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">⇤</button>
                <button type="button" className={"rdt-pin-btn rdt-pin-none"} aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} onClick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c="">⇔</button>
                <button type="button" className={"rdt-pin-btn rdt-pin-right"} aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} onClick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">⇥</button>
              </span>
              <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>)}
      </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={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-level={(groupingActive() ? wr.row.depth + 1 : undefined) ?? undefined} data-rozie-s-d5dcab4c="">
          {visibleCellsFor(wr.row).map((cellCtx) => <td key={cellCtx.id} className={clsx("rdt-td", { "rdt-select-td": isSelectColumn(cellCtx.column.id), "rdt-in-range": inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(wr.vi.index)} data-col-index={rozieAttr(colIndexOf(wr.row, cellCtx))} tabIndex={cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx))} style={parseInlineStyle(bodyCellStyle(wr.row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx)))} data-in-range={rozieAttr(inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : undefined)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : undefined)} data-rozie-s-d5dcab4c="">
            
            {(isExpanderColumn(cellCtx.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(cellCtx.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(cellCtx)) ? <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: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }) : rozieDisplay(cellCtx.getValue())}
              </span>
              <span className={"rdt-group-count"} data-rozie-s-d5dcab4c="">{rozieDisplay('(' + groupSubRowCount(wr.row) + ')')}</span>
            </span> : (isEditing(wr.vi.index, colIndexOf(wr.row, cellCtx))) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(hasEditorSlot(cellCtx.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(props.renderEditor ?? props.slots?.['editor'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}
              </span> : (editorTypeOf(cellCtx.column.id) === 'number') ? <input className={"rdt-cell-editor"} type="number" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /> : (editorTypeOf(cellCtx.column.id) === 'select') ? <select className={"rdt-cell-editor"} data-editing-cell="" value={editorValueFor(cellCtx.column.id)} onChange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
                {editorOptionsOf(cellCtx.column.id).map((opt) => <option key={opt.value} value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt.label)}</option>)}
              </select> : (editorTypeOf(cellCtx.column.id) === 'checkbox') ? <input className={"rdt-cell-editor"} type="checkbox" data-editing-cell="" checked={editorCheckedFor(cellCtx.column.id)} onChange={($event) => { onCellEditorCheckbox(cellCtx.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(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}</span> : <span className={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
              {(props.renderCell ?? props.slots?.['cell']) ? ((props.renderCell ?? props.slots?.['cell']) as Function)({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }) : rozieDisplay(cellCtx.getValue())}
            </span>}{(isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cellCtx))) && <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={totalRowCount()} onKeyDown={($event) => { onGridKeyDown($event); }} onFocus={($event) => { syncActiveFromEvent($event); }} onBlur={($event) => { onGridFocusOut($event); }} onMouseDown={($event) => { onGridMouseDown($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" data-rozie-s-d5dcab4c="">
          {hg.headers.map((header) => <th key={header.id} className={clsx("rdt-th", { "rdt-select-th": isSelectColumn(header.column.id), "rdt-th-resizing": columnIsResizing(header.column.id) })} 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> : <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>}{(columnIsFilterable(header.column.id)) && <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, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}
              </span>}<span className={"rdt-pin-controls"} role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c="">
                <button type="button" className={"rdt-pin-btn rdt-pin-left"} aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} onClick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">⇤</button>
                <button type="button" className={"rdt-pin-btn rdt-pin-none"} aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} onClick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c="">⇔</button>
                <button type="button" className={"rdt-pin-btn rdt-pin-right"} aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} onClick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">⇥</button>
              </span>
              
              <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>)}
      </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={(isGrid() ? absRowIndexOf(row) + 1 : undefined) ?? undefined} 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-level={(groupingActive() ? row.depth + 1 : undefined) ?? undefined} data-rozie-s-d5dcab4c="">
          {visibleCellsFor(row).map((cellCtx) => <td key={cellCtx.id} className={clsx("rdt-td", { "rdt-select-td": isSelectColumn(cellCtx.column.id), "rdt-in-range": inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(rowIndexOf(row))} data-col-index={rozieAttr(colIndexOf(row, cellCtx))} tabIndex={cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx))} style={parseInlineStyle(bodyCellStyle(row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx)))} data-in-range={rozieAttr(inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : undefined)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : undefined)} data-rozie-s-d5dcab4c="">
            
            {(isExpanderColumn(cellCtx.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(cellCtx.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(cellCtx)) ? <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: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }) : rozieDisplay(cellCtx.getValue())}
              </span>
              <span className={"rdt-group-count"} data-rozie-s-d5dcab4c="">{rozieDisplay('(' + groupSubRowCount(row) + ')')}</span>
            </span> : (isEditing(rowIndexOf(row), colIndexOf(row, cellCtx))) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {(hasEditorSlot(cellCtx.column.id)) ? <span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(props.renderEditor ?? props.slots?.['editor'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}
              </span> : (editorTypeOf(cellCtx.column.id) === 'number') ? <input className={"rdt-cell-editor"} type="number" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /> : (editorTypeOf(cellCtx.column.id) === 'select') ? <select className={"rdt-cell-editor"} data-editing-cell="" value={editorValueFor(cellCtx.column.id)} onChange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
                {editorOptionsOf(cellCtx.column.id).map((opt) => <option key={opt.value} value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt.label)}</option>)}
              </select> : (editorTypeOf(cellCtx.column.id) === 'checkbox') ? <input className={"rdt-cell-editor"} type="checkbox" data-editing-cell="" checked={editorCheckedFor(cellCtx.column.id)} onChange={($event) => { onCellEditorCheckbox(cellCtx.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(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}</span> : <span className={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
              {(props.renderCell ?? props.slots?.['cell']) ? ((props.renderCell ?? props.slots?.['cell']) as Function)({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }) : rozieDisplay(cellCtx.getValue())}
            </span>}{(isFillHandleCell(rowIndexOf(row), colIndexOf(row, cellCtx))) && <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 ' + pageCount())}
      </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 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="rows.length" @keydown="onGridKeyDown($event)" @focusin="syncActiveFromEvent($event)" @focusout="onGridFocusOut($event)" @mousedown="onGridMouseDown($event)">
  <thead class="rdt-thead" role="rowgroup">
    <tr v-for="(hg, hgLevel) in headerGroups" :key="hg.id" class="rdt-tr" role="row">
      <th v-for="header in hg.headers" :key="header.id" :class="['rdt-th', { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }]" 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 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><input v-if="columnIsFilterable(header.column.id)" 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" :uniqueValues="getFacetedUniqueValues(header.column.id)" :minMax="getFacetedMinMaxValues(header.column.id)" :setFilter="setColumnFilter"></slot>
          </span><span class="rdt-pin-controls" role="group" :aria-label="'Pin ' + headerLabel(header.column.id)">
            <button type="button" class="rdt-pin-btn rdt-pin-left" :aria-label="'Pin ' + headerLabel(header.column.id) + ' to left'" :aria-pressed="columnPinSide(header.column.id) === 'left'" @click="onPinColumn(header.column.id, 'left', $event)">⇤</button>
            <button type="button" class="rdt-pin-btn rdt-pin-none" :aria-label="'Unpin ' + headerLabel(header.column.id)" :aria-pressed="!columnPinSide(header.column.id)" @click="onPinColumn(header.column.id, false, $event)">⇔</button>
            <button type="button" class="rdt-pin-btn rdt-pin-right" :aria-label="'Pin ' + headerLabel(header.column.id) + ' to right'" :aria-pressed="columnPinSide(header.column.id) === 'right'" @click="onPinColumn(header.column.id, 'right', $event)">⇥</button>
          </span>
          <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>
  </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="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-level="(groupingActive() ? wr.row.depth + 1 : undefined) ?? undefined">
      <td v-for="cellCtx in visibleCellsFor(wr.row)" :key="cellCtx.id" :class="['rdt-td', { 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) }]" :role="cellRole()" :data-col="cellCtx.column.id" data-grid-cell="" :data-row="wr.vi.index" :data-col-index="colIndexOf(wr.row, cellCtx)" :tabindex="(cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx))) ?? undefined" :style="bodyCellStyle(wr.row, cellCtx.column.id)" :aria-invalid="(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx))) ?? undefined" :data-in-range="inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : undefined" :data-agg-cell="cellIsAggregated(cellCtx) ? cellCtx.column.id : undefined">
        
        <span v-if="isExpanderColumn(cellCtx.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(cellCtx.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(cellCtx)" 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="cellCtx.column.id" :column="cellCtx.column" :row="wr.row.original" :value="cellCtx.getValue()">{{ cellCtx.getValue() }}</slot>
          </span>
          <span class="rdt-group-count">{{ '(' + groupSubRowCount(wr.row) + ')' }}</span>
        </span><span v-else-if="isEditing(wr.vi.index, colIndexOf(wr.row, cellCtx))" style="display:contents">
          <span v-if="hasEditorSlot(cellCtx.column.id)" style="display:contents">
            <slot name="editor" :columnId="cellCtx.column.id" :column="cellCtx.column" :row="wr.row.original" :value="editorValueFor(cellCtx.column.id)" :commit="editorCommitFor(cellCtx.column.id)" :cancel="editorCancelFor()"></slot>
          </span><input v-else-if="editorTypeOf(cellCtx.column.id) === 'number'" class="rdt-cell-editor" type="number" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @input="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><select v-else-if="editorTypeOf(cellCtx.column.id) === 'select'" class="rdt-cell-editor" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @change="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)">
            <option v-for="opt in editorOptionsOf(cellCtx.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
          </select><input v-else-if="editorTypeOf(cellCtx.column.id) === 'checkbox'" class="rdt-cell-editor" type="checkbox" data-editing-cell="" :checked="editorCheckedFor(cellCtx.column.id)" @change="onCellEditorCheckbox(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><input v-else class="rdt-cell-editor" type="text" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @input="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /></span><span v-else class="rdt-cell-value">
          <slot name="cell" :columnId="cellCtx.column.id" :column="cellCtx.column" :row="wr.row.original" :value="cellCtx.getValue()">{{ cellCtx.getValue() }}</slot>
        </span><span v-if="isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cellCtx))" 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="(totalRowCount()) ?? undefined" @keydown="onGridKeyDown($event)" @focusin="syncActiveFromEvent($event)" @focusout="onGridFocusOut($event)" @mousedown="onGridMouseDown($event)">
  <thead class="rdt-thead" role="rowgroup">
    <tr v-for="(hg, hgLevel) in headerGroups" :key="hg.id" class="rdt-tr" role="row">
      <th v-for="header in hg.headers" :key="header.id" :class="['rdt-th', { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }]" 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 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><input v-if="columnIsFilterable(header.column.id)" 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" :uniqueValues="getFacetedUniqueValues(header.column.id)" :minMax="getFacetedMinMaxValues(header.column.id)" :setFilter="setColumnFilter"></slot>
          </span><span class="rdt-pin-controls" role="group" :aria-label="'Pin ' + headerLabel(header.column.id)">
            <button type="button" class="rdt-pin-btn rdt-pin-left" :aria-label="'Pin ' + headerLabel(header.column.id) + ' to left'" :aria-pressed="columnPinSide(header.column.id) === 'left'" @click="onPinColumn(header.column.id, 'left', $event)">⇤</button>
            <button type="button" class="rdt-pin-btn rdt-pin-none" :aria-label="'Unpin ' + headerLabel(header.column.id)" :aria-pressed="!columnPinSide(header.column.id)" @click="onPinColumn(header.column.id, false, $event)">⇔</button>
            <button type="button" class="rdt-pin-btn rdt-pin-right" :aria-label="'Pin ' + headerLabel(header.column.id) + ' to right'" :aria-pressed="columnPinSide(header.column.id) === 'right'" @click="onPinColumn(header.column.id, 'right', $event)">⇥</button>
          </span>
          
          <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>
  </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="(isGrid() ? absRowIndexOf(row) + 1 : undefined) ?? 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-level="(groupingActive() ? row.depth + 1 : undefined) ?? undefined">
      <td v-for="cellCtx in visibleCellsFor(row)" :key="cellCtx.id" :class="['rdt-td', { 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) }]" :role="cellRole()" :data-col="cellCtx.column.id" data-grid-cell="" :data-row="rowIndexOf(row)" :data-col-index="colIndexOf(row, cellCtx)" :tabindex="(cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx))) ?? undefined" :style="bodyCellStyle(row, cellCtx.column.id)" :aria-invalid="(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx))) ?? undefined" :data-in-range="inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : undefined" :data-agg-cell="cellIsAggregated(cellCtx) ? cellCtx.column.id : undefined">
        
        <span v-if="isExpanderColumn(cellCtx.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(cellCtx.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(cellCtx)" 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="cellCtx.column.id" :column="cellCtx.column" :row="row.original" :value="cellCtx.getValue()">{{ cellCtx.getValue() }}</slot>
          </span>
          <span class="rdt-group-count">{{ '(' + groupSubRowCount(row) + ')' }}</span>
        </span><span v-else-if="isEditing(rowIndexOf(row), colIndexOf(row, cellCtx))" style="display:contents">
          <span v-if="hasEditorSlot(cellCtx.column.id)" style="display:contents">
            <slot name="editor" :columnId="cellCtx.column.id" :column="cellCtx.column" :row="row.original" :value="editorValueFor(cellCtx.column.id)" :commit="editorCommitFor(cellCtx.column.id)" :cancel="editorCancelFor()"></slot>
          </span><input v-else-if="editorTypeOf(cellCtx.column.id) === 'number'" class="rdt-cell-editor" type="number" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @input="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><select v-else-if="editorTypeOf(cellCtx.column.id) === 'select'" class="rdt-cell-editor" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @change="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)">
            <option v-for="opt in editorOptionsOf(cellCtx.column.id)" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
          </select><input v-else-if="editorTypeOf(cellCtx.column.id) === 'checkbox'" class="rdt-cell-editor" type="checkbox" data-editing-cell="" :checked="editorCheckedFor(cellCtx.column.id)" @change="onCellEditorCheckbox(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /><input v-else class="rdt-cell-editor" type="text" data-editing-cell="" :value="editorValueFor(cellCtx.column.id)" @input="onCellEditorInput(cellCtx.column.id, $event)" @keydown="onEditorKeyDown($event)" @blur="onEditorBlur($event)" /></span><span v-else class="rdt-cell-value">
          <slot name="cell" :columnId="cellCtx.column.id" :column="cellCtx.column" :row="row.original" :value="cellCtx.getValue()">{{ cellCtx.getValue() }}</slot>
        </span><span v-if="isFillHandleCell(rowIndexOf(row), colIndexOf(row, cellCtx))" 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 ' + pageCount() }}
  </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 { 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;
    /**
     * 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) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
     * @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
     */
    interactionMode?: string;
    /**
     * 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, expandable: false, getSubRows: null, groupable: false, stickyHeader: false, interactionMode: 'table', 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>('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[]>('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[]];
  '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; 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 }): 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; 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 }): 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 editingRowIndex = ref<any>(null);
const rowDraft = ref({});
const rangeAnchor = ref<any>(null);
const rangeFocus = ref<any>(null);
const pasteAnnounce = 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).
// 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).
// 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.
// ── 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.
// 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.
// ── 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).
// 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.
// 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;

// 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).
// 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.
// 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;

// 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).
// 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: columnPinning.value != null ? columnPinning.value : columnPinningDefault.value,
  // 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.
// 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.)
// 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).
// 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).
// 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 gid = c.id != null ? c.id : c.header;
    if (gid == null) return null;
    const id = String(gid);
    if (!isSafeKey(id)) return null;
    const kids = [];
    for (const child of c.columns as any) {
      const cd = buildConfigDef(child);
      if (cd) kids.push(cd);
    }
    if (!kids.length) 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 "__").
// 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.
// 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: ''
    };
    withExpander = [expanderCol].concat(cols);
  }
  if (selectionEnabled()) {
    const selectCol = {
      id: SELECT_COL_ID,
      enableSorting: false,
      enableColumnFilter: false,
      filterable: false,
      isSelectColumn: true,
      pinned: '',
      width: ''
    };
    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.
// ── 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).
// ── 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.
// ── 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.
// ── 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).
// ── 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`.
// ── 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.
// ── 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.
// ── 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.
// ── 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.
// ── 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.
// ── 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;
  programmatic++;
  columnPinningDefault.value = next;
  columnPinning.value = next;
  emit('pin-change', next);
  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.
// ── 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.
const writeData = (next: any) => {
  if (programmatic) return;
  programmatic++;
  dataDefault.value = next; // fresh array only (never in-place)
  data.value = next; // 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).
// 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).
// 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);
};

// 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`.
// 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.
// 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.
// ══ 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.
// 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).
// 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.
// 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.
// 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);
};

// 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).
// ══ 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.
// 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.
// 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).
// 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.
// 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?
// 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).
// 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;
};
// 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.
// 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;
  table.setOptions((prev: any) => ({
    ...prev,
    data: currentData(),
    columns: tableColumns(),
    state: currentState(),
    enableRowSelection: props.selectionMode !== 'none',
    enableMultiRowSelection: props.selectionMode === 'multiple',
    // 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.
// 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.
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.
// 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).
// 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.
// 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).
// 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. Solid's reference-keyed <For> keeps the existing <tr> and 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.
// 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. Solid's reference-keyed <For> keeps the existing <tr> and 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.
// ── 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).
// 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'.
// 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'.
// 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).
// 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;
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).
// ── 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).
// 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.
// 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.
// 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 select column (always present).
// The full set of leaf columns (for the visibility-toggle menu) — id + header label +
// current visibility. Excludes the auto-injected select column (always present).
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) 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).
// 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.
// 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).
// 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).
const pinStyle = (colId: any) => {
  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:1;';
  }
  if (side === 'right') {
    const right = col.getAfter ? col.getAfter('right') : 0;
    return 'position:sticky;right:' + right + 'px;z-index:1;';
  }
  return '';
};
// Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
// uniform on all 6, no bound-object trap.
// Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
// uniform on all 6, no bound-object trap.
const thStyle = (colId: any) => {
  let s = '';
  const w = headerWidth(colId);
  if (w) s += 'width:' + w + ';';
  s += pinStyle(colId);
  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).
// ── 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).
// 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).
// 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).
// ── 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;
};
const pageCount = () => {
  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).
// ── 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).
// ── 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;
const rowCanExpand = (row: any) => !!(tick() >= 0 && row && row.getCanExpand && row.getCanExpand());
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. 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).
// rowShowsDetail: the #detail <tr> renders ONLY in #detail mode (no getSubRows) when the
// row is expanded. 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).
const rowShowsDetail = (row: any) => props.getSubRows == null && 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).
// 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. On Solid the expander <td>/<button> is RECREATED on that
  // re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
  // pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
  // breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
  // Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
  // the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
  // reactive flush so the fresh node exists. Harmless on the targets that keep the node
  // (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
  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).
// 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) {
    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.
// ── 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).
// 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.
// 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());
// groupSubRowCount: the number of immediate members under a group-header row (the count shown in
// the header, e.g. "North (3)").
// groupSubRowCount: the number of immediate members under a group-header row (the count shown in
// the header, e.g. "North (3)").
const groupSubRowCount = (row: any) => 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.
// 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.
// 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.
// 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).
// 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.
// 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));
};
// `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(...)`.
// `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.
// 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.
// 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.
// 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.
// 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).
// 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).
// 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.
// 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();

// ══ 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).
// ══ 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.
// 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.
// ── 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.
// 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.
// 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.
// ── 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.
// 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().
// 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().
const absRowIndexOf = (row: any) => rowIndexOf(row) + pageRowOffset();
// 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.
// 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.
// 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;
};

// ── 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).
// ── 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.
// 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;
  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;
    const focusWhenReady = () => {
      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).
// ══ 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;
};

// 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.
// 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.
// ── 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;
};
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.
// 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.
// 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).
// ── 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) => {
  const max = visibleColCount() - 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).
// 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.
// Home/End within the current row → col 0 / max. Returns the fresh colIndex.
const gotoColEdge = (toEnd: any) => {
  const max = visibleColCount() - 1;
  const nextCol = toEnd ? max < 0 ? 0 : max : 0;
  activeColIndex.value = nextCol;
  return nextCol;
};

// 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).
// 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.
// 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.
// 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.
// 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.
// 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).
// 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. ──────────
  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;
  }
  // ── 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;
  }
  // ── 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) {
    // 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.
    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);
    recoverGridFocus(String(grpRow), grpCol, null);
    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) {
    emit('activecell-change', {
      rowIndex: toAbsRow(nextRow),
      colIndex: nextCol
    });
  }
};

// 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).
// 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;
  const isHeader = rowAttr === '__header';
  activeIsHeader.value = isHeader;
  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();
    activeHeaderLevel.value = Number.isFinite(lvl) ? lvl : headerLeafLevel();
  } else {
    const row = parseInt(rowAttr, 10);
    if (Number.isFinite(row)) activeRow.value = row;
  }
  activeColIndex.value = col;
  // 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 Shift+Click range-extend seam (phase 51 req-7 / D-07). 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),
// riding the same data-row/data-col-index parse seam, then flags rangeClickPending so the
// follow-up focusin does not collapse the range. A plain (non-shift) mousedown is ignored
// here (the focusin owns the active-cell sync + the range collapse).
// onGridMouseDown: the Shift+Click range-extend seam (phase 51 req-7 / D-07). 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),
// riding the same data-row/data-col-index parse seam, then flags rangeClickPending so the
// follow-up focusin does not collapse the range. A plain (non-shift) mousedown is ignored
// here (the focusin owns the active-cell sync + the range collapse).
const onGridMouseDown = (e: any) => {
  if (!isGrid() || !e || !e.shiftKey) 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;
  setRangeFocus(row, col);
  activeIsHeader.value = false;
  activeRow.value = row;
  activeColIndex.value = col;
  rangeClickPending = true;
};

// 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.
// 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.
// 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.
const recoverGridFocus = (rowKey: any, col: any, level: any) => {
  if (!gridRoot) return;
  let attempts = 0;
  const tryFocus = () => {
    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).
// 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.
// 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.
// ══ 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.
// 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.
// 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).
// 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).
// 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.
// 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.
// 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.
// 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);
};

// 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).
// 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.
// 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.
// ══ 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.
// 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.
// 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.
// 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).
// 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.
// 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).
// 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.
// 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 }.
// 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.
// 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).
// 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;
  const srcCols = srcRows > 0 ? grid[0].length : 0;
  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.
// 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.
// 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);
};

// 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.
// 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.
// 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.
// 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).
// 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();
  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();
    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);
  }
};

// ══ 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).
// ══ 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.
// 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).
// 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.
// 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).
// 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.
// 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).
// 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.
// 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.
// 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.
// 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.
const focusEditorWhenReady = (selectAll = true) => {
  if (!gridRoot) return;
  let attempts = 0;
  const tryFocus = () => {
    const el = gridRoot ? gridRoot.querySelector('[data-editing-cell]') : null;
    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).
// 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.
// 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;
  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;
  // 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.
// 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).
// 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).
// 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;
};

// 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.
// 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;
};

// 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).
// 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).
// 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;
  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('');
  const srcIndex = sourceIndexOfRow(editingRow.value);
  const next = replaceRowValue(currentData(), srcIndex, field, newValue);
  // 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;
  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;
  // 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
  };
  return true;
};

// cancelEdit: discard the draft (D-05 — revert to the pre-edit value, no model write) and
// return focus to the owning cell.
// 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).
// ══ 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).
// 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).
const focusRowEditorAt = (rowIndex: any, colIndex: any) => {
  if (!gridRoot) 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.
// 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;
  // 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;
  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.
// 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);
      // 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.
      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
    });
  }
  // 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);
  // Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
  // active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
  const focusCol = activeColIndex.value;
  editTransition = true;
  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;
  // 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
  };
  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.
// 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).
// 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.
// 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.
// 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).
// 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).
// 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;

// ── 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).
// ── 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.
// #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.
// 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).
// 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.
// 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;
  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.
// 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.
// 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): blur NEVER commits — the row commits as a UNIT only on an
  // explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
  // into N writes + N events, violating the one-write/one-event contract). Tabbing between
  // the row's own editors is a normal focus move, not a commit.
  if (inRowEdit()) 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.
// 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);
  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
// no cell is editing. Collision-clean (not `commit`).
// commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
// no cell is editing. Collision-clean (not `commit`).
const commitEditing = () => {
  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.
// 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 aria-rowindex === absRow+1 (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.
// ── 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 aria-rowindex === absRow+1 (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.
const focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
  if (!gridRoot) return;
  let attempts = 0;
  const want = String(absRow + 1);
  const tryFocus = () => {
    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).
// 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;
  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 absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
      // same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
      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.
// 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.
// 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.
// ── 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).
// 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.
// 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.
// 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.
// ── 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).
// ── 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).
// 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,
    // 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);
    // 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);
  }
});
onBeforeUnmount(() => {
  if (virtualizerCleanup) virtualizerCleanup();
  // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
  teardownFillDrag();
});
onUpdated(() => {
  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, 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, colReg.value], () => {
  reFeed();
});

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 });
</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 {
  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);
  background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.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);
  background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.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 .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 .rdt-col-filter {
  font: inherit;
  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 .rdt-col-filter {
  display: block;
  margin-top: var(--rdt-col-filter-gap, 0.25rem);
  width: 100%;
  font-weight: normal;
}
.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-pin-controls {
  display: inline-flex;
  gap: var(--rdt-pin-gap, 0.1em);
  margin-left: var(--rdt-pin-margin, 0.35em);
}
.rozie-data-table .rdt-pin-btn {
  font: inherit;
  font-size: var(--rdt-pin-btn-size, 0.8em);
  line-height: 1;
  cursor: pointer;
  padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
  border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-pin-btn-radius, 3px);
  background: var(--rdt-pin-btn-bg, transparent);
  color: inherit;
}
.rozie-data-table .rdt-pin-btn[aria-pressed='true'] {
  background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
  font-weight: 700;
}
.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, 1%);
  text-align: var(--rdt-select-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 { 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;
  /**
   * 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) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
   * @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
   */
  interactionMode?: string;
  /**
   * 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; 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 }]>;
  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;
  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,
  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',
  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,
  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 = $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 editingRowIndex: any = $state(null);
let rowDraft = $state({});
let rangeAnchor: any = $state(null);
let rangeFocus: any = $state(null);
let pasteAnnounce = $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).
// 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).
// 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.
// ── 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.
// 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.
// ── 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).
// 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.
// 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;

// 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).
// 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.
// 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;

// 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).
// 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: columnPinning != null ? columnPinning : columnPinningDefault,
  // 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.
// 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.)
// 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).
// 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).
// 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 gid = c.id != null ? c.id : c.header;
    if (gid == null) return null;
    const id = String(gid);
    if (!isSafeKey(id)) return null;
    const kids = [];
    for (const child of c.columns as any) {
      const cd = buildConfigDef(child);
      if (cd) kids.push(cd);
    }
    if (!kids.length) 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 "__").
// 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.
// 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: ''
    };
    withExpander = [expanderCol].concat(cols);
  }
  if (selectionEnabled()) {
    const selectCol = {
      id: SELECT_COL_ID,
      enableSorting: false,
      enableColumnFilter: false,
      filterable: false,
      isSelectColumn: true,
      pinned: '',
      width: ''
    };
    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.
// ── 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).
// ── 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.
// ── 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.
// ── 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).
// ── 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`.
// ── 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.
// ── 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.
// ── 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.
// ── 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.
// ── 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.
// ── 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;
  programmatic++;
  columnPinningDefault = next;
  columnPinning = next;
  onpinchange?.(next);
  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.
// ── 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.
const writeData = (next: any) => {
  if (programmatic) return;
  programmatic++;
  dataDefault = next; // fresh array only (never in-place)
  data = next; // 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).
// 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).
// 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);
};

// 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`.
// 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.
// 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.
// ══ 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.
// 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).
// 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.
// 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.
// 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);
};

// 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).
// ══ 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.
// 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.
// 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).
// 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.
// 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?
// 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).
// 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;
};
// 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.
// 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;
  table.setOptions((prev: any) => ({
    ...prev,
    data: currentData(),
    columns: tableColumns(),
    state: currentState(),
    enableRowSelection: selectionMode !== 'none',
    enableMultiRowSelection: selectionMode === 'multiple',
    // 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.
// 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.
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.
// 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).
// 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.
// 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).
// 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. Solid's reference-keyed <For> keeps the existing <tr> and 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.
// 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. Solid's reference-keyed <For> keeps the existing <tr> and 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.
// ── 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).
// 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'.
// 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'.
// 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).
// 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;
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).
// ── 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).
// 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.
// 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.
// 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 select column (always present).
// The full set of leaf columns (for the visibility-toggle menu) — id + header label +
// current visibility. Excludes the auto-injected select column (always present).
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) 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).
// 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.
// 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).
// 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).
const pinStyle = (colId: any) => {
  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:1;';
  }
  if (side === 'right') {
    const right = col.getAfter ? col.getAfter('right') : 0;
    return 'position:sticky;right:' + right + 'px;z-index:1;';
  }
  return '';
};
// Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
// uniform on all 6, no bound-object trap.
// Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
// uniform on all 6, no bound-object trap.
const thStyle = (colId: any) => {
  let s = '';
  const w = headerWidth(colId);
  if (w) s += 'width:' + w + ';';
  s += pinStyle(colId);
  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).
// ── 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).
// 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).
// 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).
// ── 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;
};
const pageCount = () => {
  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).
// ── 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).
// ── 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;
const rowCanExpand = (row: any) => !!(tick() >= 0 && row && row.getCanExpand && row.getCanExpand());
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. 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).
// rowShowsDetail: the #detail <tr> renders ONLY in #detail mode (no getSubRows) when the
// row is expanded. 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).
const rowShowsDetail = (row: any) => getSubRows == null && 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).
// 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. On Solid the expander <td>/<button> is RECREATED on that
  // re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
  // pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
  // breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
  // Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
  // the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
  // reactive flush so the fresh node exists. Harmless on the targets that keep the node
  // (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
  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).
// 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) {
    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.
// ── 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).
// 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.
// 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());
// groupSubRowCount: the number of immediate members under a group-header row (the count shown in
// the header, e.g. "North (3)").
// groupSubRowCount: the number of immediate members under a group-header row (the count shown in
// the header, e.g. "North (3)").
const groupSubRowCount = (row: any) => 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.
// 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.
// 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.
// 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).
// 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.
// 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));
};
// `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(...)`.
// `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.
// 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.
// 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.
// 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.
// 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).
// 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).
// 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.
// 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();

// ══ 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).
// ══ 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.
// 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.
// ── 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.
// 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.
// 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.
// ── 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.
// 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().
// 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().
const absRowIndexOf = (row: any) => rowIndexOf(row) + pageRowOffset();
// 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.
// 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.
// 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;
};

// ── 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).
// ── 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.
// 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;
  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;
    const focusWhenReady = () => {
      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).
// ══ 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;
};

// 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.
// 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.
// ── 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;
};
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.
// 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.
// 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).
// ── 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) => {
  const max = visibleColCount() - 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).
// 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.
// Home/End within the current row → col 0 / max. Returns the fresh colIndex.
const gotoColEdge = (toEnd: any) => {
  const max = visibleColCount() - 1;
  const nextCol = toEnd ? max < 0 ? 0 : max : 0;
  activeColIndex = nextCol;
  return nextCol;
};

// 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).
// 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.
// 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.
// 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.
// 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.
// 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).
// 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. ──────────
  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;
  }
  // ── 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;
  }
  // ── 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) {
    // 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.
    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);
    recoverGridFocus(String(grpRow), grpCol, null);
    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) {
    onactivecellchange?.({
      rowIndex: toAbsRow(nextRow),
      colIndex: nextCol
    });
  }
};

// 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).
// 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;
  const isHeader = rowAttr === '__header';
  activeIsHeader = isHeader;
  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();
    activeHeaderLevel = Number.isFinite(lvl) ? lvl : headerLeafLevel();
  } else {
    const row = parseInt(rowAttr, 10);
    if (Number.isFinite(row)) activeRow = row;
  }
  activeColIndex = col;
  // 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 Shift+Click range-extend seam (phase 51 req-7 / D-07). 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),
// riding the same data-row/data-col-index parse seam, then flags rangeClickPending so the
// follow-up focusin does not collapse the range. A plain (non-shift) mousedown is ignored
// here (the focusin owns the active-cell sync + the range collapse).
// onGridMouseDown: the Shift+Click range-extend seam (phase 51 req-7 / D-07). 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),
// riding the same data-row/data-col-index parse seam, then flags rangeClickPending so the
// follow-up focusin does not collapse the range. A plain (non-shift) mousedown is ignored
// here (the focusin owns the active-cell sync + the range collapse).
const onGridMouseDown = (e: any) => {
  if (!isGrid() || !e || !e.shiftKey) 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;
  setRangeFocus(row, col);
  activeIsHeader = false;
  activeRow = row;
  activeColIndex = col;
  rangeClickPending = true;
};

// 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.
// 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.
// 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.
const recoverGridFocus = (rowKey: any, col: any, level: any) => {
  if (!gridRoot) return;
  let attempts = 0;
  const tryFocus = () => {
    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).
// 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.
// 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.
// ══ 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.
// 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.
// 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).
// 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).
// 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.
// 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.
// 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.
// 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);
};

// 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).
// 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.
// 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.
// ══ 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.
// 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.
// 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.
// 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).
// 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.
// 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).
// 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.
// 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 }.
// 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.
// 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).
// 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;
  const srcCols = srcRows > 0 ? grid[0].length : 0;
  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.
// 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.
// 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);
};

// 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.
// 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.
// 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.
// 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).
// 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();
  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();
    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);
  }
};

// ══ 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).
// ══ 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.
// 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).
// 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.
// 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).
// 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.
// 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).
// 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.
// 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.
// 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.
// 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.
const focusEditorWhenReady = (selectAll = true) => {
  if (!gridRoot) return;
  let attempts = 0;
  const tryFocus = () => {
    const el = gridRoot ? gridRoot.querySelector('[data-editing-cell]') : null;
    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).
// 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.
// 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;
  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;
  // 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.
// 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).
// 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).
// 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;
};

// 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.
// 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;
};

// 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).
// 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).
// 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;
  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('');
  const srcIndex = sourceIndexOfRow(editingRow);
  const next = replaceRowValue(currentData(), srcIndex, field, newValue);
  // 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;
  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;
  // 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
  };
  return true;
};

// cancelEdit: discard the draft (D-05 — revert to the pre-edit value, no model write) and
// return focus to the owning cell.
// 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).
// ══ 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).
// 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).
const focusRowEditorAt = (rowIndex: any, colIndex: any) => {
  if (!gridRoot) 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.
// 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;
  // 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;
  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.
// 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);
      // 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.
      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
    });
  }
  // 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);
  // Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
  // active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
  const focusCol = activeColIndex;
  editTransition = true;
  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;
  // 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
  };
  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.
// 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).
// 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.
// 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.
// 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).
// 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).
// 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;

// ── 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).
// ── 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.
// #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.
// 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).
// 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.
// 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;
  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.
// 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.
// 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): blur NEVER commits — the row commits as a UNIT only on an
  // explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
  // into N writes + N events, violating the one-write/one-event contract). Tabbing between
  // the row's own editors is a normal focus move, not a commit.
  if (inRowEdit()) 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.
// 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);
  activeIsHeader = false;
  activeRow = r;
  activeColIndex = c;
  beginEdit(r, c, null);
};

// commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
// no cell is editing. Collision-clean (not `commit`).
// commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
// no cell is editing. Collision-clean (not `commit`).
export const commitEditing = () => {
  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.
// 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 aria-rowindex === absRow+1 (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.
// ── 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 aria-rowindex === absRow+1 (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.
const focusAbsCellWhenReady = (absRow: any, localRow: any, col: any) => {
  if (!gridRoot) return;
  let attempts = 0;
  const want = String(absRow + 1);
  const tryFocus = () => {
    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).
// 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;
  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 absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
      // same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
      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.
// 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.
// 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.
// ── 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).
// 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.
// 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.
// 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.
// ── 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).
// ── 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).
// 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,
    // 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);
    // 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);
  }
});
onDestroy(() => (() => {
  if (virtualizerCleanup) virtualizerCleanup();
  // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
  teardownFillDrag();
})());
$effect(() => (() => {
  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, 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, colReg])(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } (() => {
  reFeed();
})(); }); });
</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}<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={rows.length} onkeydown={($event) => { onGridKeyDown($event); }} onfocusin={($event) => { syncActiveFromEvent($event); }} onfocusout={($event) => { onGridFocusOut($event); }} onmousedown={($event) => { onGridMouseDown($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" data-rozie-s-d5dcab4c>{#each hg.headers as header (header.id)}<th class={["rdt-th", { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }]} 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}<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}{#if columnIsFilterable(header.column.id)}<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, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}</span>{/if}<span class="rdt-pin-controls" role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c><button type="button" class="rdt-pin-btn rdt-pin-left" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} onclick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>⇤</button><button type="button" class="rdt-pin-btn rdt-pin-none" aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} onclick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>⇔</button><button type="button" class="rdt-pin-btn rdt-pin-right" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} onclick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>⇥</button></span><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}</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(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-level={rozieAttr(groupingActive() ? wr.row.depth + 1 : null)} data-rozie-s-d5dcab4c>{#each visibleCellsFor(wr.row) as cellCtx (cellCtx.id)}<td class={["rdt-td", { 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) }]} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(wr.vi.index)} data-col-index={rozieAttr(colIndexOf(wr.row, cellCtx))} tabindex={rozieAttr(cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx)))} style={rozieStyle(bodyCellStyle(wr.row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx)))} data-in-range={rozieAttr(inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c>{#if isExpanderColumn(cellCtx.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(cellCtx.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(cellCtx)}<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}{@render cell({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() })}{:else}{rozieDisplay(cellCtx.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, cellCtx))}<span style="display:contents" data-rozie-s-d5dcab4c>{#if hasEditorSlot(cellCtx.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{@render editor?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}</span>{:else if editorTypeOf(cellCtx.column.id) === 'number'}<input class="rdt-cell-editor" type="number" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} oninput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{:else if editorTypeOf(cellCtx.column.id) === 'select'}<select class="rdt-cell-editor" data-editing-cell="" value={rozieAttr(editorValueFor(cellCtx.column.id))} onchange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c>{#each editorOptionsOf(cellCtx.column.id) as opt (opt.value)}<option value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c>{rozieDisplay(opt.label)}</option>{/each}</select>{:else if editorTypeOf(cellCtx.column.id) === 'checkbox'}<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" checked={editorCheckedFor(cellCtx.column.id)} onchange={($event) => { onCellEditorCheckbox(cellCtx.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(cellCtx.column.id)} oninput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{/if}</span>{:else}<span class="rdt-cell-value" data-rozie-s-d5dcab4c>{#if cell}{@render cell({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() })}{:else}{rozieDisplay(cellCtx.getValue())}{/if}</span>{/if}{#if isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cellCtx))}<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(totalRowCount())} onkeydown={($event) => { onGridKeyDown($event); }} onfocusin={($event) => { syncActiveFromEvent($event); }} onfocusout={($event) => { onGridFocusOut($event); }} onmousedown={($event) => { onGridMouseDown($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" data-rozie-s-d5dcab4c>{#each hg.headers as header (header.id)}<th class={["rdt-th", { 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }]} 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}<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}{#if columnIsFilterable(header.column.id)}<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, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}</span>{/if}<span class="rdt-pin-controls" role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c><button type="button" class="rdt-pin-btn rdt-pin-left" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} onclick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>⇤</button><button type="button" class="rdt-pin-btn rdt-pin-none" aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} onclick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>⇔</button><button type="button" class="rdt-pin-btn rdt-pin-right" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} onclick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>⇥</button></span><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}</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(isGrid() ? absRowIndexOf(row) + 1 : null)} 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-level={rozieAttr(groupingActive() ? row.depth + 1 : null)} data-rozie-s-d5dcab4c>{#each visibleCellsFor(row) as cellCtx (cellCtx.id)}<td class={["rdt-td", { 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) }]} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(rowIndexOf(row))} data-col-index={rozieAttr(colIndexOf(row, cellCtx))} tabindex={rozieAttr(cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx)))} style={rozieStyle(bodyCellStyle(row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx)))} data-in-range={rozieAttr(inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c>{#if isExpanderColumn(cellCtx.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(cellCtx.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(cellCtx)}<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}{@render cell({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() })}{:else}{rozieDisplay(cellCtx.getValue())}{/if}</span><span class="rdt-group-count" data-rozie-s-d5dcab4c>{rozieDisplay('(' + groupSubRowCount(row) + ')')}</span></span>{:else if isEditing(rowIndexOf(row), colIndexOf(row, cellCtx))}<span style="display:contents" data-rozie-s-d5dcab4c>{#if hasEditorSlot(cellCtx.column.id)}<span style="display:contents" data-rozie-s-d5dcab4c>{@render editor?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}</span>{:else if editorTypeOf(cellCtx.column.id) === 'number'}<input class="rdt-cell-editor" type="number" data-editing-cell="" value={editorValueFor(cellCtx.column.id)} oninput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{:else if editorTypeOf(cellCtx.column.id) === 'select'}<select class="rdt-cell-editor" data-editing-cell="" value={rozieAttr(editorValueFor(cellCtx.column.id))} onchange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c>{#each editorOptionsOf(cellCtx.column.id) as opt (opt.value)}<option value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c>{rozieDisplay(opt.label)}</option>{/each}</select>{:else if editorTypeOf(cellCtx.column.id) === 'checkbox'}<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" checked={editorCheckedFor(cellCtx.column.id)} onchange={($event) => { onCellEditorCheckbox(cellCtx.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(cellCtx.column.id)} oninput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onkeydown={($event) => { onEditorKeyDown($event); }} onblur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c />{/if}</span>{:else}<span class="rdt-cell-value" data-rozie-s-d5dcab4c>{#if cell}{@render cell({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() })}{:else}{rozieDisplay(cellCtx.getValue())}{/if}</span>{/if}{#if isFillHandleCell(rowIndexOf(row), colIndexOf(row, cellCtx))}<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 ' + pageCount())}</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[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);
    background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
  }
  .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);
    background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
  }
  .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[data-rozie-s-d5dcab4c] {
    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[data-rozie-s-d5dcab4c] {
    font: inherit;
    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[data-rozie-s-d5dcab4c] {
    display: block;
    margin-top: var(--rdt-col-filter-gap, 0.25rem);
    width: 100%;
    font-weight: normal;
  }
  .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-pin-controls[data-rozie-s-d5dcab4c] {
    display: inline-flex;
    gap: var(--rdt-pin-gap, 0.1em);
    margin-left: var(--rdt-pin-margin, 0.35em);
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[data-rozie-s-d5dcab4c] {
    font: inherit;
    font-size: var(--rdt-pin-btn-size, 0.8em);
    line-height: 1;
    cursor: pointer;
    padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
    border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
    border-radius: var(--rdt-pin-btn-radius, 3px);
    background: var(--rdt-pin-btn-bg, transparent);
    color: inherit;
  }
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[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-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, 1%);
    text-align: var(--rdt-select-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, InjectionToken, TemplateRef, ViewEncapsulation, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';

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).
// 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; uniqueValues: any; minMax: any; setFilter: any };
  columnId: 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 };
  columnId: any;
  column: any;
  row: any;
  value: any;
  commit: any;
  cancel: any;
}

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

function __rozieDisplay(v: unknown): string {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'object') {
    try {
      return JSON.stringify(v, null, 2);
    } catch {
      // Circular structure or a non-serialisable value (BigInt nested in an
      // object). Degrade to a non-throwing form so the wrap never crashes the
      // render — that is the entire point of "safe" interpolation (SPEC-1).
      return String(v);
    }
  }
  return String(v);
}

function __rozieAttr(v: unknown): string | null {
  return v == null ? null : __rozieDisplay(v);
}

const __rozieTokenRegistry: Map<string, InjectionToken<unknown>> =
  ((globalThis as Record<string, unknown>).__rozieCtx ??= new Map()) as Map<
    string,
    InjectionToken<unknown>
  >;
function rozieToken(key: string): InjectionToken<unknown> {
  let token = __rozieTokenRegistry.get(key);
  if (!token) {
    token = new InjectionToken<unknown>('rozie:' + key);
    __rozieTokenRegistry.set(key, token);
  }
  return token;
}

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


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

    <div class="rdt-column-defs" style="display:none" aria-hidden="true"><ng-container *ngTemplateOutlet="(defaultTpl ?? 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>
    }<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 ?? templates()?.['groupBar'])) {
    <ng-container *ngTemplateOutlet="(groupBarTpl ?? 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" [style]="__style">
    <table class="rozie-data-table" [ngClass]="{ 'rdt-sticky': stickyHeader() }" [attr.role]="rozieAttr(tableRole())" [attr.aria-rowcount]="rows().length" (keydown)="onGridKeyDown($event)" (focusin)="syncActiveFromEvent($event)" (focusout)="onGridFocusOut($event)" (mousedown)="onGridMouseDown($event)">
      <thead class="rdt-thead" role="rowgroup">
        @for (hg of headerGroups(); track hg.id; let hgLevel = $index) {
    <tr class="rdt-tr" role="row">
          @for (header of hg.headers; track header.id) {
    <th class="rdt-th" [ngClass]="{ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }" 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 ?? templates()?.['selectAll'])) {
    <ng-container *ngTemplateOutlet="(selectAllTpl ?? 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 {
    <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 ?? templates()?.['colHeader'])) {
    <ng-container *ngTemplateOutlet="(colHeaderTpl ?? 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 ?? templates()?.['colHeader'])) {
    <ng-container *ngTemplateOutlet="(colHeaderTpl ?? 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>
    }@if (columnIsFilterable(header.column.id)) {
    <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 ?? templates()?.['filter']); context: { $implicit: { columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }, columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }" />
              </span>
    }<span class="rdt-pin-controls" role="group" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id))">
                <button type="button" class="rdt-pin-btn rdt-pin-left" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')" [attr.aria-pressed]="columnPinSide(header.column.id) === 'left'" (click)="onPinColumn(header.column.id, 'left', $event)">⇤</button>
                <button type="button" class="rdt-pin-btn rdt-pin-none" [attr.aria-label]="rozieAttr('Unpin ' + headerLabel(header.column.id))" [attr.aria-pressed]="!columnPinSide(header.column.id)" (click)="onPinColumn(header.column.id, false, $event)">⇔</button>
                <button type="button" class="rdt-pin-btn rdt-pin-right" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')" [attr.aria-pressed]="columnPinSide(header.column.id) === 'right'" (click)="onPinColumn(header.column.id, 'right', $event)">⇥</button>
              </span>
              <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>
    }
      </thead>

      <tbody class="rdt-tbody" role="rowgroup">
        
        <tr class="rdt-spacer" aria-hidden="true">
          <td [attr.colspan]="rozieAttr(visibleColCount())" [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(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-level]="rozieAttr(groupingActive() ? wr.row.depth + 1 : null)">
          @for (cellCtx of visibleCellsFor(wr.row); track cellCtx.id) {
    <td class="rdt-td" [ngClass]="{ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) }" [attr.role]="rozieAttr(cellRole())" [attr.data-col]="rozieAttr(cellCtx.column.id)" data-grid-cell="" [attr.data-row]="rozieAttr(wr.vi.index)" [attr.data-col-index]="rozieAttr(colIndexOf(wr.row, cellCtx))" [attr.tabindex]="rozieAttr(cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx)))" [style]="bodyCellStyle(wr.row, cellCtx.column.id)" [attr.aria-invalid]="rozieAttr(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx)))" [attr.data-in-range]="rozieAttr(inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : null)" [attr.data-agg-cell]="rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)">
            
            @if (isExpanderColumn(cellCtx.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(cellCtx.column.id)) {
    <span style="display:contents">
              @if ((selectCellTpl ?? templates()?.['selectCell'])) {
    <ng-container *ngTemplateOutlet="(selectCellTpl ?? templates()?.['selectCell']); context: _selectCell_ctx(wr, cellCtx)" />
    } @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(cellCtx)) {
    <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 ?? templates()?.['cell'])) {
    <ng-container *ngTemplateOutlet="(cellTpl ?? templates()?.['cell']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }, columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }" />
    } @else {
    {{ rozieDisplay(cellCtx.getValue()) }}
    }
              </span>
              <span class="rdt-group-count">{{ rozieDisplay('(' + groupSubRowCount(wr.row) + ')') }}</span>
            </span>
    } @else if (isEditing(wr.vi.index, colIndexOf(wr.row, cellCtx))) {
    <span style="display:contents">
              @if (hasEditorSlot(cellCtx.column.id)) {
    <span style="display:contents">
                <ng-container *ngTemplateOutlet="(editorTpl ?? templates()?.['editor']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() }, columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() }" />
              </span>
    } @else if (editorTypeOf(cellCtx.column.id) === 'number') {
    <input class="rdt-cell-editor" type="number" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (input)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    } @else if (editorTypeOf(cellCtx.column.id) === 'select') {
    <select class="rdt-cell-editor" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (change)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)">
                @for (opt of editorOptionsOf(cellCtx.column.id); track opt.value) {
    <option [attr.value]="rozieAttr(opt.value)">{{ rozieDisplay(opt.label) }}</option>
    }
              </select>
    } @else if (editorTypeOf(cellCtx.column.id) === 'checkbox') {
    <input class="rdt-cell-editor" type="checkbox" data-editing-cell="" [checked]="editorCheckedFor(cellCtx.column.id)" (change)="onCellEditorCheckbox(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    } @else {
    <input class="rdt-cell-editor" type="text" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (input)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    }</span>
    } @else {
    <span class="rdt-cell-value">
              @if ((cellTpl ?? templates()?.['cell'])) {
    <ng-container *ngTemplateOutlet="(cellTpl ?? templates()?.['cell']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }, columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }" />
    } @else {
    {{ rozieDisplay(cellCtx.getValue()) }}
    }
            </span>
    }@if (isFillHandleCell(wr.vi.index, colIndexOf(wr.row, cellCtx))) {
    <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 ?? 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())" [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(totalRowCount())" (keydown)="onGridKeyDown($event)" (focusin)="syncActiveFromEvent($event)" (focusout)="onGridFocusOut($event)" (mousedown)="onGridMouseDown($event)">
      <thead class="rdt-thead" role="rowgroup">
        @for (hg of headerGroups(); track hg.id; let hgLevel = $index) {
    <tr class="rdt-tr" role="row">
          @for (header of hg.headers; track header.id) {
    <th class="rdt-th" [ngClass]="{ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) }" 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 ?? templates()?.['selectAll'])) {
    <ng-container *ngTemplateOutlet="(selectAllTpl ?? 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 {
    <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 ?? templates()?.['colHeader'])) {
    <ng-container *ngTemplateOutlet="(colHeaderTpl ?? 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 ?? templates()?.['colHeader'])) {
    <ng-container *ngTemplateOutlet="(colHeaderTpl ?? 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>
    }@if (columnIsFilterable(header.column.id)) {
    <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 ?? templates()?.['filter']); context: { $implicit: { columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }, columnId: header.column.id, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter }" />
              </span>
    }<span class="rdt-pin-controls" role="group" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id))">
                <button type="button" class="rdt-pin-btn rdt-pin-left" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')" [attr.aria-pressed]="columnPinSide(header.column.id) === 'left'" (click)="onPinColumn(header.column.id, 'left', $event)">⇤</button>
                <button type="button" class="rdt-pin-btn rdt-pin-none" [attr.aria-label]="rozieAttr('Unpin ' + headerLabel(header.column.id))" [attr.aria-pressed]="!columnPinSide(header.column.id)" (click)="onPinColumn(header.column.id, false, $event)">⇔</button>
                <button type="button" class="rdt-pin-btn rdt-pin-right" [attr.aria-label]="rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')" [attr.aria-pressed]="columnPinSide(header.column.id) === 'right'" (click)="onPinColumn(header.column.id, 'right', $event)">⇥</button>
              </span>
              
              <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>
    }
      </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(isGrid() ? absRowIndexOf(row) + 1 : null)" [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-level]="rozieAttr(groupingActive() ? row.depth + 1 : null)">
          @for (cellCtx of visibleCellsFor(row); track cellCtx.id) {
    <td class="rdt-td" [ngClass]="{ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) }" [attr.role]="rozieAttr(cellRole())" [attr.data-col]="rozieAttr(cellCtx.column.id)" data-grid-cell="" [attr.data-row]="rozieAttr(rowIndexOf(row))" [attr.data-col-index]="rozieAttr(colIndexOf(row, cellCtx))" [attr.tabindex]="rozieAttr(cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx)))" [style]="bodyCellStyle(row, cellCtx.column.id)" [attr.aria-invalid]="rozieAttr(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx)))" [attr.data-in-range]="rozieAttr(inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : null)" [attr.data-agg-cell]="rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)">
            
            @if (isExpanderColumn(cellCtx.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(cellCtx.column.id)) {
    <span style="display:contents">
              @if ((selectCellTpl ?? templates()?.['selectCell'])) {
    <ng-container *ngTemplateOutlet="(selectCellTpl ?? templates()?.['selectCell']); context: _selectCell_ctx_1(row, cellCtx)" />
    } @else {

                <input class="rdt-select-row" type="checkbox" aria-label="Select row" [checked]="rowIsSelected(row)" (change)="onToggleRow(row, $event)" />
              
    }
            </span>
    } @else if (cellIsGrouped(cellCtx)) {
    <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 ?? templates()?.['cell'])) {
    <ng-container *ngTemplateOutlet="(cellTpl ?? templates()?.['cell']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }, columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }" />
    } @else {
    {{ rozieDisplay(cellCtx.getValue()) }}
    }
              </span>
              <span class="rdt-group-count">{{ rozieDisplay('(' + groupSubRowCount(row) + ')') }}</span>
            </span>
    } @else if (isEditing(rowIndexOf(row), colIndexOf(row, cellCtx))) {
    <span style="display:contents">
              @if (hasEditorSlot(cellCtx.column.id)) {
    <span style="display:contents">
                <ng-container *ngTemplateOutlet="(editorTpl ?? templates()?.['editor']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() }, columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() }" />
              </span>
    } @else if (editorTypeOf(cellCtx.column.id) === 'number') {
    <input class="rdt-cell-editor" type="number" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (input)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    } @else if (editorTypeOf(cellCtx.column.id) === 'select') {
    <select class="rdt-cell-editor" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (change)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)">
                @for (opt of editorOptionsOf(cellCtx.column.id); track opt.value) {
    <option [attr.value]="rozieAttr(opt.value)">{{ rozieDisplay(opt.label) }}</option>
    }
              </select>
    } @else if (editorTypeOf(cellCtx.column.id) === 'checkbox') {
    <input class="rdt-cell-editor" type="checkbox" data-editing-cell="" [checked]="editorCheckedFor(cellCtx.column.id)" (change)="onCellEditorCheckbox(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    } @else {
    <input class="rdt-cell-editor" type="text" data-editing-cell="" [value]="editorValueFor(cellCtx.column.id)" (input)="onCellEditorInput(cellCtx.column.id, $event)" (keydown)="onEditorKeyDown($event)" (blur)="onEditorBlur($event)" />
    }</span>
    } @else {
    <span class="rdt-cell-value">
              @if ((cellTpl ?? templates()?.['cell'])) {
    <ng-container *ngTemplateOutlet="(cellTpl ?? templates()?.['cell']); context: { $implicit: { columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }, columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }" />
    } @else {
    {{ rozieDisplay(cellCtx.getValue()) }}
    }
            </span>
    }@if (isFillHandleCell(rowIndexOf(row), colIndexOf(row, cellCtx))) {
    <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 ?? 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 ' + pageCount()) }}
      </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: [`
    .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 {
      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);
      background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
    }
    .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);
      background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
    }
    .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 .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 .rdt-col-filter {
      font: inherit;
      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 .rdt-col-filter {
      display: block;
      margin-top: var(--rdt-col-filter-gap, 0.25rem);
      width: 100%;
      font-weight: normal;
    }
    .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-pin-controls {
      display: inline-flex;
      gap: var(--rdt-pin-gap, 0.1em);
      margin-left: var(--rdt-pin-margin, 0.35em);
    }
    .rozie-data-table .rdt-pin-btn {
      font: inherit;
      font-size: var(--rdt-pin-btn-size, 0.8em);
      line-height: 1;
      cursor: pointer;
      padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
      border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
      border-radius: var(--rdt-pin-btn-radius, 3px);
      background: var(--rdt-pin-btn-bg, transparent);
      color: inherit;
    }
    .rozie-data-table .rdt-pin-btn[aria-pressed='true'] {
      background: var(--rdt-pin-btn-active-bg, rgba(0, 0, 0, 0.1));
      font-weight: 700;
    }
    .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, 1%);
      text-align: var(--rdt-select-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);
  /**
   * 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: unknown[]) => unknown) | 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) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
   * @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
   */
  interactionMode = input<string>('table');
  /**
   * 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);
  editingRowIndex = signal<any>(null);
  rowDraft = signal({});
  rangeAnchor = signal<any>(null);
  rangeFocus = signal<any>(null);
  pasteAnnounce = 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' });
  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);
  private __rozieWatchInitial_0 = 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();
    });
    effect(() => () => {
      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(), 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(), this.colReg()])(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
      this.reFeed();
    })(); }); });
  }

  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,
      // 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);
      // 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);
    }
  }

  table: any = null;
  virtualizer: any = null;
  virtualizerCleanup: any = null;
  gridScrollEl: any = null;
  remeasurePending = false;
  GRID_PAGE_STEP = 10;
  gridRoot: any = null;
  programmatic = 0;
  expandedTouched = false;
  groupingActiveDefault = () => ((this.grouping() != null ? this.grouping() : this.groupingDefault()) || []).length > 0;
  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.columnPinning() != null ? this.columnPinning() : this.columnPinningDefault(),
    // 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 gid = c.id != null ? c.id : c.header;
      if (gid == null) return null;
      const id = String(gid);
      if (!this.isSafeKey(id)) return null;
      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;
      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: ''
      };
      withExpander = [expanderCol].concat(cols);
    }
    if (this.selectionEnabled()) {
      const selectCol = {
        id: this.SELECT_COL_ID,
        enableSorting: false,
        enableColumnFilter: false,
        filterable: false,
        isSelectColumn: true,
        pinned: '',
        width: ''
      };
      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;
    this.programmatic++;
    this.columnPinningDefault.set(next);
    this.columnPinning.set(next);
    this.pinChange.emit(next);
    this.programmatic--;
  };
  writeData = (next: any) => {
    if (this.programmatic) return;
    this.programmatic++;
    this.dataDefault.set(next); // fresh array only (never in-place)
    this.data.set(next); // 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);
  };
  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;
  };
  reFeed = () => {
    if (!this.table) return;
    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 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();
  };
  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.templates()?.['editor']);
  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) 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) => {
    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:1;';
    }
    if (side === 'right') {
      const right = col.getAfter ? col.getAfter('right') : 0;
      return 'position:sticky;right:' + right + 'px;z-index:1;';
    }
    return '';
  };
  thStyle = (colId: any) => {
    let s = '';
    const w = this.headerWidth(colId);
    if (w) s += 'width:' + w + ';';
    s += this.pinStyle(colId);
    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;
  };
  pageCount = () => {
    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());
  rowIsExpanded = (row: any) => !!(this.tick() >= 0 && row && row.getIsExpanded && row.getIsExpanded());
  rowShowsDetail = (row: any) => this.getSubRows() == null && 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. On Solid the expander <td>/<button> is RECREATED on that
    // re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
    // pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
    // breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
    // Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
    // the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
    // reactive flush so the fresh node exists. Harmless on the targets that keep the node
    // (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
    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) {
      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());
  groupSubRowCount = (row: any) => 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));
  };
  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();
  absRowIndexOf = (row: any) => this.rowIndexOf(row) + 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;
  };
  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;
    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;
      const focusWhenReady = () => {
        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;
  };
  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;
  };
  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) => {
    const max = this.visibleColCount() - 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) => {
    const max = this.visibleColCount() - 1;
    const nextCol = toEnd ? max < 0 ? 0 : max : 0;
    this.activeColIndex.set(nextCol);
    return nextCol;
  };
  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 __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. ──────────
    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;
    }
    // ── 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;
    }
    // ── 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) {
      // 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.
      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);
      this.recoverGridFocus(String(grpRow), grpCol, null);
      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) {
      this.activecellChange.emit({
        rowIndex: this.toAbsRow(nextRow),
        colIndex: nextCol
      });
    }
  };
  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;
    const isHeader = rowAttr === '__header';
    this.activeIsHeader.set(isHeader);
    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();
      this.activeHeaderLevel.set(Number.isFinite(lvl) ? lvl : this.headerLeafLevel());
    } else {
      const row = parseInt(rowAttr, 10);
      if (Number.isFinite(row)) this.activeRow.set(row);
    }
    this.activeColIndex.set(col);
    // 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 || !e.shiftKey) 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;
    this.setRangeFocus(row, col);
    this.activeIsHeader.set(false);
    this.activeRow.set(row);
    this.activeColIndex.set(col);
    this.rangeClickPending = true;
  };
  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) => {
    if (!this.gridRoot) return;
    let attempts = 0;
    const tryFocus = () => {
      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);
  };
  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;
    const srcCols = srcRows > 0 ? grid[0].length : 0;
    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);
  };
  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();
    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();
      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);
    }
  };
  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) => {
    if (!this.gridRoot) return;
    let attempts = 0;
    const tryFocus = () => {
      const el = this.gridRoot ? this.gridRoot.querySelector('[data-editing-cell]') : null;
      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;
    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);
    // 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);
  };
  endRowEdit = () => {
    this.editingRowIndex.set(null);
    this.rowDraft.set({});
    this.invalidMsg.set('');
    this.activeInControl.set(false);
    this.editVer.set(this.editVer() + 1);
  };
  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;
    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('');
    const srcIndex = this.sourceIndexOfRow(__editingRow);
    const next = this.replaceRowValue(this.currentData(), srcIndex, field, newValue);
    // 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;
    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;
    // 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
    };
    return true;
  };
  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;
    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;
    // 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);
    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);
        // 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.
        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
      });
    }
    // 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);
    // Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
    // active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
    const focusCol = this.activeColIndex();
    this.editTransition = true;
    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;
    // 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
    };
    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;
  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;
    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): blur NEVER commits — the row commits as a UNIT only on an
    // explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
    // into N writes + N events, violating the one-write/one-event contract). Tabbing between
    // the row's own editors is a normal focus move, not a commit.
    if (this.inRowEdit()) 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);
    this.activeIsHeader.set(false);
    this.activeRow.set(r);
    this.activeColIndex.set(c);
    this.beginEdit(r, c, null);
  };
  commitEditing = () => {
    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(absRow + 1);
    const tryFocus = () => {
      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;
    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 absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
        // same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
        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, cellCtx: 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, cellCtx: 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 { For, Show, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, parseInlineStyle, rozieAttr, rozieClass, rozieContext, rozieDisplay } from '@rozie/runtime-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).
// 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[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);
  background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.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);
  background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.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[data-rozie-s-d5dcab4c] {
  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[data-rozie-s-d5dcab4c] {
  font: inherit;
  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[data-rozie-s-d5dcab4c] {
  display: block;
  margin-top: var(--rdt-col-filter-gap, 0.25rem);
  width: 100%;
  font-weight: normal;
}
.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-pin-controls[data-rozie-s-d5dcab4c] {
  display: inline-flex;
  gap: var(--rdt-pin-gap, 0.1em);
  margin-left: var(--rdt-pin-margin, 0.35em);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[data-rozie-s-d5dcab4c] {
  font: inherit;
  font-size: var(--rdt-pin-btn-size, 0.8em);
  line-height: 1;
  cursor: pointer;
  padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
  border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-pin-btn-radius, 3px);
  background: var(--rdt-pin-btn-bg, transparent);
  color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[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-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, 1%);
  text-align: var(--rdt-select-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; 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; }

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;
  /**
   * 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: unknown[]) => unknown) | 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) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
   * @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
   */
  interactionMode?: string;
  /**
   * 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;
  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;
}

export default function DataTable(_props: DataTableProps): JSX.Element {
  const _merged = mergeProps({ columns: (() => [])(), selectionMode: 'none', manual: false, expandable: false, getSubRows: null, groupable: false, stickyHeader: false, interactionMode: 'table', virtual: false, estimateRowHeight: 40, maxHeight: '' }, _props);
  const [local, attrs] = splitProps(_merged, ['data', 'columns', 'selectionMode', 'sorting', 'globalFilter', 'columnFilters', 'pagination', 'manual', 'expandable', 'expanded', 'getSubRows', 'groupable', 'grouping', 'rowSelection', 'columnVisibility', 'columnSizing', 'columnOrder', 'columnPinning', 'stickyHeader', 'interactionMode', 'virtual', 'estimateRowHeight', 'maxHeight', 'children', 'ref']);
  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 }); });

  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>(_props as unknown as Record<string, unknown>, 'expanded', null);
  const [grouping, setGrouping] = createControllableSignal<any[]>(_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 [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('');
  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,
      // 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);
      // 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);
    }
  });
  onCleanup(() => {
    if (virtualizerCleanup) virtualizerCleanup();
    // CR-04: remove any live fill-drag document listeners if we unmount mid-drag.
    teardownFillDrag();
  });
  createEffect(() => {
    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(), 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(), colReg()])(), (v) => untrack(() => (() => {
    reFeed();
  })()), { 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;

  // 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;
  }

  // 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: columnPinning() != null ? columnPinning() : columnPinningDefault(),
      // 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 gid = c.id != null ? c.id : c.header;
      if (gid == null) return null;
      const id = String(gid);
      if (!isSafeKey(id)) return null;
      const kids = [];
      for (const child of c.columns as any) {
        const cd = buildConfigDef(child);
        if (cd) kids.push(cd);
      }
      if (!kids.length) 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: ''
      };
      withExpander = [expanderCol].concat(cols);
    }
    if (selectionEnabled()) {
      const selectCol = {
        id: SELECT_COL_ID,
        enableSorting: false,
        enableColumnFilter: false,
        filterable: false,
        isSelectColumn: true,
        pinned: '',
        width: ''
      };
      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;
    programmatic++;
    setColumnPinningDefault(next);
    setColumnPinning(next);
    _props.onPinChange?.(next);
    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.
  function writeData(next: any) {
    if (programmatic) return;
    programmatic++;
    setDataDefault(next); // fresh array only (never in-place)
    setData(next); // 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);
  }

  // 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;
  }
  // 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;
    table.setOptions((prev: any) => ({
      ...prev,
      data: currentData(),
      columns: tableColumns(),
      state: currentState(),
      enableRowSelection: local.selectionMode !== 'none',
      enableMultiRowSelection: local.selectionMode === 'multiple',
      // 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.
  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. Solid's reference-keyed <For> keeps the existing <tr> and 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"]);
  }
  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 select column (always present).
  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) 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).
  function pinStyle(colId: any) {
    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:1;';
    }
    if (side === 'right') {
      const right = col.getAfter ? col.getAfter('right') : 0;
      return 'position:sticky;right:' + right + 'px;z-index:1;';
    }
    return '';
  }
  // Combined inline style for a <th> (width + pin) and a <td> (pin). Plain string concat —
  // uniform on all 6, no bound-object trap.
  function thStyle(colId: any) {
    let s = '';
    const w = headerWidth(colId);
    if (w) s += 'width:' + w + ';';
    s += pinStyle(colId);
    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;
  }
  function pageCount() {
    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;
  }
  function rowCanExpand(row: any) {
    return !!(tick() >= 0 && row && row.getCanExpand && row.getCanExpand());
  }
  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. 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).
  function rowShowsDetail(row: any) {
    return local.getSubRows == null && 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. On Solid the expander <td>/<button> is RECREATED on that
    // re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
    // pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
    // breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
    // Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
    // the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
    // reactive flush so the fresh node exists. Harmless on the targets that keep the node
    // (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
    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) {
      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());
  }
  // groupSubRowCount: the number of immediate members under a group-header row (the count shown in
  // the header, e.g. "North (3)").
  function groupSubRowCount(row: any) {
    return 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));
  }
  // `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();
  }

  // ══ 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().
  function absRowIndexOf(row: any) {
    return rowIndexOf(row) + pageRowOffset();
  }
  // 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;
  }

  // ── 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;
    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;
      const focusWhenReady = () => {
        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;
  }

  // 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;
  }
  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) {
    const max = visibleColCount() - 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) {
    const max = visibleColCount() - 1;
    const nextCol = toEnd ? max < 0 ? 0 : max : 0;
    setActiveColIndex(nextCol);
    return nextCol;
  }

  // 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. ──────────
    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;
    }
    // ── 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;
    }
    // ── 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) {
      // 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.
      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);
      recoverGridFocus(String(grpRow), grpCol, null);
      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) {
      _props.onActivecellChange?.({
        rowIndex: toAbsRow(nextRow),
        colIndex: nextCol
      });
    }
  }

  // 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;
    const isHeader = rowAttr === '__header';
    setActiveIsHeader(isHeader);
    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();
      setActiveHeaderLevel(Number.isFinite(lvl) ? lvl : headerLeafLevel());
    } else {
      const row = parseInt(rowAttr, 10);
      if (Number.isFinite(row)) setActiveRow(row);
    }
    setActiveColIndex(col);
    // 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 Shift+Click range-extend seam (phase 51 req-7 / D-07). 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),
  // riding the same data-row/data-col-index parse seam, then flags rangeClickPending so the
  // follow-up focusin does not collapse the range. A plain (non-shift) mousedown is ignored
  // here (the focusin owns the active-cell sync + the range collapse).
  function onGridMouseDown(e: any) {
    if (!isGrid() || !e || !e.shiftKey) 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;
    setRangeFocus$local(row, col);
    setActiveIsHeader(false);
    setActiveRow(row);
    setActiveColIndex(col);
    rangeClickPending = true;
  }

  // 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.
  function recoverGridFocus(rowKey: any, col: any, level: any) {
    if (!gridRoot) return;
    let attempts = 0;
    const tryFocus = () => {
      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);
  }

  // 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;
    const srcCols = srcRows > 0 ? grid[0].length : 0;
    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);
  }

  // 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();
    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();
      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);
    }
  }

  // ══ 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.
  function focusEditorWhenReady(selectAll = true) {
    if (!gridRoot) return;
    let attempts = 0;
    const tryFocus = () => {
      const el = gridRoot ? gridRoot.querySelector('[data-editing-cell]') : null;
      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;
    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);
    // 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);
  }

  // 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);
  }

  // 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;
    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('');
    const srcIndex = sourceIndexOfRow(editingRow());
    const next = replaceRowValue(currentData(), srcIndex, field, newValue);
    // 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;
    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;
    // 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
    };
    return true;
  }

  // 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).
  function focusRowEditorAt(rowIndex: any, colIndex: any) {
    if (!gridRoot) 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;
    // 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);
    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);
        // 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.
        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
      });
    }
    // 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);
    // Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
    // active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
    const focusCol = activeColIndex();
    editTransition = true;
    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;
    // 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
    };
    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;

  // ── 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;
    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): blur NEVER commits — the row commits as a UNIT only on an
    // explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
    // into N writes + N events, violating the one-write/one-event contract). Tabbing between
    // the row's own editors is a normal focus move, not a commit.
    if (inRowEdit()) 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);
    setActiveIsHeader(false);
    setActiveRow(r);
    setActiveColIndex(c);
    beginEdit(r, c, null);
  }

  // commitEditing() — programmatic commit of the open editor ($expose, req-3). No-op when
  // no cell is editing. Collision-clean (not `commit`).
  function commitEditing() {
    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 aria-rowindex === absRow+1 (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.
  function focusAbsCellWhenReady(absRow: any, localRow: any, col: any) {
    if (!gridRoot) return;
    let attempts = 0;
    const want = String(absRow + 1);
    const tryFocus = () => {
      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;
    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 absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
        // same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
        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>}<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) => { 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="">
          <For each={allLeafColumns()}>{(lc) => <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>}</For>
        </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 }) ?? <For each={groupingKeys()}>{(gk) => <span class={"rdt-group-token"} data-group-token="" data-rozie-s-d5dcab4c="">{rozieDisplay(gk)}</span>}</For>}
    </div></Show>}{<Show when={local.virtual} fallback={<table aria-rowcount={rozieAttr(totalRowCount())} class={"rozie-data-table" + " " + rozieClass({ 'rdt-sticky': local.stickyHeader })} role={rozieAttr(tableRole())} onKeyDown={($event) => { onGridKeyDown($event); }} onFocusIn={($event) => { syncActiveFromEvent($event); }} onFocusOut={($event) => { onGridFocusOut($event); }} onMouseDown={($event) => { onGridMouseDown($event); }} data-rozie-s-d5dcab4c="">
      <thead class={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
        <For each={headerGroups()}>{(hg, hgLevel) => <tr class={"rdt-tr"} role="row" data-rozie-s-d5dcab4c="">
          <For each={hg.headers}>{(header) => <th class={"rdt-th" + " " + rozieClass({ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) })} 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={<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) => { 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>}{<Show when={columnIsFilterable(header.column.id)}><input type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} class={"rdt-col-filter"} value={columnFilterValue(header.column.id)} onInput={($event) => { onColumnFilterInput(header.column.id, $event); }} onClick={($event) => { 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, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}
              </span></Show>}<span class={"rdt-pin-controls"} role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c="">
                <button type="button" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} class={"rdt-pin-btn rdt-pin-left"} onClick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">⇤</button>
                <button type="button" aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} class={"rdt-pin-btn rdt-pin-none"} onClick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c="">⇔</button>
                <button type="button" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} class={"rdt-pin-btn rdt-pin-right"} onClick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">⇥</button>
              </span>
              
              <button type="button" aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} class={"rdt-resize-handle"} 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="" /></button>
            </span>}><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) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c="" /></Show>}
            </span></Show>}</th>}</For>
        </tr>}</For>
      </thead>

      <tbody class={"rdt-tbody"} role="rowgroup" data-rozie-s-d5dcab4c="">
        
        <For each={rows()}>{(row) => <>
        <tr class={"rdt-tr" + " " + rozieClass({ 'rdt-group-header': rowIsGrouped(row) })} role="row" data-depth={rozieAttr(row.depth)} aria-rowindex={rozieAttr(isGrid() ? absRowIndexOf(row) + 1 : null)} 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-level={rozieAttr(groupingActive() ? row.depth + 1 : null)} data-rozie-s-d5dcab4c="">
          <For each={visibleCellsFor(row)}>{(cellCtx) => <td class={"rdt-td" + " " + rozieClass({ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(rowIndexOf(row))} data-col-index={rozieAttr(colIndexOf(row, cellCtx))} tabIndex={rozieAttr(cellTabindex(String(rowIndexOf(row)), colIndexOf(row, cellCtx)))} style={parseInlineStyle(bodyCellStyle(row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(rowIndexOf(row), colIndexOf(row, cellCtx)))} data-in-range={rozieAttr(inRange(rowIndexOf(row), colIndexOf(row, cellCtx)) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c="">
            
            {<Show when={isExpanderColumn(cellCtx.column.id)} fallback={<Show when={isSelectColumn(cellCtx.column.id)} fallback={<Show when={cellIsGrouped(cellCtx)} fallback={<Show when={isEditing(rowIndexOf(row), colIndexOf(row, cellCtx))} fallback={<span class={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
              {(_props.cellSlot ?? _props.slots?.['cell'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }) ?? rozieDisplay(cellCtx.getValue())}
            </span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {<Show when={hasEditorSlot(cellCtx.column.id)} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'number'} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'select'} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'checkbox'} fallback={<input type="text" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}><input type="checkbox" data-editing-cell="" class={"rdt-cell-editor"} checked={editorCheckedFor(cellCtx.column.id)} onChange={($event) => { onCellEditorCheckbox(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><select data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onChange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
                <For each={editorOptionsOf(cellCtx.column.id)}>{(opt) => <option value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt.label)}</option>}</For>
              </select></Show>}><input type="number" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(_props.editorSlot ?? _props.slots?.['editor'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}
              </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) => { 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: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue() }) ?? rozieDisplay(cellCtx.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) => { 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) => { onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c="">{rozieDisplay(rowIsExpanded(row) ? '▾' : '▸')}</button></Show>}</span></Show>}{<Show when={isFillHandleCell(rowIndexOf(row), colIndexOf(row, cellCtx))}><span data-fill-handle="" data-testid="fill-handle" aria-hidden="true" class={"rdt-fill-handle"} onPointerDown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c="" /></Show>}</td>}</For>
        </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>}</>}</For>
      </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={rows().length} class={"rozie-data-table" + " " + rozieClass({ 'rdt-sticky': local.stickyHeader })} role={rozieAttr(tableRole())} onKeyDown={($event) => { onGridKeyDown($event); }} onFocusIn={($event) => { syncActiveFromEvent($event); }} onFocusOut={($event) => { onGridFocusOut($event); }} onMouseDown={($event) => { onGridMouseDown($event); }} data-rozie-s-d5dcab4c="">
      <thead class={"rdt-thead"} role="rowgroup" data-rozie-s-d5dcab4c="">
        <For each={headerGroups()}>{(hg, hgLevel) => <tr class={"rdt-tr"} role="row" data-rozie-s-d5dcab4c="">
          <For each={hg.headers}>{(header) => <th class={"rdt-th" + " " + rozieClass({ 'rdt-select-th': isSelectColumn(header.column.id), 'rdt-th-resizing': columnIsResizing(header.column.id) })} 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={<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) => { 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>}{<Show when={columnIsFilterable(header.column.id)}><input type="text" aria-label={rozieAttr('Filter ' + headerLabel(header.column.id))} class={"rdt-col-filter"} value={columnFilterValue(header.column.id)} onInput={($event) => { onColumnFilterInput(header.column.id, $event); }} onClick={($event) => { 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, uniqueValues: getFacetedUniqueValues(header.column.id), minMax: getFacetedMinMaxValues(header.column.id), setFilter: setColumnFilter })}
              </span></Show>}<span class={"rdt-pin-controls"} role="group" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id))} data-rozie-s-d5dcab4c="">
                <button type="button" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to left')} aria-pressed={columnPinSide(header.column.id) === 'left'} class={"rdt-pin-btn rdt-pin-left"} onClick={($event) => { onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c="">⇤</button>
                <button type="button" aria-label={rozieAttr('Unpin ' + headerLabel(header.column.id))} aria-pressed={!columnPinSide(header.column.id)} class={"rdt-pin-btn rdt-pin-none"} onClick={($event) => { onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c="">⇔</button>
                <button type="button" aria-label={rozieAttr('Pin ' + headerLabel(header.column.id) + ' to right')} aria-pressed={columnPinSide(header.column.id) === 'right'} class={"rdt-pin-btn rdt-pin-right"} onClick={($event) => { onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c="">⇥</button>
              </span>
              <button type="button" aria-label={rozieAttr('Resize ' + headerLabel(header.column.id))} class={"rdt-resize-handle"} 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="" /></button>
            </span>}><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) => { onToggleAllRows($event); }} data-rozie-s-d5dcab4c="" /></Show>}
            </span></Show>}</th>}</For>
        </tr>}</For>
      </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>
        
        <For each={windowedRows()}>{(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(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-level={rozieAttr(groupingActive() ? wr.row.depth + 1 : null)} data-rozie-s-d5dcab4c="">
          <For each={visibleCellsFor(wr.row)}>{(cellCtx) => <td class={"rdt-td" + " " + rozieClass({ 'rdt-select-td': isSelectColumn(cellCtx.column.id), 'rdt-in-range': inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) })} role={rozieAttr(cellRole())} data-col={rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row={rozieAttr(wr.vi.index)} data-col-index={rozieAttr(colIndexOf(wr.row, cellCtx))} tabIndex={rozieAttr(cellTabindex(String(wr.vi.index), colIndexOf(wr.row, cellCtx)))} style={parseInlineStyle(bodyCellStyle(wr.row, cellCtx.column.id))} aria-invalid={rozieAttr(cellAriaInvalid(wr.vi.index, colIndexOf(wr.row, cellCtx)))} data-in-range={rozieAttr(inRange(wr.vi.index, colIndexOf(wr.row, cellCtx)) ? 'true' : null)} data-agg-cell={rozieAttr(cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c="">
            
            {<Show when={isExpanderColumn(cellCtx.column.id)} fallback={<Show when={isSelectColumn(cellCtx.column.id)} fallback={<Show when={cellIsGrouped(cellCtx)} fallback={<Show when={isEditing(wr.vi.index, colIndexOf(wr.row, cellCtx))} fallback={<span class={"rdt-cell-value"} data-rozie-s-d5dcab4c="">
              {(_props.cellSlot ?? _props.slots?.['cell'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }) ?? rozieDisplay(cellCtx.getValue())}
            </span>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
              {<Show when={hasEditorSlot(cellCtx.column.id)} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'number'} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'select'} fallback={<Show when={editorTypeOf(cellCtx.column.id) === 'checkbox'} fallback={<input type="text" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" />}><input type="checkbox" data-editing-cell="" class={"rdt-cell-editor"} checked={editorCheckedFor(cellCtx.column.id)} onChange={($event) => { onCellEditorCheckbox(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><select data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onChange={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="">
                <For each={editorOptionsOf(cellCtx.column.id)}>{(opt) => <option value={rozieAttr(opt.value)} data-rozie-s-d5dcab4c="">{rozieDisplay(opt.label)}</option>}</For>
              </select></Show>}><input type="number" data-editing-cell="" class={"rdt-cell-editor"} value={editorValueFor(cellCtx.column.id)} onInput={($event) => { onCellEditorInput(cellCtx.column.id, $event); }} onKeyDown={($event) => { onEditorKeyDown($event); }} onBlur={($event) => { onEditorBlur($event); }} data-rozie-s-d5dcab4c="" /></Show>}><span style={{ display: "contents" }} data-rozie-s-d5dcab4c="">
                {(_props.editorSlot ?? _props.slots?.['editor'])?.({ columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: editorValueFor(cellCtx.column.id), commit: editorCommitFor(cellCtx.column.id), cancel: editorCancelFor() })}
              </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) => { 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: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue() }) ?? rozieDisplay(cellCtx.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) => { 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) => { 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, cellCtx))}><span data-fill-handle="" data-testid="fill-handle" aria-hidden="true" class={"rdt-fill-handle"} onPointerDown={($event) => { onFillHandlePointerDown($event); }} data-rozie-s-d5dcab4c="" /></Show>}</td>}</For>
        </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>}</>}</For>
        
        <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) => { onPrevPage(); }} data-rozie-s-d5dcab4c="">Prev</button>
      <span class={"rdt-page-status"} aria-live="polite" data-rozie-s-d5dcab4c="">
        {rozieDisplay('Page ' + (pageIndex() + 1) + ' of ' + pageCount())}
      </span>
      <button type="button" class={"rdt-page-btn rdt-page-next"} disabled={!canNextPage()} onClick={($event) => { onNextPage(); }} data-rozie-s-d5dcab4c="">Next</button>
      <select aria-label="Rows per page" class={"rdt-page-size"} 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></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 { createLitControllableProperty, rozieAttr, rozieDisplay, rozieStyle } from '@rozie/runtime-lit';
import { ContextProvider, createContext } from '@lit/context';
import { repeat } from 'lit/directives/repeat.js';
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).
// 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: unknown;
  groupableColumns: unknown;
  applyGrouping: unknown;
  clearGrouping: unknown;
}

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

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

interface RozieFilterSlotCtx {
  columnId: unknown;
  uniqueValues: unknown;
  minMax: unknown;
  setFilter: unknown;
}

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

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

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

interface RozieDetailSlotCtx {
  row: unknown;
}

@customElement('rozie-data-table')
export default class DataTable extends SignalWatcher(LitElement) {
  static styles = css`
.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[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);
  background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.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);
  background: var(--rdt-header-bg, rgba(0, 0, 0, 0.03));
}
.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[data-rozie-s-d5dcab4c] {
  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[data-rozie-s-d5dcab4c] {
  font: inherit;
  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[data-rozie-s-d5dcab4c] {
  display: block;
  margin-top: var(--rdt-col-filter-gap, 0.25rem);
  width: 100%;
  font-weight: normal;
}
.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-pin-controls[data-rozie-s-d5dcab4c] {
  display: inline-flex;
  gap: var(--rdt-pin-gap, 0.1em);
  margin-left: var(--rdt-pin-margin, 0.35em);
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[data-rozie-s-d5dcab4c] {
  font: inherit;
  font-size: var(--rdt-pin-btn-size, 0.8em);
  line-height: 1;
  cursor: pointer;
  padding: var(--rdt-pin-btn-padding, 0.1em 0.25em);
  border: var(--rdt-pin-btn-border, 1px solid rgba(0, 0, 0, 0.15));
  border-radius: var(--rdt-pin-btn-radius, 3px);
  background: var(--rdt-pin-btn-bg, transparent);
  color: inherit;
}
.rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pin-btn[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-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, 1%);
  text-align: var(--rdt-select-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;
  /**
   * 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: unknown[]) => unknown) | 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) | `'grid'`. `'grid'` lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)** — `role="grid"`, a roving single tab-stop, and 2-D APG arrow-key cell navigation. `'table'` is byte-behaviorally identical to a plain accessible table.
   * @deprecated Reserved forward-compat seam — grid cell-navigation is not implemented yet; do not rely on the `grid` mode.
   */
  @property({ type: String, reflect: true }) interactionMode: string = 'table';
  /**
   * 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 _editingRowIndex = signal<any>(null);
  private _rowDraft = signal<any>({});
  private _rangeAnchor = signal<any>(null);
  private _rangeFocus = signal<any>(null);
  private _pasteAnnounce = signal('');
  @query('[data-rozie-ref="__rozieRoot"]') private _ref__rozieRoot!: HTMLElement;
private __rozieWatchInitial_0 = 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) });

  @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: unknown; groupableColumns: unknown; applyGrouping: unknown; clearGrouping: unknown }) => unknown;
  @state() private _hasSlotSelectAll = false;
  @queryAssignedElements({ slot: 'selectAll', flatten: true }) private _slotSelectAllElements!: Element[];
  @property({ attribute: false }) selectAll?: (scope: { checked: unknown; indeterminate: unknown; toggle: unknown }) => unknown;
  @state() private _hasSlotColHeader = false;
  @queryAssignedElements({ slot: 'colHeader', flatten: true }) private _slotColHeaderElements!: Element[];
  @property({ attribute: false }) colHeader?: (scope: { columnId: unknown; column: unknown; label: unknown }) => unknown;
  @state() private _hasSlotFilter = false;
  @queryAssignedElements({ slot: 'filter', flatten: true }) private _slotFilterElements!: Element[];
  @property({ attribute: false }) filter?: (scope: { columnId: unknown; uniqueValues: unknown; minMax: unknown; setFilter: unknown }) => unknown;
  @state() private _hasSlotSelectCell = false;
  @queryAssignedElements({ slot: 'selectCell', flatten: true }) private _slotSelectCellElements!: Element[];
  @property({ attribute: false }) selectCell?: (scope: { row: unknown; checked: unknown; toggle: unknown }) => unknown;
  @state() private _hasSlotCell = false;
  @queryAssignedElements({ slot: 'cell', flatten: true }) private _slotCellElements!: Element[];
  @property({ attribute: false }) cell?: (scope: { columnId: unknown; column: unknown; row: unknown; value: unknown }) => unknown;
  @state() private _hasSlotEditor = false;
  @queryAssignedElements({ slot: 'editor', flatten: true }) private _slotEditorElements!: Element[];
  @property({ attribute: false }) editor?: (scope: { columnId: unknown; column: unknown; row: unknown; value: unknown; commit: unknown; cancel: unknown }) => unknown;
  @state() private _hasSlotDetail = false;
  @queryAssignedElements({ slot: 'detail', flatten: true }) private _slotDetailElements!: Element[];
  @property({ attribute: false }) detail?: (scope: { row: unknown }) => 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, 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, this._colReg.value])(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
      this.reFeed();
    })(); }); }));

    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,
      // 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);
      // 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);
    }
  }

  updated(changedProperties: Map<string, unknown>): void {
    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();
      };
      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}<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: Event) => { 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" key=${rozieAttr(lc.id)} data-rozie-s-d5dcab4c>
        <input class="rdt-colvis-checkbox" type="checkbox" ?checked=${lc.visible} @change=${($event: Event) => { 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="" key=${rozieAttr(gk)} 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=${this._rows.value.length} @keydown=${($event: Event) => { this.onGridKeyDown($event); }} @focusin=${($event: Event) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event) => { this.onGridFocusOut($event); }} @mousedown=${($event: Event) => { this.onGridMouseDown($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" key=${rozieAttr(hg.id)} 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-th-resizing': this.columnIsResizing(header.column.id) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="columnheader" key=${rozieAttr(header.id)} 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) => { this.onToggleAllRows($event); }} data-rozie-s-d5dcab4c />` : nothing}</slot>`}
        </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: Event) => { 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>`}${this.columnIsFilterable(header.column.id) ? 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: Event) => { this.onColumnFilterInput(header.column.id, $event); }} @click=${($event: Event) => { 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, 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, 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 class="rdt-pin-controls" role="group" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id))} data-rozie-s-d5dcab4c>
            <button class="rdt-pin-btn rdt-pin-left" type="button" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id) + ' to left')} aria-pressed=${this.columnPinSide(header.column.id) === 'left'} @click=${($event: Event) => { this.onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>⇤</button>
            <button class="rdt-pin-btn rdt-pin-none" type="button" aria-label=${rozieAttr('Unpin ' + this.headerLabel(header.column.id))} aria-pressed=${!this.columnPinSide(header.column.id)} @click=${($event: Event) => { this.onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>⇔</button>
            <button class="rdt-pin-btn rdt-pin-right" type="button" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id) + ' to right')} aria-pressed=${this.columnPinSide(header.column.id) === 'right'} @click=${($event: Event) => { this.onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>⇥</button>
          </span>
          <button class="rdt-resize-handle" type="button" aria-label=${rozieAttr('Resize ' + this.headerLabel(header.column.id))} @pointerdown=${($event: Event) => { this.onResizeStart(header.column.id, $event); }} @touchstart=${($event: Event) => { 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>`)}
  </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(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-level=${rozieAttr(this.groupingActive() ? wr.row.depth + 1 : null)} data-rozie-s-d5dcab4c>
      ${repeat<any>(this.visibleCellsFor(wr.row), (cellCtx, _idx) => cellCtx.id, (cellCtx, _idx) => html`<td class="${Object.entries({ "rdt-td": true, 'rdt-select-td': this.isSelectColumn(cellCtx.column.id), 'rdt-in-range': this.inRange(wr.vi.index, this.colIndexOf(wr.row, cellCtx)) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.cellRole())} key=${rozieAttr(cellCtx.id)} data-col=${rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row=${rozieAttr(wr.vi.index)} data-col-index=${rozieAttr(this.colIndexOf(wr.row, cellCtx))} tabindex=${rozieAttr(this.cellTabindex(String(wr.vi.index), this.colIndexOf(wr.row, cellCtx)))} style=${rozieStyle(this.bodyCellStyle(wr.row, cellCtx.column.id))} aria-invalid=${rozieAttr(this.cellAriaInvalid(wr.vi.index, this.colIndexOf(wr.row, cellCtx)))} data-in-range=${rozieAttr(this.inRange(wr.vi.index, this.colIndexOf(wr.row, cellCtx)) ? 'true' : null)} data-agg-cell=${rozieAttr(this.cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c>
        
        ${this.isExpanderColumn(cellCtx.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: Event) => { this.onToggleExpand(wr.row, $event); }} data-rozie-s-d5dcab4c>${rozieDisplay(this.rowIsExpanded(wr.row) ? '▾' : '▸')}</button>` : nothing}</span>` : this.isSelectColumn(cellCtx.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) => { this.onToggleRow(wr.row, $event); }} data-rozie-s-d5dcab4c />
          </slot>`}
        </span>` : this.cellIsGrouped(cellCtx) ? 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: Event) => { 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: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cellCtx.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, cellCtx)) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.hasEditorSlot(cellCtx.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
            ${this.editor !== undefined ? this.editor({columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: this.editorValueFor(cellCtx.column.id), commit: this.editorCommitFor(cellCtx.column.id), cancel: this.editorCancelFor()}) : html`<slot name="editor" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: this.editorValueFor(cellCtx.column.id), commit: this.editorCommitFor(cellCtx.column.id), cancel: this.editorCancelFor()}); } catch { return '{}'; } })()}></slot>`}
          </span>` : this.editorTypeOf(cellCtx.column.id) === 'number' ? html`<input class="rdt-cell-editor" type="number" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @input=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : this.editorTypeOf(cellCtx.column.id) === 'select' ? html`<select class="rdt-cell-editor" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @change=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c>
            ${repeat<any>(this.editorOptionsOf(cellCtx.column.id), (opt, _idx) => opt.value, (opt, _idx) => html`<option key=${rozieAttr(opt.value)} value=${rozieAttr(opt.value)} data-rozie-s-d5dcab4c>${rozieDisplay(opt.label)}</option>`)}
          </select>` : this.editorTypeOf(cellCtx.column.id) === 'checkbox' ? html`<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" ?checked=${this.editorCheckedFor(cellCtx.column.id)} @change=${($event: Event) => { this.onCellEditorCheckbox(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : html`<input class="rdt-cell-editor" type="text" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @input=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />`}</span>` : html`<span class="rdt-cell-value" data-rozie-s-d5dcab4c>
          ${this.cell !== undefined ? this.cell({columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: wr.row.original, value: cellCtx.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cellCtx.getValue())}</slot>`}
        </span>`}${this.isFillHandleCell(wr.vi.index, this.colIndexOf(wr.row, cellCtx)) ? html`<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" @pointerdown=${($event: Event) => { 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.totalRowCount())} @keydown=${($event: Event) => { this.onGridKeyDown($event); }} @focusin=${($event: Event) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event) => { this.onGridFocusOut($event); }} @mousedown=${($event: Event) => { this.onGridMouseDown($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" key=${rozieAttr(hg.id)} 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-th-resizing': this.columnIsResizing(header.column.id) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="columnheader" key=${rozieAttr(header.id)} 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) => { this.onToggleAllRows($event); }} data-rozie-s-d5dcab4c />` : nothing}</slot>`}
        </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: Event) => { 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>`}${this.columnIsFilterable(header.column.id) ? 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: Event) => { this.onColumnFilterInput(header.column.id, $event); }} @click=${($event: Event) => { 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, 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, 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 class="rdt-pin-controls" role="group" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id))} data-rozie-s-d5dcab4c>
            <button class="rdt-pin-btn rdt-pin-left" type="button" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id) + ' to left')} aria-pressed=${this.columnPinSide(header.column.id) === 'left'} @click=${($event: Event) => { this.onPinColumn(header.column.id, 'left', $event); }} data-rozie-s-d5dcab4c>⇤</button>
            <button class="rdt-pin-btn rdt-pin-none" type="button" aria-label=${rozieAttr('Unpin ' + this.headerLabel(header.column.id))} aria-pressed=${!this.columnPinSide(header.column.id)} @click=${($event: Event) => { this.onPinColumn(header.column.id, false, $event); }} data-rozie-s-d5dcab4c>⇔</button>
            <button class="rdt-pin-btn rdt-pin-right" type="button" aria-label=${rozieAttr('Pin ' + this.headerLabel(header.column.id) + ' to right')} aria-pressed=${this.columnPinSide(header.column.id) === 'right'} @click=${($event: Event) => { this.onPinColumn(header.column.id, 'right', $event); }} data-rozie-s-d5dcab4c>⇥</button>
          </span>
          
          <button class="rdt-resize-handle" type="button" aria-label=${rozieAttr('Resize ' + this.headerLabel(header.column.id))} @pointerdown=${($event: Event) => { this.onResizeStart(header.column.id, $event); }} @touchstart=${($event: Event) => { 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>`)}
  </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.isGrid() ? this.absRowIndexOf(row) + 1 : null)} 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-level=${rozieAttr(this.groupingActive() ? row.depth + 1 : null)} data-rozie-s-d5dcab4c>
      ${repeat<any>(this.visibleCellsFor(row), (cellCtx, _idx) => cellCtx.id, (cellCtx, _idx) => html`<td class="${Object.entries({ "rdt-td": true, 'rdt-select-td': this.isSelectColumn(cellCtx.column.id), 'rdt-in-range': this.inRange(this.rowIndexOf(row), this.colIndexOf(row, cellCtx)) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.cellRole())} key=${rozieAttr(cellCtx.id)} data-col=${rozieAttr(cellCtx.column.id)} data-grid-cell="" data-row=${rozieAttr(this.rowIndexOf(row))} data-col-index=${rozieAttr(this.colIndexOf(row, cellCtx))} tabindex=${rozieAttr(this.cellTabindex(String(this.rowIndexOf(row)), this.colIndexOf(row, cellCtx)))} style=${rozieStyle(this.bodyCellStyle(row, cellCtx.column.id))} aria-invalid=${rozieAttr(this.cellAriaInvalid(this.rowIndexOf(row), this.colIndexOf(row, cellCtx)))} data-in-range=${rozieAttr(this.inRange(this.rowIndexOf(row), this.colIndexOf(row, cellCtx)) ? 'true' : null)} data-agg-cell=${rozieAttr(this.cellIsAggregated(cellCtx) ? cellCtx.column.id : null)} data-rozie-s-d5dcab4c>
        
        ${this.isExpanderColumn(cellCtx.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: Event) => { this.onToggleExpand(row, $event); }} data-rozie-s-d5dcab4c>${rozieDisplay(this.rowIsExpanded(row) ? '▾' : '▸')}</button>` : nothing}</span>` : this.isSelectColumn(cellCtx.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) => { this.onToggleRow(row, $event); }} data-rozie-s-d5dcab4c />
          </slot>`}
        </span>` : this.cellIsGrouped(cellCtx) ? 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: Event) => { 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: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cellCtx.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, cellCtx)) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
          ${this.hasEditorSlot(cellCtx.column.id) ? html`<span style="display:contents" data-rozie-s-d5dcab4c>
            ${this.editor !== undefined ? this.editor({columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: this.editorValueFor(cellCtx.column.id), commit: this.editorCommitFor(cellCtx.column.id), cancel: this.editorCancelFor()}) : html`<slot name="editor" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: this.editorValueFor(cellCtx.column.id), commit: this.editorCommitFor(cellCtx.column.id), cancel: this.editorCancelFor()}); } catch { return '{}'; } })()}></slot>`}
          </span>` : this.editorTypeOf(cellCtx.column.id) === 'number' ? html`<input class="rdt-cell-editor" type="number" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @input=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : this.editorTypeOf(cellCtx.column.id) === 'select' ? html`<select class="rdt-cell-editor" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @change=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c>
            ${repeat<any>(this.editorOptionsOf(cellCtx.column.id), (opt, _idx) => opt.value, (opt, _idx) => html`<option key=${rozieAttr(opt.value)} value=${rozieAttr(opt.value)} data-rozie-s-d5dcab4c>${rozieDisplay(opt.label)}</option>`)}
          </select>` : this.editorTypeOf(cellCtx.column.id) === 'checkbox' ? html`<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" ?checked=${this.editorCheckedFor(cellCtx.column.id)} @change=${($event: Event) => { this.onCellEditorCheckbox(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : html`<input class="rdt-cell-editor" type="text" data-editing-cell="" .value=${this.editorValueFor(cellCtx.column.id)} @input=${($event: Event) => { this.onCellEditorInput(cellCtx.column.id, $event); }} @keydown=${($event: Event) => { this.onEditorKeyDown($event); }} @blur=${($event: Event) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />`}</span>` : html`<span class="rdt-cell-value" data-rozie-s-d5dcab4c>
          ${this.cell !== undefined ? this.cell({columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue()}) : html`<slot name="cell" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cellCtx.column.id, column: cellCtx.column, row: row.original, value: cellCtx.getValue()}); } catch { return '{}'; } })()}>${rozieDisplay(cellCtx.getValue())}</slot>`}
        </span>`}${this.isFillHandleCell(this.rowIndexOf(row), this.colIndexOf(row, cellCtx)) ? html`<span class="rdt-fill-handle" data-fill-handle="" data-testid="fill-handle" aria-hidden="true" @pointerdown=${($event: Event) => { 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: Event) => { 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.pageCount())}
  </span>
  <button class="rdt-page-btn rdt-page-next" type="button" ?disabled=${!this.canNextPage()} @click=${($event: Event) => { this.onNextPage(); }} data-rozie-s-d5dcab4c>Next</button>
  <select class="rdt-page-size" aria-label="Rows per page" .value=${this.pageSize()} @change=${($event: Event) => { 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;

  expandedTouched = false;

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

  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.columnPinning != null ? this.columnPinning : this._columnPinningDefault.value,
  // 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 gid = c.id != null ? c.id : c.header;
    if (gid == null) return null;
    const id = String(gid);
    if (!this.isSafeKey(id)) return null;
    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;
    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: ''
    };
    withExpander = [expanderCol].concat(cols);
  }
  if (this.selectionEnabled()) {
    const selectCol = {
      id: this.SELECT_COL_ID,
      enableSorting: false,
      enableColumnFilter: false,
      filterable: false,
      isSelectColumn: true,
      pinned: '',
      width: ''
    };
    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;
  this.programmatic++;
  this._columnPinningDefault.value = next;
  this._columnPinningControllable.write(next);
  this.dispatchEvent(new CustomEvent("pin-change", {
    detail: next,
    bubbles: true,
    composed: true
  }));
  this.programmatic--;
};

  writeData = (next: any) => {
  if (this.programmatic) return;
  this.programmatic++;
  this._dataDefault.value = next; // fresh array only (never in-place)
  this._dataControllable.write(next); // 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);
};

  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;
};

  reFeed = () => {
  if (!this.table) return;
  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 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();
};

  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);

  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) 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) => {
  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:1;';
  }
  if (side === 'right') {
    const right = col.getAfter ? col.getAfter('right') : 0;
    return 'position:sticky;right:' + right + 'px;z-index:1;';
  }
  return '';
};

  thStyle = (colId: any) => {
  let s = '';
  const w = this.headerWidth(colId);
  if (w) s += 'width:' + w + ';';
  s += this.pinStyle(colId);
  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;
};

  pageCount = () => {
  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());

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

  rowShowsDetail = (row: any) => this.getSubRows == null && 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. On Solid the expander <td>/<button> is RECREATED on that
  // re-render (the reference-keyed cell <For> receives fresh table-core cell instances each
  // pull — the <tr> persists but its cells are rebuilt), which drops DOM focus to <body> and
  // breaks keyboard activation (Enter/Space on the focused expander leaves nothing focused).
  // Re-focusing the (possibly-recreated) expander in the SAME row keeps the control focused —
  // the focusActiveCell imperative-refocus precedent. The rAF defers past the synchronous
  // reactive flush so the fresh node exists. Harmless on the targets that keep the node
  // (Vue/React/Svelte/Angular/Lit re-focus the same element → no-op).
  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) {
    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());

  groupSubRowCount = (row: any) => 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));
};

  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();

  absRowIndexOf = (row: any) => this.rowIndexOf(row) + 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;
};

  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;
  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;
    const focusWhenReady = () => {
      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;
};

  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;
};

  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) => {
  const max = this.visibleColCount() - 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) => {
  const max = this.visibleColCount() - 1;
  const nextCol = toEnd ? max < 0 ? 0 : max : 0;
  this._activeColIndex.value = nextCol;
  return nextCol;
};

  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. ──────────
  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;
  }
  // ── 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;
  }
  // ── 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) {
    // 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.
    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);
    this.recoverGridFocus(String(grpRow), grpCol, null);
    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) {
    this.dispatchEvent(new CustomEvent("activecell-change", {
      detail: {
        rowIndex: this.toAbsRow(nextRow),
        colIndex: nextCol
      },
      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;
  const isHeader = rowAttr === '__header';
  this._activeIsHeader.value = isHeader;
  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();
    this._activeHeaderLevel.value = Number.isFinite(lvl) ? lvl : this.headerLeafLevel();
  } else {
    const row = parseInt(rowAttr, 10);
    if (Number.isFinite(row)) this._activeRow.value = row;
  }
  this._activeColIndex.value = col;
  // 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 || !e.shiftKey) 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;
  this.setRangeFocus(row, col);
  this._activeIsHeader.value = false;
  this._activeRow.value = row;
  this._activeColIndex.value = col;
  this.rangeClickPending = true;
};

  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) => {
  if (!this.gridRoot) return;
  let attempts = 0;
  const tryFocus = () => {
    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);
};

  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;
  const srcCols = srcRows > 0 ? grid[0].length : 0;
  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);
};

  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();
  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();
    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);
  }
};

  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;
  let attempts = 0;
  const tryFocus = () => {
    const el = this.gridRoot ? this.gridRoot.querySelector('[data-editing-cell]') : null;
    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;
  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;
  // 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;
};

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

  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;
  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('');
  const srcIndex = this.sourceIndexOfRow(this._editingRow.value);
  const next = this.replaceRowValue(this.currentData(), srcIndex, field, newValue);
  // 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;
  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;
  // 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
  };
  return true;
};

  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;
  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;
  // 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;
  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);
      // 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.
      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
    });
  }
  // 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);
  // Snapshot the active COLUMN to return focus to (the whole row is in edit, so the
  // active-cell column is the roving focus target), BEFORE endRowEdit clears editing state.
  const focusCol = this._activeColIndex.value;
  this.editTransition = true;
  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;
  // 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
  };
  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;

  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;
  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): blur NEVER commits — the row commits as a UNIT only on an
  // explicit Enter / save / editRow-driven flow (a per-cell blur-commit would split the row
  // into N writes + N events, violating the one-write/one-event contract). Tabbing between
  // the row's own editors is a normal focus move, not a commit.
  if (this.inRowEdit()) 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);
  this._activeIsHeader.value = false;
  this._activeRow.value = r;
  this._activeColIndex.value = c;
  this.beginEdit(r, c, null);
};

  commitEditing = () => {
  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(absRow + 1);
  const tryFocus = () => {
    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;
  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 absolute aria-rowindex (absRow+1) before focusing, so the OLD page's
      // same-indexed cell is never grabbed-then-removed (drop-to-<body>). DOM-only, React-safe.
      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, idiomatic 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 — all from the one source above, built on @tanstack/table-core with no per-framework adapter behind it.

See also

Pre-v1.0 — internal monorepo.