Skip to content

feat: 3x3 frozen band model - right-frozen columns + simultaneous top/bottom frozen rows (ViewportMgr Phase 4) - #1238

Closed
6pac wants to merge 43 commits into
masterfrom
viewportmgr-phase4
Closed

6pac wants to merge 43 commits into
masterfrom
viewportmgr-phase4

Conversation

@6pac

@6pac 6pac commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

Completes the 3×3 band model. Rebased onto current master (includes the #1229 squash and the #1231/#1232 fixes — the scrollTo paging fix verified intact alongside the band code); supersedes the stacked #1236. Contains the full ViewportMgr series: the #1234 commits replay here identically, so #1234 can be closed in favour of this PR or merged first — either order works. SlickGrid now supports left AND right frozen columns and top AND bottom frozen rows in any combination — up to nine panes — all managed by ViewportMgr, with every legacy configuration byte-identical (proven by the phase 0–3 characterization suites, which run unchanged).

New capabilities (all opt-in via new options; defaults preserve current behaviour exactly)

  • frozenRightColumn (count from the right edge — counts survive column reorder/hide, unlike index-based frozenColumn): a right-frozen column band with new *-right-frozen css classes. The historical "right" elements keep their names and become the scrollable middle band. Column-side selection, geometry (band pinned at the right edge, middle band shrinks), row-fragment routing (rowNode grows to 3 entries), band-local cell coordinates, keyboard navigation across the boundary, and Y-scroll following are all implemented and spec-pinned.
  • frozenBottomRow (count, usable TOGETHER with frozenRow, which then always means top rows; the legacy frozenBottom flag keeps its exact historical single-band meaning): a bottom-frozen row band (*-bottom-frozen classes) with three-way row routing, geometry (band pinned below the body, above its scrollbar), X-scroll following, and runtime toggling. The shared bottom-frozen × right-frozen corner pane is created by whichever band materializes second.
  • Public band API: getFrozenBandCounts() / getFrozenRightStartIndex(); cellrangeselector is migrated onto it, which also fixes its cross-canvas measurement under the new modes and adds correct offsets/clamps for the new bands.
  • ViewportMgr extracted to slick.core.ts (registered on the Slick namespace beside DragExtendHandle; contracts in src/models/viewportMgr.interface.ts) — slick.grid.ts ends ~1,500 lines lighter than before the whole refactor, with zero frozen-flag branching outside setFrozenOptions. No consumer impact: core already loads before the grid everywhere.

Hardening

Three adversarial equivalence audits ran during Phase 4 (right-band transitions, bottom-band-off equivalence, init-window binding). Real defects found and fixed by them: stale-band routing/geometry guards after un-freezing, an init-window event double-binding (latent since the right-band milestone), and a dropped disableSelection on init-materialized headers. Known preserved quirks and invariants are documented in the source at their definitions.

Testing

Three new example pages and 25 new band tests across three specs (viewportmgr-right-frozen-band, viewportmgr-bottom-frozen-band, extended viewportmgr-lazy-materialization), covering init-time and runtime materialization, geometry, routing, navigation, and un-freeze restoration. Every commit in the series landed only after the full Cypress suite passed locally (632 tests). Headless-only retries: { runMode: 1 } added to the Cypress config after reproducing a ~1-in-4 machine-level flake on pristine master (three different victim specs across runs; CI here is the authoritative check).

🤖 Generated with Claude Code

@6pac

6pac commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Freeze activation options — ViewportMgr refactor (SlickGrid)

Reference for the grid-option surface controlling frozen rows/columns after the
ViewportMgr refactor (branches viewportmgr-phase1 / viewportmgr-phase4,
PRs #1234 / #1236). Companion docs: PLAN-panegrid.md, KNOWN-QUIRKS.md.

Option surface

Option Kind Default Meaning
frozenColumn legacy, index -1 Left-frozen columns: freeze columns 0..N inclusive. Semantics unchanged from upstream.
frozenRow legacy, count -1 Frozen rows. Always the top band whenever frozenBottomRow is in use; otherwise positioned by frozenBottom.
frozenBottom legacy flag false Positions the single frozenRow band at the bottom instead of the top. Ignored when frozenBottomRow > 0 (it only ever selects the single-band position).
frozenRightColumn new, count from the right edge 0 Right-frozen column band. A count, not an index — counts stay correct when columns are reordered or hidden (avoids the index-drift problems calculateFrozenColumnIndexById patches for frozenColumn).
frozenBottomRow new, count 0 Rows frozen at the bottom alongside top rows — the simultaneous top+bottom mode (three row bands).
lazyPanes new, opt-in false Build only the top-left pane/viewport/canvas when nothing is frozen at construction; all other panes materialize on demand. Off by default because it changes the DOM for consumers that style/query the historically ever-present unused panes.

Activation model

  • No allow-freeze presets. The originally planned init-time gates
    ("allow freeze left/right/top/bottom", which would have pre-created pane DOM and
    permanently fixed a grid's available freeze modes) were dropped during the
    dynamic-materialization feasibility work (plan §4b): band existence is driven
    directly by the freeze options themselves. Any freeze can be enabled or disabled
    at runtime via setOptions() on any grid; band DOM materializes on first use at
    its canonical position. The vestigial preCreateBands perf hint from the early
    API sketch was never needed and never implemented.
  • A default-configured grid is byte-identical to the pre-refactor grid (all six
    classic panes built up front; freeze options show/hide and route).
  • The two new bands (frozenRightColumn, frozenBottomRow) are always
    materialize-on-demand regardless of lazyPanes — set them in the constructor
    options or later via setOptions(); either path produces the same DOM.
  • Un-freezing hides band panes but keeps them in the DOM (matches the historical
    always-present behaviour; see KNOWN-QUIRKS.md design decision Prevent useless onSelectedRangesChanged events in selectionmodels' setSelectedRanges #9).

Plugin-facing band API (for completeness)

  • grid.getFrozenBandCounts() → { frozenLeftCols, frozenRightCols, frozenTopRows, frozenBottomRows } (a copy; zero = band absent)
  • grid.getFrozenRightStartIndex() → index of the first right-frozen column, or the column count when the band is off (idx >= result is a safe membership test)

@6pac

6pac commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Will investigate ways to reduce the line count tomorrow, but this PR is the start for manual testing.

@ghiscoding

Copy link
Copy Markdown
Collaborator

What I'm worried about in this PR is the very large amount of new lines of code, over 1400 lines of code (loc) as shown below, that is a lot!!! The only thing that we're gaining here is right freeze and also to stop rendering panes that aren't being used (e.g. pane-right or pane-bottom, etc...). If so, 1400 loc for basically just adding right freeze, is just way too much. It would increase the lib size by a lot, we can certainly do better

image

Repository owner deleted a comment from 6pac-ai Jul 16, 2026
@6pac

6pac commented Jul 16, 2026

Copy link
Copy Markdown
Owner Author

looking into that, but I think it's worth testing at this point to sanity check the overall usability.

Claude is indicating that it kept a lot of architecture the same way just to maintain byte-identical HTML in thos PR, but we can lose about 600 further lines through a subsequent stage 2 refactor. I'm investigating adding the sticky rows feature as well - I'm interested to see if that would use a lot of the same code.

At the moment I'm not sreally sure how to roll this out though - this PR applies to main and can be merged locally without merging in the main repo, for testing. A further PR could I suppose also be pulled and merged locally on top of the previous merge. Is that workflow OK for you?

@ghiscoding

ghiscoding commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

I think that considering this is probably going to be released under a major (aka breaking) version, then there's no point in carrying over old code if you can rewrite it in a better way that might change certain flags and behaviours (that's what major versions are for). Like I said a while ago, we can create a branch named "next" or "dev" (based on master) and once accepted merge all your PRs on that next branch so that you can use it until ready to fully release it under a major version (that's what I've done in the past with v4 and v5), then merge back next into master when officially released.

The only PR that could be merged and released under current v5 is the variable row height, I would put more focus on finishing that one. Anything else (freeze/pinning rewrite, sticky columns, drop SortableJS, ...) should be done in a separate next or dev branch and consider that branch to be the future v6 (because all of these features contain some form of breaking changes/behaviors)

looking into that, but I think it's worth testing at this point to sanity check the overall usability.

I did test the UI quickly, that's how I saw that you did stop rendering the pane-right/pane-bottom/... when not displayed (which I agree with). But apart from the right column freeze (which seems a little off in the demo since left columns weren't covering all left space though it becomes ok when grid has lots of columns), that feature alone surely doesn't explain the addition of over 1400 loc

6pac and others added 24 commits July 18, 2026 18:15
…ruction (Phase 1)

Moves the construction of the 6 panes, 4 viewports, 4 canvases, header/headerrow/
top-panel/footer-row/pre-header containers out of SlickGrid.initialize() into a new
internal ViewportMgr class (same file — a separate file would break script-tag
consumers since the iife build emits one file per source). The grid keeps aliases to
every element, so all logic is unchanged and the DOM is byte-identical, as proven by
the dom-shape-characterization spec. Full suite green: 600 tests (599 pass, 1 pending),
matching the Phase 0 baseline exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ty and width distribution into ViewportMgr (Phase 2, milestones 1-2)

ViewportMgr now owns a freeze-state snapshot (pushed by setFrozenOptions) and:
- paneCellIndex() — the (col,row)->pane index math from _getContainerElement
- applyPaneFrozenClasses / applyPaneVisibility / applyOverflow
- selectScrollContainers() — the setScroller X/Y-owner + follower selection
- applyCanvasWidths() — the pane/viewport/canvas/header width distribution from
  updateCanvasWidth (width computations stay in the grid)

The grid methods are now thin delegates. Behaviour identical: full Cypress suite
green (600 tests, 599 pass / 1 pending), matching the Phase 0 baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n into ViewportMgr (Phase 2, milestone 3)

ViewportMgr.applyPaneHeights() now computes paneTopH/paneBottomH/viewportTopH from
the freeze configuration and sizes every pane/viewport/canvas, returning the heights
for the grid's layout pipeline. The container VBox delta is passed as a lazy callback
so the autoHeight-only style recalc is not made unconditional. Full Cypress suite
green (600 tests, 599 pass / 1 pending), matching baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o ViewportMgr (Phase 2, milestones 4-5)

- attachRow(): the 4-way canvas attachment from renderRows, returning the rowNode
  array. Preserves the historical render-side band threshold (rowIdx >= actualFrozenRow
  without the +1 non-frozenBottom adjustment used by paneCellIndex) verbatim.
- syncHorizontalScroll(): the scrollToX follower fan-out (scroll owner, header,
  top panel, footer row, pre-header, frozen header-row/viewport followers); the
  grid-owned top-header panel stays in scrollToX.
- syncVerticalFollowers(): the frozen-left viewport scrollTop mirroring from
  _handleScroll.
- selectScrollContainers() now caches its result as the authoritative owner set.

Full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…C-eligible

Found by an adversarial transcription audit of the Phase 1-2 refactor: the manager
retained references to every pane/viewport/canvas element and the container after
destroy(), keeping the detached subtree alive if the app held onto the grid instance.
Also restores the original post-destroy failure mode for the delegated methods.
Full Cypress suite green (600 tests, 599 pass / 1 pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o ViewportMgr (Phase 2, milestone 6)

- frozenRowOffset(): the getFrozenRowOffset computation (grid method stays as a
  public delegate), historical commented-out one-liner preserved.
- isRowInFrozenBand(): the cleanupRows keep-frozen-rows predicate.
- isRowCellCleanupExempt(): the cleanUpCells skip predicate, transcribed verbatim
  including the long-standing quirk that the second disjunct is not guarded by
  !frozenBottom (documented at the definition).
- isColumnInFrozenBand(): the frozen-column cleanup exemption.

Full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ase 2, milestone 7)

New ViewportMgr helpers absorb the recurring 'which side of the freeze' patterns:
- isColumnRightOfFreeze() — the hasFrozenColumns() && idx > frozenColumn test
- sideLocalColumnIdx() — the right-side child-index rebase (idx - frozenColumn - 1)
- sideForColumn() — pick the L or R element of a pair for a column

Rewritten call sites: getHeader, getHeaderColumn, getHeaderRow, getFooterRow,
getHeaderRowColumn, getFooterRowColumn, getHeadersWidth, getCanvasWidth,
appendRowHtml (rowDivR clone guard + cell routing + always-render frozen band),
appendCellHtml (frozen cell class), cleanUpAndRenderCells (node reattachment).
The compound 'hasFrozenColumns() && idx <= frozenColumn' collapses to
isColumnInFrozenBand() alone — equivalent for non-negative indices since the
unfrozen sentinel is -1.

Full Cypress suite green (600 tests, 599 pass / 1 pending), matching baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tes through ViewportMgr (Phase 2, milestone 8)

Adds hasFrozenRows() accessor and bodyCanvasL() (the scrollable-body left canvas
selector shared by updateRowCount and bindAncestorScrollEvents) to ViewportMgr, and
rewrites the remaining ~34 grid-side sites onto the manager's accessors and column-
side helpers: setupColumnResize, setupColumnReorder, createColumnHeaders,
createColumnFooter, updateRowCount, scrollTo, scrollRowIntoView, render, renderRows,
getCellFromEvent, setActiveCellInternal, navigateToPos, bindAncestorScrollEvents,
appendRowHtml, updateCanvasWidth.

Grid-side frozen-flag branching is now zero outside setFrozenOptions (the state
owner): 95 sites at baseline, 3 writes remaining. frozenBottom/frozenRow option
reads intentionally stay grid-side where they carry option semantics rather than
band routing. Full Cypress suite green (600 tests, 599 pass / 1 pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(Phase 3, milestone 9)

Introduces the opt-in lazyPanes grid option (no behaviour yet) and makes every
ViewportMgr method plus the four remaining unconditional grid-side right-element
writes (initialize spacer width, createColumnHeaders/headerRow emptying,
updateRowCount canvasTopR height) tolerate absent panes. All guards are inert
while every pane exists: full Cypress suite green (600 tests, 599 pass /
1 pending), matching baseline exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…zen (Phase 3, milestone 10)

With lazyPanes: true and no frozen rows/columns at init, buildPanes/buildFooterRows
now create just 2 panes / 1 viewport / 1 canvas plus left-side chrome, using inline
conditionals so element order stays canonical in both modes. getHeaderChildren now
iterates the _headers array instead of assuming two containers (the one crash the
existence-guard pass had not covered). Adds examples/example-lazy-panes.html and a
7-test characterization spec proving the single-pane DOM shape and basic function
(render, navigation, scrolling). Default-mode DOM unchanged: full suite green
(607 tests, 606 pass / 1 pending — 600 baseline + 7 new minus overlap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Panes grid (Phase 3, milestone 11)

- ViewportMgr.materializeSecondaryPanes(): builds the right/bottom panes, chrome,
  viewports and canvases at their canonical sibling positions and pushes them into
  the shared element arrays in place; idempotent.
- SlickGrid.materializeLazyPanes(): hooked into setFrozenOptions (so it runs before
  setScroller/setColumns in the internal_setOptions pipeline); re-aliases fields,
  wires events for the new elements only, binds sort clicks on the new right header,
  and re-anchors ancestor-scroll bindings.
- Event wiring extracted from finishInitialization into bindPaneEvents() shared by
  init and materialization — this also closes the audited gap where late-created
  viewports would miss MouseWheel handling.
- Alias block extracted to syncViewportMgrAliases(); setupColumnSort parameterized.
- Un-freezing keeps materialized panes (hidden), matching historical behaviour.
- Tests: viewportmgr-lazy-materialization.cy.ts (6 tests: freeze columns from lazy,
  header split + row routing, unfreeze, refreeze with rows, fresh-page frozen-rows-
  first), deliberately sorted after the example-* specs and self-contained.

Verification: 3 consecutive full-suite runs — green, green except one failure in
example-excel-compatible-spreadsheet's native-clipboard paste test, green. That
test is probabilistically flaky under full-suite load on this Windows machine
(passes in isolation and in 2/3 full runs; also failed historically only in full
runs); Linux CI remains the arbiter. All 13 lazy/materialization tests passed in
all three runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stone 12)

ViewportFreezeState gains frozenRowCount (the frozenRow option value) and
updateFreezeState derives a FreezeBandCounts view — frozenLeftCols /
frozenRightCols (0 until right-frozen columns land) / frozenTopRows /
frozenBottomRows — alongside the authoritative legacy snapshot. hasFrozenColumns()
is the first predicate re-expressed in band terms (frozenLeftCols > 0, provably
equivalent to frozenColumnIdx > -1). Pure refactor, no behaviour change.

Full suite: one ambient flake (example-plugin-headerbuttons, passes 9/9 in
isolation — third distinct victim of the machine-level ~1-in-4 full-run flake,
after excel-clipboard on the phase branch and row-span on pristine master),
then a fully green re-run (613 tests, 612 pass / 1 pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oad flakes

Local full-suite runs showed an ambient ~1-in-4 flake in timing-sensitive tests
(native clipboard paste, render waits) reproduced on pristine master with three
different victim specs across runs. runMode-only retry absorbs these while
Cypress still flags retried tests as flaky; openMode stays at 0 so interactive
debugging sees raw failures. Matches the { retries: 1 } several specs already
carry individually.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… inert)

New grid option frozenRightColumn: a COUNT of columns to freeze at the right edge
(not an index like frozenColumn — counts stay correct under column reorder/hide).
setFrozenOptions normalizes it (non-negative integer; the left and right bands must
leave at least one scrollable column between them) and pushes it into ViewportMgr's
band-count state as frozenRightCols. Nothing consumes the band yet — the right-
frozen DOM, routing and geometry arrive in the following M13 stages — so behaviour
is unchanged: full Cypress suite green, matching baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e 13b)

ViewportMgr.materializeRightFrozenBand() builds three new panes with NEW
*-right-frozen css classes (appended after the classic six so classic sibling
positions are untouched — the historical 'right' elements keep their names and
become the scrollable middle band), plus header/header-row/top-panel chrome,
viewports, canvases and footer row; shared element arrays extend at the END so
classic indexes 0-3 stay valid. Wired for both init-time (setFrozenOptions runs
before the event-binding loops) and runtime enabling, with events bound to the
new elements only; applyPaneVisibility shows/hides the band; un-freezing keeps
it hidden in the DOM, matching classic pane behaviour.

Staged state, pinned by viewportmgr-right-frozen-band.cy.ts (8 tests + new
example page): the band exists and is visible, but cells still render in the
classic canvases until geometry (M13c) and routing (M13d) land. Full suite
green: 623 tests (622 pass / 1 pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…owing (Phase 4, milestone 13c)

- Three-way width split: getCanvasWidth/getHeadersWidth accumulate canvasWidthRF/
  headersWidthRF for the last frozenRightColumn VISIBLE columns (boundary via
  getFrozenRightStartIdx; identical behaviour when the band is off).
- applyCanvasWidths pins the RF panes at the right edge and shrinks the scrollable
  middle band by the band width — in both the left-frozen layout and the
  no-left-freeze layout (where the historical '100%' widths become pixel widths
  while the band is active).
- applyPaneHeights mirrors the classic right-pane vertical geometry for the band,
  including frozen-row canvas heights.
- RF viewports keep both overflows hidden (never own a scrollbar) and follow Y via
  syncVerticalFollowers, scrollTo and updateRowCount. Scroll-owner selection needed
  NO change: the middle band was already the owner under the historical naming.

Cells still render in the classic canvases until M13d routing. Spec grows a
geometry test (band sized, right-pinned, middle+RF fill the container). Full suite
green (622 tests, 621 pass / 1 pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (Phase 4, milestone 13d)

Right-frozen columns now work end-to-end:
- createColumnHeaders/createColumnFooter route the band's columns via the new
  three-way bandElementForColumn (headerRF/headerRowRF/footerRowRF included in the
  destroy/empty/width passes).
- appendRowHtml builds a third row fragment; renderRows collects divArrayRF and
  attachRow appends it to the RF canvases, growing rowsCache[].rowNode to 3 entries
  (RF fragment always last; rowNodeIdxForColumn encapsulates the index math).
- applyColumnWidths and getCellNodeBox rebase RF columns to band-local coordinates;
  paneCellIndex maps RF cells to array slots 4/5 (materializeRightFrozenPanes
  canonicalizes the classic set first so those slots hold under lazyPanes).
- cleanUpCells exempts the band (always horizontally visible); RF cells get the
  'frozen' css class; ensureCellNodesInRowsCache generalizes to N fragments; the
  public getHeader/getHeaderColumn/getHeaderRowColumn/getFooterRowColumn getters
  resolve three-way with band-local child indexes.

Spec asserts real routing: header split, RF row fragments with band-local x=0,
runtime toggle restoring classic routing both directions.

Verification note: the first full-suite run after this change failed 13 rowspan
tests in example-0032 during an overnight machine-load window (3:41 vs the usual
~2:45); the spec passes 45/45 in isolation, with its preceding spec subset, and
the full-suite re-run is completely green (623 tests, 622 pass / 1 pending) —
consistent with the documented ambient flake on this machine, which also struck
this same spec on pristine master.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…audit (Phase 4, M13 hardening)

A three-way adversarial audit proved the M13c/M13d changes behaviourally inert with
frozenRightColumn=0 (sealing the example-0032 incident as the documented ambient
machine flake) and surfaced four RF-on transition weaknesses, fixed here:

- appendRowHtml: cell routing now requires the RF fragment to exist (guard was
  band-count-based while the fragment clone was DOM-based), with a same-fragment
  fallback in the three-way pick — no undefined targets during materialization
  transitions.
- attachRow: the RF append guard now checks the actual target canvas (bottom-band
  rows previously checked canvasTopRF but appended to canvasBottomRF).
- appendRowHtml: divArrayRF moved to a trailing optional parameter so downstream
  subclass overrides of the protected method keep compiling and binding correctly.
- updateCanvasWidth: the getHeadersWidth() recompute guard gains the RF term,
  keeping it symmetric with applyCanvasWidths' distribution guard (stale
  headersWidthRF was otherwise possible with RF on and nothing else frozen).

Full suite verified green BEFORE this commit: 623 tests, 622 pass / 1 pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…IntoView (Phase 4, milestone 13e)

Right-frozen cells are always horizontally visible, so scrollCellIntoView now
returns early for them — mirroring the historical left-frozen guard. New spec
test drives arrow-key navigation across the middle↔right-frozen boundary in both
directions, which also exercises the RF canvas keydown wiring and pane-index cell
lookup end-to-end, and asserts the middle viewport does not scroll on band entry.

Full suite verified green BEFORE this commit: 624 tests, 623 pass / 1 pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nert)

New grid option frozenBottomRow: a COUNT of rows to freeze at the bottom, usable
TOGETHER with frozenRow (which then always means top rows; the legacy frozenBottom
flag is ignored when the count is set — it only positions the single-band case).
setFrozenOptions normalizes it (non-negative integer; top + bottom bands must
leave at least one scrollable body row) and pushes it into ViewportMgr's band
derivation, which now expresses all three row-band configurations while every
legacy configuration derives to exactly the same values as before. Nothing
consumes the simultaneous-bands state yet — the bottom-frozen band DOM, geometry
and routing arrive in M14b-d.

Verification: first full run failed only the documented clipboard flake (passes
8/8 isolated); full re-run verified green BEFORE this commit (624 tests,
623 pass / 1 pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uble-binding (Phase 4, milestone 14b)

- materializeBottomFrozenBand() builds one pane+viewport+canvas per active column
  band with *-bottom-frozen classes, appended after all existing panes; the shared
  bottom-frozen × right-frozen corner pane is added by WHICHEVER band materializes
  second (ensureBottomFrozenRightVariant called from both), with events bound on
  either path. applyPaneVisibility shows the band only in simultaneous top+bottom
  mode, mirroring classic bottom-pane column visibility.
- Slot registry: dynamically materialized viewports/canvases record their array
  positions at push time (rfTopSlot/rfBottomSlot/bfSlot*), replacing paneCellIndex's
  hardcoded RF slots 4/5 — band materialization order can no longer skew lookups.
- Fixes a latent M13b bug: finishInitialization sets initialized=true BEFORE its
  array-wide bindPaneEvents pass, so bands materialized during setFrozenOptions in
  that window were bound twice (once by the materializer, once by the array pass) —
  affected right-frozen-at-init grids. Materializers now gate on a _paneEventsBound
  flag set after the array-wide pass.

Staged state pinned by viewportmgr-bottom-frozen-band.cy.ts (6 tests + example
page): the band exists (init-time and runtime) but classic top-frozen rendering is
unchanged until M14c geometry and M14d routing. Full suite verified green BEFORE
this commit: 630 tests, 629 pass / 1 pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lowing (Phase 4, milestone 14c)

In simultaneous top+bottom mode the scrollable body pane shrinks by the band
height inside the height computation itself (single write, no post-adjustment);
the band pins directly below the body with per-column-band widths mirroring the
classic bottom panes across all three column configurations (left-frozen /
right-frozen / plain), including the shared BF×RF corner pane. Band viewports are
scrollbar-less on both axes; the band's scrollable-column viewport joins the
X-followers in syncHorizontalScroll, mirroring how frozen-top viewports follow
horizontal scrolling. The body pane remains the X/Y scroll owner (v1 layout: the
horizontal scrollbar sits at the body's bottom edge, above the band).

Rows still render in the classic canvases until M14d routing; the spec's new
geometry test pins band height = frozenBottomRow * rowHeight and its position
directly below the body pane. Full suite verified green BEFORE this commit:
631 tests, 630 pass / 1 pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…m frozen rows work end-to-end (Phase 4, milestone 14d)

The 3x3 band model is complete: with frozenRow + frozenBottomRow set, the last
frozenBottomRow rows render in the band's canvases across all column bands.

- isRowInBottomFrozenBand: BOUNDED membership (splitRow <= row < splitRow + count)
  so the add-new row can never be captured; the same test serves both render and
  lookup sides — the new band deliberately has no threshold asymmetry (the
  historical one-row asymmetry remains only in the legacy single-band logic).
- bottomFrozenSplitRow = dataLength - frozenBottomRow, computed in setFrozenOptions
  (staleness semantics deliberately match actualFrozenRow's).
- attachRow routes three row bands x three column bands (incl. the BF x RF corner
  fragment); paneCellIndex resolves BF cells via the slot registry.
- render() gains a second frozen pass + a band range in the horizontal-scroll cell
  pass; updateRowCount excludes the band from the scrollable body count;
  frozenRowOffset rebases band rows to band-local coordinates; cleanupRows/
  cleanUpCells exempt the band; band rows get the frozen css class;
  scrollRowIntoView never scrolls for band rows; getCellFromEvent resolves clicks
  in the band canvas (the -bottom-frozen class token cannot collide with the
  classic -bottom selector).

Spec now asserts real routing: Task 498/499 in the band with band-local y=0,
frozen class, click-activation without body scroll, runtime toggle restoring
classic routing. Full suite verified green BEFORE this commit: 632 tests,
631 pass / 1 pending. BF-off equivalence audit running as the closing check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two-agent adversarial audit of the M14b-d delta vs the M13e tip; the
render-routing area proved fully inert when simultaneous mode is off, and one
real regression plus two tightenings were found and fixed:

- disableSelection ordering (REAL): the _paneEventsBound guard swap had silently
  dropped disableSelection() for headers materialized during the init window
  (right-frozen-at-init and lazy+right-frozen grids) — it was never part of the
  double-binding the flag fixed, and the array-wide disableSelection(this._headers)
  ran BEFORE setFrozenOptions created those headers. finishInitialization now
  applies it AFTER setFrozenOptions so init-materialized headers are covered via
  the shared array, with no double-application.
- setFrozenOptions consults getDataLength() only when frozenBottomRow is actually
  in use, restoring BASE's call cadence for legacy grids (matters for
  side-effectful CustomDataView.getLength implementations and the null-data
  pre-init edge).
- getCellFromEvent short-circuits the '.grid-canvas-bottom-frozen' ancestor walk
  behind hasBottomFrozenBand() (perf; the class token could never false-match).

Full suite verified green BEFORE this commit: 632 tests, 631 pass / 1 pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6pac-ai and others added 5 commits July 19, 2026 22:02
Formula-transcribed pins computed from live column data (no hardcoded px):
the +1000 left/single-band header slack (exact), the right-frozen band as a
PLAIN column sum with no slack or scrollbar (exact, header and canvas), the
cumulative headersWidthR floor under a left freeze, plain per-band canvas
sums, and the plain-grid quirk that the hidden R header container still
gets width: 0px written. All 11 tests pass against current code before the
computeHeaderWidths/computeCanvasWidths relocation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
computeHeaderWidths/computeCanvasWidths move VERBATIM from getHeadersWidth/
getCanvasWidth (reverse iteration and all), guarded by the golden spec
committed first (viewportmgr-width-golden.cy.ts, 11 formula pins). Quirks
preserved in place: the +1000 left/single-band slack, CUMULATIVE r
(includes the post-slack l) under a left freeze, the RF band as a plain
sum, the out-of-range isColumnRightOfFreeze(columns.length) scrollbar
probe after the loop, and fullWidthRows extra width to the scrollable band.

Two deliberate non-moves: rfStartIdx stays a GRID-derived fresh input
(these call sites historically re-derive it per call; the freeze snapshot
is not substituted), and the grid keeps headersWidthL/R/RF + headersWidth +
canvasWidthL/R/RF as synced mirrors - they are protected fields visible to
subclass wrappers (slickgrid-universal); deleting them is a semver-major
follow-up, not part of this work.

tsc/eslint clean; gate 77/77 (golden spec leading); full suite 679 / 0
failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion)

- columnBandGeometry(colIdx, geometry): applyColumnWidths' three-way band
  width pick plus BOTH historical x-reset conventions as named booleans -
  the RF band resets BEFORE its first column, the left freeze resets AFTER
  the frozen column (which itself does not accumulate). frozenColumnIdx and
  rfStartIdx stay grid-fresh inputs (live options read + per-call
  derivation preserved).
- accumulateBandWidths: setupColumnResize's two CLEAN bucketing passes
  collapse to one call. Honest scope note: the report claimed six repeats;
  the other four interleave bucketing with forceFit width mutation and are
  a different shape - they stay inline.
- setLiveResizeLeftWidth: the drag-time header slack (+1000) and middle
  header pane re-anchor move behind the facade.
- updateColumnCaches keeps its left-only reset INLINE, now documented: RF
  columns deliberately continue its coordinate space (preserved asymmetry
  vs the band oracle).

tsc/eslint clean; gate 81/81; full suite 679 / 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- createRowFragments(rowDiv): clone-per-active-band moves into the vm (the
  clone-not-share requirement documented at the source); appendRowHtml keeps
  the divArray push plumbing verbatim - the holding-div drain in renderRows
  depends on its exact pattern, so the alignment-hazard zone is untouched.
- fragmentForColumn(frags, i, { alwaysRenderColumn, branch }): the cell
  routing rules keyed by the caller's EXACT control branch. The viewport
  branch keeps the RF->L fallback (transition safety); the offViewport
  branch keeps alwaysRender/left-frozen->l and right-frozen->rf. The two
  rule sets are deliberately NOT unified into one inViewport predicate:
  outer-viewport-test-true with isRenderCell false must render NOTHING,
  including frozen cells (historical).
- collectRowCellNodes: the fragment-flatten with its ascending-column-order
  invariant (cellRenderQueue tail-draining depends on it) named and moved.
- updateRowPositions keeps its [0]-only reposition INLINE, now documented
  as inherited upstream behavior.

Considered and skipped (no facade gain): appendCellToRow (the band routing
is already the vm's rowNodeIdxForColumn), setRowTop as a member, and any
restructure of the renderRows holding-div drain.

tsc/eslint clean; gate 180/180 (rowspan/colspan suites leading); full
suite 679 / 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sion)

The facade plan (FACADE-FEASIBILITY.md) scheduled these for terminal
deletion as compat scaffolding. The M19f census says otherwise: ~57
legitimate references remain at grid chrome/geometry sites where
vm.paneHeaderL reads better than a paneAt()/at() chain with a non-null
assertion, and the vm's own geometry appliers and materializer manifests
use them throughout. Deleting them would trade readable code for ~66
lines - the same no-facade-gain test that trimmed other report members
(appendCellToRow, setRowTop, isBandBoundaryAfter) applies. The getter
block's comment now states the retention decision and their role.

M19 series complete: a (collections + alias deletion), b (column/row
routing + predicates, spec-guarded), c (materialization manifests),
d (width arithmetic relocation, golden-guarded), e (render-path minimal
cut), f (this decision). slick.grid.ts net -422 vs master with the full
3x3 band feature set included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@6pac

6pac commented Jul 20, 2026 •

Copy link
Copy Markdown
Owner Author

This PR has undergone three stages: 1) wrap the viewport/panes in an object, 2) rework individial viewport related objects (eg, ViewportL, ViewportR, HeaderL, HeaderR, HeaderRowL, HeaderRowR) into an array 3) encapsulate all multi pane concerns into the ViewportMgr object and take the slick.grid.ts code back to what it looked like before the frozen panes change.
This is the prompt for part 3, just to make clear what the motivation was.


The primary reason I wanted to do this change was to simplify the code in 'slick.grid.js'. This goal has not been met.

To illustrate what I mean, take the following examples:

SlickGrid-pre-frozen: (in createColumnHeaders())

  $headers.empty();
  $headers.width(getHeadersWidth());

SlickGrid-post-frozen: (in createColumnHeaders())

  $headerL.empty();
  $headerR.empty();

  getHeadersWidth();

  $headerL.width(headersWidthL);
  $headerR.width(headersWidthR);

6pac master: (in createColumnHeaders())

Utils.emptyElement(this._headerL);
Utils.emptyElement(this._headerR);

this.getHeadersWidth();

Utils.width(this._headerL, this.headersWidthL);
Utils.width(this._headerR, this.headersWidthR);

6pac viewportmgr-phase4: (in createColumnHeaders())

Utils.emptyElement(this._headerL);
if (this._headerR) {
  Utils.emptyElement(this._headerR);
}
if (this._viewportMgr.headerRF) {
  Utils.emptyElement(this._viewportMgr.headerRF);
}

this.getHeadersWidth();

Utils.width(this._headerL, this.headersWidthL);
Utils.width(this._headerR, this.headersWidthR);
Utils.width(this._viewportMgr.headerRF, this.headersWidthRF);

The pre-frozen code is simple and says exactly what it does. Then post-frozen, rather that reading the logical flow, you suddenly have to deal with multiple headers etc - and with the LazyPanes option we used the multiple headers may not even be actively used.
The current 6pac code more or less mirrors the post-frozen format, and viewportmgr-phase4 collapses some of the logic into the viewportmgr object, but it doesn't really change it.
What I'd like is for it to read something like:

desired:

  this._viewportMgr.headers.empty();
  this._viewportMgr.headers.width(getHeadersWidth());

This would require wrapping all of the headers and footers into the ViewportMgr object - and that's what I really wanted, a single object that slick.grid.js can manipulate like a single pane that keeps all of the concerns about how many pane/header/footer elements there are and hides them from the grid code.

Another example:

SlickGrid-pre-frozen:

function getFooterRowColumn(columnIdOrIdx) {
  var idx = (typeof columnIdOrIdx === "number" ? columnIdOrIdx : getColumnIndex(columnIdOrIdx));
  var $rtn = $footerRow.children().eq(idx);
  return $rtn && $rtn[0];
}

SlickGrid-post-frozen:

function getFooterRowColumn(columnIdOrIdx) {
  var idx = (typeof columnIdOrIdx === "number" ? columnIdOrIdx : getColumnIndex(columnIdOrIdx));

  var $footerRowTarget;

  if (hasFrozenColumns()) {
    if (idx <= options.frozenColumn) {
      $footerRowTarget = $footerRowL;
    } else {
      $footerRowTarget = $footerRowR;

      idx -= options.frozenColumn + 1;
    }
  } else {
    $footerRowTarget = $footerRowL;
  }

  var $footer = $footerRowTarget.children().eq(idx);
  return $footer && $footer[0];
}

6pac master:

/**
* Get the Footer Row Column DOM element by its column Id or index
* @param {Number|String} columnIdOrIdx - column Id or index
*/
getFooterRowColumn(columnIdOrIdx: number | string) {
  let idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx));
  let footerRowTarget: HTMLDivElement;

  if (this.hasFrozenColumns()) {
    if (idx <= this._options.frozenColumn!) {
      footerRowTarget = this._footerRowL;
    } else {
      footerRowTarget = this._footerRowR;

      idx -= this._options.frozenColumn! + 1;
    }
  } else {
    footerRowTarget = this._footerRowL;
  }

  return footerRowTarget.children[idx] as HTMLDivElement;
}

6pac viewportmgr-phase4:

/** 
 * Get the Footer Row Column DOM element by its column Id or index
 * @param {Number|String} columnIdOrIdx - column Id or index
 */
getFooterRowColumn(columnIdOrIdx: number | string) {
  const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx));
  const footerRowTarget = this._viewportMgr.bandElementForColumn(idx, this._footerRowL, this._footerRowR,  this._viewportMgr.footerRowRF);

 return footerRowTarget.children[this._viewportMgr.bandLocalColumnIdx(idx)] as HTMLDivElement;

}

desired:

/**
 * Get the Footer Row Column DOM element by its column Id or index
 * @param {Number|String} columnIdOrIdx - column Id or index
 */
getFooterRowColumn(columnIdOrIdx: number | string) {
  const idx = (typeof columnIdOrIdx === 'number' ? columnIdOrIdx : this.getColumnIndex(columnIdOrIdx));
  return this._viewportMgr.getColumnChildren(idx);
}

Report on the feasibility of this, and list the proposed new members of ViewportMgr, and before and after code in slick.grid.ts for each use case in that file. It impacts on both the viewportmgr-phase4 and the m16 plan currently in progress.
Also assess if it would be easier to start from scratch and redo the whole viewportmgr-phase4 PR, or to update and replace the m18 work, or to add it on after m18?

@ghiscoding

ghiscoding commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

@6pac so I asked Codex 5.6 Sol (which is the equivalent to Claude Fable 5) to see if I can replicate this PR but to do it in a target of 300-400 LOC (line of code) and it was able to achieve that (instead of 1400 loc which is what you have so far), but...

then I had another idea, what if we implement the same approach as Ag-Grid (column pinning) and/or SyncFusion grid (frozen row/column)? The biggest advantage is that the UI is much more appealing since it only has 1 horizontal scroll and 1 vertical scroll for the entire grid even if it's 3x3 or more, so the UI is much more user friendly I find (versus SlickGrid freezing which has multiple scrollbars with most of them disable but visible for alignments)

So I asked Codex to try to implement the same concept as Ag-Grid, it called that a Docking Controller, this is still a POC but after an evening on it, I have a rough working POC. I like this approach much better and the bonus is that I asked Codex to eventually add sticky in combo with pinning and it would totally be doable without that much more loc addition.

Screencast_20260903_005128-2.webm

I asked the AI to give me an LOC estimate after what I have working so far including the addition of pinning and sticky, the result is shown below (please note that the LOC count includes CSS stylesheet lines)

Scope Rough LOC
Permanent pinning implementation retained after cleanup ~1,100–1,400 gross LOC
Basic sticky columns/rows added on top +150–300 LOC
Grouped quarterly sticky headers added on top +150–300 additional LOC

so in the end, I'm much more interested in this new approach of a Docking Controller with single scrollbar instead of your Viewport Manager (this PR). I've burned almost all my weekly tokens for reasoning and implementing this, so I might only finish it next week, but it's looking good so far... what do you think? Also again a reminder that this would only be released in our next major version because there's obviously some breaking changes on the entire DOM and also the grid options changes and all.

Also compared to Ag-Grid, my POC keeps pinning as column index (pin all columns up to x), but Ag-Grid actually separates each column pinning, meaning that you pin each column independently (you could pin column 1 and 3, but leave 2 unpin which would move it to the right pane since it isn't pinned). I'm not sure if we should use this approach or keep using "pin up to x" approach, Ag-Grid is more flexible but it requires adding { pinned: "left"} for each column being pinned instead of us just using an index, which is better is debatable I guess but we could do probably do both.

cc @zewa666

@zewa666

zewa666 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

jeez this looks mighty as hell. lots of tasks on my table but I definitely need to check this out soon

@6pac

6pac commented Sep 3, 2026 •

Copy link
Copy Markdown
Owner Author

Interesting! What you are proposing sounds better than the SlickGrid frozen columns feature presently, and the sticky grids feature would be awesome.

But go up a few comments and read where I say 'This is the prompt for part 3, just to make clear what the motivation was.' I don't think it fulfils that requirement at all.

So I have to rethink how important that is to me. TBH, it was quite important when I thought I would be hand-coding going forwards, but now that AI is doing all the work, it probably doesn't matter if the codebase is hard to read. And honestly, I think we're nearly there re the feature set. How much more can we implement?

I am quite keen at this point to do the next breaking release with what we have and just clean the slate. Then I think we're well-positioned to run these kind of experiments with much less rebase churn.

@ghiscoding

ghiscoding commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

@6pac I understand your concern but this new concept (which again is follow Ag-Grid approach) is totally different compare to the previous div containers (I see them as independent silos on a farm) but with the new approach it's rather working with divs and rows, it's very different in the DOM (which is totally a breaking change especially for E2E tests, I'm assuming this would break a lot of the Cypress tests and will require lots of refactoring). At this point, I'm only focusing on making it work, I haven't looked that much at the code but your comment above is part of the AI completion task.

I think it's also a good time to get rid of that +/- 1000px offset that I never understood what it does exactly. It might have been an IE fix of some kind, it's time to get rid of these old hacks I think

Anyway, I asked the AI to compare current POC with your point 3 above (I literraly provided your entire comment above) and it replied with:

Codex analysis of your previous comment

The original PR’s principle is exactly right—and the current POC has not reached that maintainable end state yet.

The architectural direction is better: one viewport, one canvas, one horizontal scrollbar, and a pure DockingController for pin/sticky policy. But the current implementation is temporarily harder to read because the old multi-pane model still exists alongside it.

Area Current POC Required final state
DOM setup Creates four panes/canvases, then removes three in activateSingleViewportLayout() Construct only one viewport/canvas/header/header-row/footer from the start
Headers/footers Still empties and sizes L and R, and branches on frozen state in createColumnHeaders() One header container; pinning only changes each cell’s docking class/position
DOM getters getHeaderColumn, getHeaderRowColumn, and getFooterRowColumn still dispatch between frozen panes Direct lookup from the one chrome container, by column index/id
Resize/scroll Large dormant frozen-row/frozen-column branches remain One resize and scroll path, with docking offsets as the only special behavior

The clearest warning sign is [`setFrozenOptions()`](/home/ghislainb/sourcecode/slickgrid-universal/packages/common/src/core/slickGrid.ts): it force-disables the old API, but all of the fields and branches it protects are still present. That is useful for a POC, but it should never be the final form. There are still hundreds of freeze/pane-related references in packages/common.

I would not revive the old ViewportMgr approach. Its purpose was to hide a genuinely multi-pane renderer; this rewrite should delete that renderer. A manager would only conceal legacy complexity rather than remove it.

The desired final code should look close to the pre-frozen examples from the PR:

this._header.replaceChildren();
this._header.style.width = `${this.getHeadersWidth()}px`;

return this._footerRow.children[columnIndex] as HTMLDivElement;

Pinning-specific logic should be confined to:

  • DockingController: calculates left/center/right and top/center/bottom membership.
  • A small docking DOM layer: applies region offsets/classes and handles per-row left/center/right placement.
  • The virtual renderer: renders one row node and its three optional regions.

Everything else should no longer know “left pane”, “right pane”, “top pane”, or “frozen”.

Because this is intentionally breaking, I recommend making this a merge/acceptance condition: no dormant hasFrozenColumns()/hasFrozenRows branches in normal rendering, no _headerL/_headerR or _viewportTopL/_viewportTopR model, and no compatibility disabling like setFrozenOptions(). Rename the surviving singular elements to _header, _headerRow, _footerRow, _viewport, and _canvas.

So: the POC is a good substrate, but merging it before the deletion/consolidation pass would make maintenance worse. The cleanup pass is not optional polish; it is the part that realizes the original PR’s simplification goal.

updated DOM

below you can see that we're dealing more with rows instead of previously entire div container silo, we have 3 possible div container for each row, we have a slick-pinned-left-cells, then slick-scrolling-cells and also slick-pinned-right-cells, if you want to add more, then it's a lot more flexible and possible. Also I think this approach is what makes it easy to add sticky columns/rows.

Screenshot_20260903_133826

@ghiscoding

ghiscoding commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

@6pac @zewa666 there you go, another 200-300 loc maybe and we have sticky columns and rows (in addition to pinning feature). I have 2 separate examples, 1 example for pinning and 1 example for sticky. They are 2 separate features but they share a lot of code together. Can we use both pinning and sticky in the same example? I'm not sure maybe... but I would think that the basic idea is to use pinning or sticky separately.

after having both pinning and features, I asked the AI to give me another LOC count update:

The +1,656 additions / +1,469 net LOC covers the combined POC implementation for:

  • Single-viewport pinning
  • Sticky columns and rows
  • Shared docking resolver
  • Public API/interface changes
  • Required shared SCSS styling

It does not include Example 04, Example 47, tests, or framework demos. It also still includes legacy frozen-pane code that is planned for removal, so the final cleaned net LOC should be lower.

Screencast_20260903_184235.webm

isn't that awesome? Also note that I did not do a code cleanup or review yet, I mean I'm focused on making the POC work. There's most probably lot of cleanup that we could do to decrease LOC count and drop any legacy frozen related stuff (because again this is definitely a breaking change and totally different UI/UX)... and now I'm really close to be out of tokens (less 8% left), so I will probably have to pause for couple days.

@6pac

6pac commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

OK, it sounds like this will also meet my goal. The only issue is the change for those who might be wedded to the way the frozen rows/column feature works already.
As I've said - that's not me, I've never once used the frozen rows feature.

So I'm 100% happy with this, but I think it's probably worth asking some consumers of the frozen feature what they think.

@6pac

6pac commented Sep 4, 2026 •

Copy link
Copy Markdown
Owner Author

Great work by the way, this is exactly what the project needs - new ways of looking at problems!

@ghiscoding

ghiscoding commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

@6pac That's what breaking version (major) really mean and that is also why I want this renamed to pinning rather than freezing. Also nowadays, AI will do most of the work anyway. I mean you could ask @zewa666 but he's probably going to answer the same as me... Ideally, just a global search & replace should do the work but it might depend on the answer for the question below.

What I'm unsure at this point though is as I mentioned above, should we use pinning on

  1. each individual column (that's what Ag-Grid does via { pinned: 'left' } on an individual basis).
    • you could also pin column 1 and 3 but skip column 2 and that would move column 2 to the right (unpin section)
  2. keep only pinning index in the grid options (same as frozen column but with a different option name).
  3. support both via 1 and 3, via column definition and/or grid option.

The sticky part is done on the column basis, so perhaps we should do the same for pinning?

@6pac

6pac commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Sure, don't take this as a criticism. I think this is a good change.

What I'm saying is that a major version usually means 'we've had to break some things to move forward and you'll need to check your code'.
Removing and replacing a major feature is a little more than that. Just not sure of the reaction we'll get. The scenario I'm thinking of (which seems common at the moment) is consumers with Excel-like spreadsheet grids, where they have their users trained to a particular mode of operation. Often, training issues are just as thorny as functional ones.

@6pac

6pac commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

Re the config, I'd tend to favour 1) but again be good to get opinions from people who want to use it, ie. try to think of a use case that will break it.

@ghiscoding

ghiscoding commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

no worries, I did take it as a constructive comment. So on the breaking change side of things, I do typically like to offer some kind of transitions... but in this case I don't see any possible way of doing it because I certainly don't want to support both legacy freezing and this new pinning.

For the implementation choice, I had another thought and I think that going with an hybrid approach is the best possible way to avoid this from being an even bigger breaking change. Let me explain, in Slickgrid-Universal, we also have Grid State & Preset and I did add the freezing columns in there and we do use it on a big project at work. So if we move it to the column instead of the grid option, that would a much bigger breaking change and an hybrid approach (with grid option index) would smoothen the blow I think.

For a survey, we could open one in the Discussion tab, but the problem is that barely anyone would see it, the only other option would be to tag people directly, typically the big users and/or recent contributors. I did that couple times. But I think I need to go further with the POC before doing any such survey. Like I said earlier, I didn't even look much at the code, neither the grid options, and so I need to advance the POC more into an MVP before making any surveying. I'm pretty sure that most user would simply be happy with the breaking change especially considering the better UI/UX... I also have to make sure that we're not decreasing in perf for very large dataset with this new pinning/sticky approach. Once I'm done, I would ask you to make a second view analysis with Claude, having 2 reviews from 2 different AI is even better

@zewa666

zewa666 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

I think the major version would indicate this well enough. yeah its a bit more than a break, its a feature deprecation sort of, but the replacement is subjectively better for me.

what the latest push in AI development made me think of though is that we might should start thinking about shipping curated skills along with the library. that would serve two purposes. first, LLMs would know better how to apply specific features from slickgrid on the consumer end. but secondly, the skills could also act as a verification of the docs and thus overall improve the development of new features as LLMs could check up on skills when touching existing features

@6pac

6pac commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

@zewa666 I agree there should be something along those lines. A comprehensive reference manual, would be a good start.
I'm working on that in the background, but I'm currently doing another manual for another project, so I was leaving this until afterwards to apply lessons learned.

@6pac

6pac commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

@ghiscoding I've had a bit of time to think about this now.

As I said, personally I have no preference around these; I'm fine with what you've proposed above.

But I wonder, there do seem to be a lot of developers using SlickGrid essentially as an Excel replacement. I think this new proposal is different to how Excel behaves? So I do wonder if that will be a problem for them.

Also, is it possible for me to get access to the PR/code branch for this? Or has it been done on SlickGrid-Universal?

It seems a bit crazy, but now I'm wondering if we should offer all three options - the classic frozen rows/columns, the viewport-based frozen rows/columns and the sticky columns. It would definitely increase the codebase size (and complexity) significantly, but a lot of the more complex logic could be spun off to plugins.

I know at least one organisation that's using this for an Excel-like purpose, so I've contacted them and sent some links for comment.

@ghiscoding

ghiscoding commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

@6pac I'm still working on it in Slickgrid-Universal only via PR ghiscoding/slickgrid-universal#2782, once I'm done then I'll ask Codex to replicate the entire thing in here. Should we keep the freezing option, the answer is No... but we can replicate similar behaviour to what freezing was.

So let me explain, the new feature is being renamed to Pinning (the freezing wording is being dropped) because I wanted to start with new naming since it's a full rewrite, but also because it's really different single it's just single column Pinning (it adds pinnable and pinned to column definition, pinned). The freezing definition in current SlickGrid is defined as "freeze from column 0 to X", but with the new implementation, it's a single column Pinning (1 at a time via pinned and not an up to X anymore) and that is a big distinction because it now allows us to Pin at different indexing positions (e.g. if we have Column 1, 2, 3, ... we could Pin 1, 3 and skip 2 and that is now possible). Does that mean the end user will have to Pin 1-by-1? Yes and No, I added a internal function that basically loops through from 0 to X and toggles every column pinned flag.

The other great thing that I love with the new UI is that we now have what it calls Docking and we no longer have separate divs with separate scrollbars, we now 1 horizontal scrollbar and 1 vertical scrollbar with/without Pinning. My main reference was Ag-Grid, Codex implemented the same approach, which I like a lot (single scrollbar is so much nicer and usable)

in the print screen below, we can now have all Pinning sides all at once without any issues

image

It now works correctly with Grouping as well, this will fix the super old issue #443, you can also see below that I pinned the 2nd column which keeps the checkboxes (1st column) to the unpin section, single column pinning is just so much more flexible than before

Screencast_20260910_223012-1.webm

I'll probably be done by early next week and replicate the PR in here. It did a lot of rewrite, lot of variable changes (there's no pane, nor freezing container anymore) and re-implemented pretty much the entire way of showing things. Also as part of my acceptance, I did provide your original goal feedback (making it more readable) to my goals list, so it is being considered.

I do keep a progress file for the AI to stay focus, you can see the current Pinning Progress

Also since I have full unit test suite, codecov currently reports ~750 LOC (that's raw LOC, without comments and without any CSS) and that does include Sticky feature as well. So I'm very satisfied with the end result, this fixes a lot of UX problems I've encountered over the years.

@6pac

6pac commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

Great, thanks for providing the progress update.

@ghiscoding

ghiscoding commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

and I've just fixed the last bug I found, it now works with colspan and pinning too (though it required ~75 LOC to fix), with the small exception that the pinning must be sequential (e.g. works with pinned: [0, 1] but doesn't work unsequential like [0, 2] when some are skipped). So the colspan below looks like it's cell that spans across the pinning boundary, but in reality, it's just an illusion and is really 2 cells that look like 1 large cell and so we just fool the end user for a better UX 😆

Screencast_20260911_013813.webm

also I forgot to provide some code sample to explain what I meant above

gridOptions = {
- frozenColumn: 2,
- frozenRow: 2,
- frozenBottom: false,
+ pinning: { 
+   columns: { 
+     left: 2, // pin from 0 to 2.. or use `[0, 1, 2]` which is equivalent
+      // you can "right" too
+    },
+   rows: {
+     top: [0, 1], // or integer from 0 to X
+     // you can add "bottom" too
+   }
}

or you could just simply define it directly in the column definition via pinned and if you want to block a column then use pinnable: false

columns = [{
  id: 'firstName',
  field: 'firstName',
+ pinned: true,
}, {
  id: 'lastName',
  field: 'lastName',
+ pinnabled: false, // cannot be pinned dynamically
}]

what about the Sticky feature? That one is via column definition for the sticky columns, and via grid options for the sticky rows

gridOptions = {
+ stickyRows: { 
+   both: ['total-revenue', 'total-expenses', 'net-profit'] 
+ },
}

sticky columns

columns = [{
  id: 'firstName',
  field: 'firstName',
+ sticky: 'both', // can be "left", "right" or "both" for which side to stick
}]

that's it, pretty straightforward I think... oh 1 very important thing I forgot to mention the +/- 1000px offset is gone, I assume that was in place for issues with IE6 but we no longer need this and it was causing issues at first, so yeah it's gone!

@6pac

6pac commented Sep 17, 2026

Copy link
Copy Markdown
Owner Author

Closed- superseded by new single-pane approach.

@6pac 6pac closed this Sep 17, 2026
6pac-ai added a commit that referenced this pull request Sep 29, 2026
Merging next-v6 brought in the VitePress site, which still documents the 3x3
frozen band model from #1238. That PR was closed without merging, and this one
removes frozen panes, so:

- docs/in-depth/frozen.md is replaced by docs/in-depth/pinning-sticky.md, the
  page this branch kept at the docs root. It now has the site's front matter, a
  v6 note, the complete list of examples and links into the reference. The
  sidebar, the In-depth index, the home page and four other pages that linked
  to or described frozen panes now point to it or describe pinning.
- The migration guide describes the move from frozen panes to pinning. Its API
  list for this PR is the actual delta against next-v6: 11 options, 2 column
  properties, 4 methods and 5 events added, and 10 options and 4 methods
  removed. validateColumnFreeze() from #1299 is removed again, so that entry is
  updated too.
- The option, event and column-property counts on the introduction pages follow
  the new API (120, 62 and 52).

Two corrections to the page itself: permanent pins are rejected only when they
are wider than the whole grid, because the column budget limits sticky columns
only; and getColumnByIdx(), getColumnHeaderByIndex() and
removeCellCssStylesBatch() are no longer listed as removed, because no 6pac
release had them.

The Docs workflow runs a strict drift gate on every push to next-v6, and it
would have failed on this merge: five new options had no JSDoc and five new
events had no reference note. The options now have JSDoc, and the events have
overlays, as the other 57 events do. The overlay for the removed option
throwWhenFrozenNotAllViewable is deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6pac pushed a commit that referenced this pull request Oct 3, 2026
)

* feat(grid)!: replace frozen panes with pinning and sticky docking

* chore: fix cypress failures

* chore: colspan and grouping should spread left to right over pinning

* chore: part 1 fixes of audit review

* chore: part 2 fixes of audit review

* chore: part 3 fixes of audit review

* chore: part 4 final pass fixes of audit review

* chore: fix cypress failing tests

* chore: fix cypress failing tests

* chore: fix cypress failing tests

* fix(grid): drop DOM references reflectively in destroy(true)

`destroy(true)` nulled a hand-maintained list of 46 property names, so every new element
field (the docking overlay, proxy scroller and chrome regions among them) had to be added
in two places and `dockingChromeByColumn` was missed. The element references are now
cleared by content: any field holding an element, a non-empty array of elements or a plain
record of elements is nulled, and the chrome map is cleared. A self-hosted spec checks
single, array and record fields on a plain and a pinned grid.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(grid): remove slickgrid-universal plumbing that is inert in 6pac

Drop the options, fields and code paths that were carried over from the multi-package
fork and have no implementation here: the formatted-data cache planner and its DataView
hooks (`SlickDataView` implements neither), `enableGridMenu` last-column compensation,
`enableRowDetailView`/`rowDetailView.renderMode`, `silenceWarnings` and the zoom warning,
`selectionOptions` (the selection model's own option is authoritative again),
`datasetIdPropertyName` (the DataView's id property is used), `Column.editorClass`,
`exportCustomFormatter`, `exportWithFormatter`, the unread `Column.pinnable`,
`EditorArguments.isCompositeEditor`, and the `FormattedDataCachePlanner`/`TrustedHTML`
type stubs. `sanitizeHtmlString` is back to its 6pac signature with the
`logSanitizedHtml` logging path. The two functional additions that were kept
(`allowDragFromClosest`, `columnResizingDelay`) are now documented.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): resolve getCellFromPoint through the rendered docking layout

`getCellFromPoint()` on a docking grid used `document.elementFromPoint()` and fell back to
the natural layout when the point was off-screen or not over a cell, so drag auto-scroll and
non-contiguous row pins still resolved the wrong cell. The point is now mapped geometrically:
pinned/sticky rows through the overlay bands, scrolling rows through the inverse of
`getRenderedRowTop()`, and columns through the left/centre/right band offsets at the current
scroll position. RTL and non-docking grids keep the original calculation.

With the end cell resolved correctly, dragging upward from the bottom-right of a pinned grid
auto-scrolls up again, which is what the base spec asserted; the weakened `equal` assertion is
restored to `greaterThan`. A self-hosted spec covers pinned columns and rows while scrolled,
a row shifted by a non-contiguous pin, and a row that is not rendered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): keep every scrolling row reachable above a bottom-pinned band

The previous change removed bottom-pinned rows from the canvas flow by shortening the canvas
by their height. The viewport and the overlay band were unchanged, so at maximum scroll the
last unpinned row (or the add-new row) sat exactly under the band and could not be reached.
The canvas now keeps its full height: rows after a bottom pin are still rendered one pinned
height higher, so the pinned row's slot collapses to the end of the canvas where the band
covers it, and every scrolling row stays visible above it. A self-hosted spec checks a pinned
last row, a pinned last row with `enableAddRow`, and a pinned middle row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): mirror cell CSS class changes onto cross-band colspan fragments

`updateCellCssStylesOnRenderedRows()` only touched the logical host cell, so a colspan that
crosses a docking boundary kept the `selected` (or any custom) class on its continuation
fragment after the host lost it. Added and removed classes are now applied to the host and
to every fragment of the span. The colspan spec selects a fragment, checks both pieces carry
`selected`, then selects another cell and checks both are cleared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): announce chrome cells before docking activation empties them

Enabling pinning on an initialized grid (and removing it again) rebuilt the header,
header-row and footer chrome through the docking region helpers, which emptied the roots
before `createColumnHeaders()`/`createColumnFooter()` could fire `onBeforeHeaderCellDestroy`,
`onBeforeHeaderRowCellDestroy` and `onBeforeFooterRowCellDestroy`, so plugins attached to
those cells never cleaned up. The destroy events are now fired by the region set/reset
helpers themselves right before they empty a root, which covers the initial build, lazy
activation and deactivation from one place; the duplicated loops in the header and footer
builders are removed and the header-row event now passes the cell as `node` as its type
declares. A self-hosted spec counts each event per column across activation and removal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): virtualize the centre cells of docked rows horizontally

`cleanUpAndRenderCells()` only walked the vertical render range, so a pinned row outside it
(typically a pinned last row on a long dataset) never received new centre cells when the grid
scrolled right, and `cleanUpCells()` exempted every pinned row, so docked rows inside the
range accumulated cells without bound. Docked rows are now appended to the rows processed
against the horizontal range and cleaned like any other row; the pinned bands themselves are
still always materialized. A self-hosted spec scrolls a 40-column grid with a pinned first
and last row and checks that both gain the newly visible column and drop the off-screen one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): forward native chrome scrolls as deltas in proxy mode

With the docking horizontal scrollbar, the viewport and the header/header-row/footer
containers stay at scrollLeft 0 while their content is translated by the proxy position. A
native scroll on one of them (the browser revealing a focused filter, an integration
scrolling `.slick-viewport`) is therefore a delta from the current position, but it was
forwarded as an absolute position, jumping the grid back towards the left edge. It is now
added to the proxy position, and `handleElementScroll()` no longer mirrors the reset-to-zero
echo of those containers as an absolute position. A self-hosted spec scrolls the proxy to
400, scrolls the header-row container by 60 and expects 460.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): map column reorder slots to the DOM band of each header

On the sticky transform path an active sticky column is listed in the resolved left/right
band while its header stays in the centre header region. The drop handler mapped the
resolved bands onto the three Sortable arrays, so a docked sticky column produced an
undefined slot and the drop threw while destructuring it. Slots are now derived from the
band each header actually lives in (permanent pins left/right, everything else centre in
column order), and a slot/array length mismatch leaves the order unchanged instead of
throwing. A self-hosted spec docks a sticky column by scrolling, drags one centre header
onto another and checks the resulting column order and the reorder event.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(examples): keep the header-menu demo command wide enough for the sub-menu alignment spec

Renaming the demo command from "Freeze/Pinning" to "Pinning" made the level-0 header menu
13px narrower, which moved the drop-left level-1 menu 14px to the right. The level-2
alignment rule compares `item left + sub-menu width + item width` with the grid width; on
Windows font metrics that sum went from 563 to 577 against a 575px grid, so the spec's
`dropright` expectation failed there while Linux CI stayed green. The command is now
"Column Pinning", the terminology used by the pinning feature, which restores the geometry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: document pinning/sticky docking for this repository and add the frozen-pane migration table

`docs/pinning-sticky.md` now covers the option semantics (visible-column shorthands, index vs
id references, row references), the runtime API, the docking budgets, rendering notes, the
stable selectors, a migration table from the v5 frozen options/methods/classes, and the known
limitations. The pinning skill and the implementation status file are rewritten to describe
this flat repository only: no unit-test layer, no `Column.pinnable`, no fork-only Grid State,
Header Menu or locale claims, and a verification section that lists the actual Cypress
coverage. The row/column reference JSDoc states that numbers are indexes and strings are ids.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): row references by id, null pinning removal, mirrored bottom band

- `pinning.rows` / `stickyRows` entries accept `{ id }` so datasets with numeric ids can pin
  by id; the grid resolves every reference (index, string id, `{ id }`) to a row index before
  calling the docking controller, which now matches indexes only.
- Invalidating rows marks the row docking as stale and the next `render()` re-resolves it, so
  a DataView sort or filter that keeps the row count moves the pins with their rows.
- `setOptions({ pinning: null })` behaves like `pinning: undefined`; `stickyRows: null` clears
  the sticky lists instead of throwing.
- The bottom band mirrors the top band: docked sticky rows sit inside (nearest the centre) and
  permanent rows at the edge; the inner row carries the edge class.
- `docking.minCenterRowCount` budgets permanent rows only, as its JSDoc says.
- `docking.stickyHysteresis` is renamed `stickyActivationBuffer` (it is an activation buffer for
  sticky columns, not stateful hysteresis).

A self-hosted spec covers `{ id }` references across a sort, index references, null/undefined
removal and the bottom band order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): restore 6pac API contracts and make setColumns() report rejection

Restore the public contracts that the pinning rewrite drifted away from:

- applyHtmlCode(target, value, { emptyTarget, skipEmptyReassignment })
  is back to the base signature and is the single HTML-assignment path
  (the module-level applyHtmlToElement helper is removed).
- setTopPanelVisibility() and the other five panel setters take
  (visible, animate) again and slide the panel unless animate is false.
- the internal event dispatcher is trigger() again, not triggerEvent().
- validateAndEnforceOptions() is public again.
- onHeaderKeyDown publishes { event, column } (OnHeaderKeyDownEventArgs).
- the pinned-width check rejects only when the pins exceed the available
  width, not when they exactly fill it.

setColumns() now validates the prospective pinning on a copy before it
mutates the caller's columns or fires onBeforeSetColumns, and returns
false when the request is rejected (true otherwise).

Adds cypress/e2e/quirk-grid-api-contracts.cy.ts covering each contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(grid): drop the fork's keyboard focus routing, keep the base sinks

Remove the header/grid-menu focus routing that the pinning rewrite
brought over from the fork: focusHeaderRowFilter(), focusHeaderMenuOrColumn(),
focusGridMenu(), focusHeaderColumn(), focusGridCell(), the container
Tab handler, the viewport focus listener, the F6 shortcut and the
Shift+Tab-from-first-cell hop. Shift+Tab is navigatePrev() again, the
key handler is handleKeyDown() again, and focus() has no mode argument.

The focus sinks are created inside the grid container with tabIndex 0,
as in the base grid, instead of as tabIndex -1 siblings of the container.
handleClick() no longer treats defaultPrevented as a reason to skip
cell activation; only the Slick event's immediate-propagation flag does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(grid): take the docking hot paths off O(n²) lookups and layout thrash

- applyDockingToColumnChrome() indexes header, header-row and footer cells
  once per pass instead of scanning the whole header for every column,
  batches every measurement before any geometry write (one forced layout
  instead of one per column) and moves the four placement branches into
  placeDockedChromeElement().
- syncDockedRowContainers() skips rows already synchronized against the
  same row-docking revision, band heights, scrollLeft and overlay height.
- applyRowTopOffset() caches the rowspan-host flag on the row cache entry
  (isRowSpanHost()) instead of querying the DOM and scanning metadata on
  every sync.
- applyDockingScrollOffsetToRow() no longer removes an absent inline
  transform on every horizontal scroll.
- getRowDockingRegion() uses the cached cell regions and returns the row
  itself for undocked rows instead of a per-cell querySelector.
- handleMouseWheel() reads the resolved docking flag instead of
  re-normalizing the pinning references on every wheel event.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(grid): one Utils.replaceList() helper for the setOptions() list replacement

setOptions() replaced the sticky and pinned row/column lists with seven
hand-written spread expressions. Utils.replaceList(incoming, current)
now expresses the rule once: a given list is copied, an omitted one keeps
a copy of the current value (or an empty list).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(grid): drop unused public methods and the duplicated top panel

Remove getColumnByIdx() (no callers, returned undefined rather than null),
getColumnHeaderByIndex() (an alias of getColumnByIndex()) and
removeCellCssStylesBatch() (no callers). getTopPanels() returns the one
top panel the single-viewport renderer has instead of the same element
twice; the two examples that appended a filter panel to each side now
append both to that panel.

The header elements keep both `ui-state-default` and `slick-state-default`
in the same order as master, and the stylesheet comments no longer claim
that `slick-state-default` replaced the legacy class.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(grid): condense narrative docking comments to their intent

Thirty comment blocks in slick.grid.ts told the history of a fix (the
measured pixel values, the earlier approach, the proof-of-concept name)
rather than what the code does. Each now states the behaviour or the
constraint in one or two lines; no code changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: restore the assertions the pinning rewrite weakened

Several specs were loosened rather than retargeted at the new scroll owner:

- example-auto-scroll-when-dragging asserted only `lte` for the row
  auto-scroll (so no scrolling also passed) and no longer waited for row
  16 to appear. Both are restored.
- example-auto-header-height lost both container-overflow checks with
  pinning active; both are back, plus the case that only existed for
  frozen rows.
- headers-width-scroll-sync no longer compared the header position with
  the scroll owner. It now asserts the header content is translated by
  exactly -scrollLeft while the header scroller itself stays at 0.
- example-plugin-hybridselectionmodel is back on cy.trigger() instead of
  hand-built MouseEvents.
- example-pinning-columns-and-rows checks one cell value per band in both
  the overlay and the canvas, not just cell counts.
- example-pinning-columns-reorder gains the header-drag auto-scroll case
  that was lost with the frozen reorder spec.

quirk-fractional-height-bottom-render had its precondition inverted so
the quirk no longer had to reproduce. The single scroll owner genuinely
removed the sub-pixel divergence it was built on, so the spec now asserts
that the two limits agree and says why; the render assertions that are
the actual regression guard are unchanged.

cypress/support/commands.ts drops the dead multi-viewport branch from
getCell/getNthCell and the unused getTransformValue helper. force: true
goes from 162 to 150 uses; the ones that remain are commented where the
target is covered by design (an aria-hidden colspan fragment, a docked
sticky column, a stale duplicate node during docked virtualized scroll).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(examples): follow the data with pinned rows, and drop the last frozen names

example-pinning-columns-and-rows hard-coded `bottom: [49999]`. Rows are
pinned by index, so any filter that shortened the data left that
reference past the end of the set and the bottom band silently
disappeared. The example now recomputes both edges whenever the row count
changes, skipping the update when the references are unchanged and
suppressing the column reset so the header-row filter input is not
rebuilt while someone is typing in it. Covered by a new spec case that
filters the grid down to 111 rows and expects the bottom band on the last
one.

example-draggable-header-grouping passed `rows: { left, right }`, which
the row pinning option ignores; it takes top/bottom.

The two `example-quirk-frozen-row-*.html` pages were renamed to
`-pinning-`: their content had already been converted, only the file
names still said frozen. No frozen file names remain under examples/.

Also: the index listed example-pinning-rows as "Pinned Columns & Rows"
though it only pins rows, the `.slick-pane` and `.slick-pane-header`
rules match nothing now that the six-pane layout is gone, and AGENTS.md
said to preserve changes under dist/ when the point of the rule is that
dist/ is disposable build output that must not be hand-written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(examples): keep the dev server's CSP needs out of the CSP example

example-csp-header advertised a Content-Security-Policy carrying
'nonce-browser-sync' and a browser-sync Trusted Types name, and shipped
example-csp-policy.js whose only job was to create that policy. Anyone
copying the example's policy would have granted a nonce to a script that
does not exist in their application.

The example now declares the policy it actually needs. scripts/dev-watch.mjs
adds the two BrowserSync tokens and injects the Trusted Types policy
through a BrowserSync rewrite rule, so they exist only while the dev
server is the one serving the page, and only on pages that declare a CSP.

Verified by serving the page through dev-watch (tokens and policy present)
and through the test server (clean production policy, spec green).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: record the row reference forms, the API restorations and the RTL gap

docs/pinning-sticky.md and the pinning-sticky skill now describe the
three row reference forms (index, string id and the new `{ id }` object
for numeric dataset ids), note that `pinning: null` clears pinning like
`undefined`, and state that index references must be recomputed when a
filter changes the row count.

The option table uses `stickyActivationBuffer`, the bottom band's
nesting order is documented alongside the top band's, and the migration
section covers `setColumns()` returning a boolean, the
`OnHeaderKeyDownEventArgs` shape, the three removed methods and the
unchanged `applyHtmlCode` / `trigger` / `set*Visibility` signatures.

RTL is promoted from "not exercised by the RTL tests" to a measured
limitation: an RTL grid builds the docking scrollbar but uses the
non-proxy geometry, which places a right-pinned column outside the
viewport, and the docked hit-test path is skipped for RTL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: rename the last frozen-named spec to match the example it drives

example-variable-row-height-frozen.cy.ts visits
example-variable-row-height-pinning.html and its suite is already named
for pinned columns and rows; only the file name still said frozen. No
frozen file names remain outside the migration documentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(grid): clip a cross-band colspan instead of painting it over the next band

A colspan starting in a pinned band was rendered as one host cell stretched
to the full span width, given `overflow: visible` and `z-index: 21`. The host
sits in the sticky pinned band, so it stayed put while the centre band
scrolled and covered whatever passed beneath it: with a four-column span the
Owner, Effort Driven and Region cells of that row were invisible at any
non-zero scroll position, while the same cells were readable in every
neighbouring row.

Each piece of the span is now clipped to its own band. The host renders the
part of the content that belongs to its band, and each continuation carries a
presentational copy of the host's content shifted left by what the earlier
bands already showed, so the text reads continuously across the boundary
instead of restarting or being elided. The copy is `aria-hidden` and the host
keeps the role, the value and the event wiring, so selection, navigation and
formatters are unchanged.

example-colspan.cy.ts asserted the old behaviour directly (the host's right
edge had to lie beyond its band). It now asserts the host is clipped to the
band, the continuation starts at the host's edge, its copy is aligned with the
host's text, and a scrolling cell stays the topmost element under the pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(grid): follow a docked centre column that is resized past the viewport edge

Dragging a centre column's resize handle past the right edge grew the column
to about a viewport width and then stopped: the column froze, the grid never
scrolled, and the handle sat outside the visible area with no way to continue.

A centre column's cached coordinates are relative to the centre band, but the
test that decides whether to scroll compared them against the whole scroll
owner's clientWidth. The pinned bands' width therefore acted as dead room in
which the column could grow past the edge without the grid following it. Since
nothing scrolled, the auto-scroll interval's target never moved either, so each
tick re-applied the same width and the drag stalled.

The comparison now uses the visible width of the centre band. Measured on the
pinning example, the scroll owner advances 0, 62, 254, 434, 590, 746 over two
seconds while the column grows 80 to 993, and the column's trailing edge stays
at the viewport edge throughout.

Adds the resize auto-scroll case to example-pinning-columns-reorder.cy.ts; it
was the one case from the deleted frozen reorder spec that could not be ported
while this was broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: stop forcing clicks that the runner's own pre-click scroll was breaking

The spreadsheet and colspan specs forced their clicks past the actionability
check, and the spreadsheet spec's helper blamed a duplicate cell node left by
docked virtualized rendering. There is no duplicate: sampling the DOM once per
animation frame across the scroll never finds more than one node for the cell,
and the node is the topmost element at its own centre.

The runner scrolls a subject into view before clicking it. On a virtualized
grid that scroll re-renders the row, detaching the element the test just
resolved, and the live node that replaces it is then reported as "covering"
the detached one, which is why both elements in the error looked identical.
Passing scrollBehavior: false to the click, on a cell that is already in view,
removes the cause instead of ignoring the symptom.

The colspan fragment clicks no longer need forcing either, now that a span is
clipped to its band rather than rendered on top of the next one.

force: true across the suite goes from 150 to 141. The one use left in
example-sticky-financial-report is genuine: a right-docked sticky column really
does cover the natural cell beneath it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(grid): correct four docking geometry measurements

- internalScrollColumnIntoView() measured the scroll owner's border box and
  then subtracted the vertical scrollbar again. In proxy mode the docking
  scrollbar is already sized to the inner width (measured 583 against a 598
  container with a 15px gutter), so the usable width came out 15px short and
  the grid scrolled when it did not need to. It now reads clientWidth, which
  excludes the scrollbar in both the proxy and the native scroll-owner modes.
- viewportHasHScroll used `canvasWidth >= viewportW - scrollbarWidth` while the
  docking scrollbar decides its own visibility with `contentWidth > clientWidth`.
  Content that exactly filled the viewport therefore had room reserved for a
  scrollbar the proxy never showed. Both now make the same test.
- getRightDockedChromeLeft() subtracted two getBoundingClientRect() values,
  which are screen pixels, from terms that are layout pixels; a CSS scale on any
  ancestor skewed the right-pinned chrome. The measured distance is converted
  back to layout pixels, which is identity for an unscaled grid.
- validateColspanPinningSequence() only inspected rendered rows, so a colspan
  that a non-sequential pinning would split went unnoticed until it scrolled
  into view. It now scans every row that can carry metadata, stopping at the
  first match. Only a non-sequential request reaches that scan, and a data
  provider that exposes no length still falls back to the rendered rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(grid): publish the docking scroll offset once instead of per element

Every horizontal scroll wrote --slick-docking-scroll-left onto both cell
regions of every cached docked row, every active sticky cell, every full-width
group cell and every pinned chrome element. All of those writes carried the
same value, and custom properties inherit, so one write on the grid container
reaches all of them. On a grid with 30 docked rows and a few pinned columns
that is roughly 70 style writes per scroll event replaced by one.

The property is refreshed by the proxy scroll pass and whenever the docking
scrollbar is resized, so it is current before the first paint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(grid): measure a chrome cell's box once per class signature

The docking chrome pass called getComputedStyle() twice per column to read the
padding and borders of the header-row and footer cells. Those come from the
cell's classes, not from its column, so cells that look alike share one
measurement; the pass now memoises it by class signature. No stylesheet rule
selects a chrome cell by position, so the signature is a safe key.

This is a reduction in style queries rather than a measured speedup: on a
211-column grid the pass times between 16 and 33ms across runs, which is too
noisy to attribute a difference to. The pass is left running on every resize
mousemove deliberately, because the pinned chrome has to track the column
width while the drag is in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(grid): drop the colspan host fallback and let auto header height reach the docking root

updateRenderedColspanFragmentGeometry() searched a row's DOM for the span host
whenever the cell map had no entry for it. The map is only incomplete while a
row's render queue is still pending, so the row's queue is drained first and
the host is read from the map, which is what the fallback was standing in for.

The auto header height rule sized .slick-header-columns-left and -right. Inside
a docking chrome root both are display: contents and have no box, so the height
went nowhere; it now also targets .slick-header-columns-root, which is the real
element there. Plain grids are unaffected, since the left wrapper is a real box
for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(examples): survive a column-picker hide from the pre-header

Porting the pre-header column-picker case from the deleted frozen spec found a
crash in the example itself: renderHeaderGroups() and syncPinnedGroupHeaders()
both read getComputedStyle(grid.getHeaderColumn(0)), and that header is briefly
absent while the columns are rebuilt. Hiding any column from the pre-header
picker therefore threw. Both now fall back to the default background.

The restored case also covers what the frozen spec asserted and the pinning one
did not: the picker names each column by its group, and hiding the first pinned
column leaves the remaining pinned columns consistent. Because columns.left is
an inclusive boundary over the visible columns, hiding the first one moves
Start into the pinned band, which the case now pins down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(grid): draw a cross-band colspan as one cell at the boundary

A colspan that crosses a docking boundary renders as a host plus a continuation,
and both are cells, so both drew an edge where the two meet. The active-cell
outline showed it in every theme: the rule that drops a shared edge matched only
continuations, and the host is not one, so the host drew its trailing edge down
the middle of the span. The theme's own column separator showed it wherever a
theme draws one; every shipped colspan example uses alpine, which draws none.

The piece whose right edge is shared with another piece of the same span no
longer paints that separator, and the active outline drops the shared edge on
any piece that is not the last. Both are direction-aware: the shared edge is the
following piece's in a left-to-right grid and the preceding piece's in a
right-to-left one.

Measured on example-colspan with the stock separator restored, the host's
border-right goes from 1px dotted silver to 1px dotted transparent and its
active outline's trailing edge from 1px to 0, while the pieces stay where they
were (host 102..202, continuation 202..402). The colour is dropped rather than
the width so the geometry does not move.

A click on either half already reported one cell; the continuation is cloned
from the host and carries its column classes. getCellFromPoint still resolves
to the column under the pointer, which is how it behaves for any colspan,
pinned or not.

The existing resize case asserted the seam as correct and is corrected here.

Reported by @ghiscoding on #1302.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(grid): drop the pre-proxy horizontal scroll path

Docking has one horizontal scroll owner: the proxy scrollbar. The methods that
positioned pinned rows and chrome by writing a transform per scroll event are
from the design that preceded it, and each begins by returning when the proxy
scrollbar exists.

It always exists when it matters. Configured docking and the proxy scrollbar are
introduced and removed together at three sites: activateSingleViewportLayout()
on init, the lazy activation in setColumns(), and the teardown in setOptions().
So whenever a docked row or a docked chrome element exists the guard returns,
and with no docking configured the loops iterate over an empty band list and a
cache with no docked rows.

Measured before removing: instrumenting the three methods to count only the
invocations that would do work, then calling applyDockingScrollOffsets()
directly in 25 states across five examples - as loaded, scrolled, pinning added,
pinning removed, pinning re-added - the invariant held in every one and the work
count was zero in every one, including states with 44 docked rows and three
bands. quirk-docking-scroll-owner.cy.ts now guards that invariant.

applyDockingScrollOffsetToRow() goes entirely rather than keeping its proxy
half. That half cleared inline transforms on the cell regions and the removed
tail was their only writer; the proxy stylesheet sets those transforms with
!important, so an inline value never applied.

This also leaves one copy of the chrome natural/docked offset geometry, which
until now was written out in both placeDockedChromeElement() and
applyDockingChromeScrollOffsets().

Suite: 78 specs, 731 passing, 0 failing, 1 pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(grid): raise the pinning rejection alert from one place

Three validation sites wrote out the same alert-once block, differing only in
which callback and message they used: alert when the caller forces it or the
grid has not alerted yet, latch the flag, return false. rejectPinning() does
that once and returns false, so each site is a single return.

The file is the same length either way - the helper costs what the three sites
give back - but the rule that a validation running on every render alerts only
the first time now lives in one place rather than three.

Suite: 78 specs, 731 passing, 0 failing, 1 pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(grid): assert the docking invariant the single scroll path actually needs

The spec asserted that configured docking and the proxy scrollbar are always in
step. They are not: setOptions() with suppressColumnSet skips the lazy creation
in setColumns(), so pinning can be configured and resolved into bands with no
scrollbar. Reported in the audit on #1302 and reproduced.

Nothing is rendered as docked in that state, which is the property the single
scroll path relies on, so that is what the spec now asserts: without the scroll
owner there are no docked rows, no pinned header classes, and no band entry
resolving to a chrome element. Measured in that state: 3 bands, an empty chrome
map, no docked rows, and no work for a scroll to do.

Covers the suppressColumnSet state directly, including a horizontal scroll while
half-configured and the recovery once setColumns() runs.

The chrome lookup cache is deliberately excluded from the assertion: it still
holds elements from an earlier docking after pinning is removed, which no band
resolves to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(grid): drop three redundancies found by the LOC audit

From the audit posted to #1302. Each was confirmed before being applied; the
candidates it raised that did not hold up are not here.

The rendered-row scan in applyDockingProxyScrollOffsets() wrote an inline
transform onto each full-width group cell. _slick-docking.scss already applies
the identical translate3d to `.slick-row-docked > .slick-cell-full-width-group`
from the custom property the method publishes one line earlier. The CSS needs
the cell to be a direct child of its row, which it always is:
getRowDockingRegion() returns the row itself for a full-width group row, so such
a cell is never placed inside a band wrapper. The rest of the method stays - the
permanent-column header loop matters when a column is both pinned and sticky.

normalizeColumnPinningReferences() built its visible-column list with a reduce
that is character-for-character getVisibleColumnIndexes(). Its empty-list early
return went with it: with no visible columns, `count` is Math.min(requested, 0),
which is 0 on both sides, so the existing `count === 0` guard already returned.

The pinned-edge and sticky-edge separators repeated the same five pseudo-element
declarations, and the two left rules carried an identical box-shadow, as did the
two right ones. Grouped, keeping left before right so cascade order is unchanged.
Verified on the built CSS rather than the source: all 12 separator selectors
across the three stylesheets still carry the same six declarations, every left
selector keeps `inset -1px 0 0` and every right one `inset 1px 0 0`.

Suite: 78 specs, 732 passing, 0 failing, 1 pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(core): reconcile two-sided sticky columns in one pass

The last candidate from the audit on #1302. A column configured sticky on both
edges is a candidate for each band, and the reconciliation kept two maps, then
removed the loser from whichever list with findIndex() + splice() - a scan and a
shift per contested column. It now builds one map of the trailing candidates and
filters the leading ones against it, deleting from the map when the leading edge
wins, so the step is linear.

Behaviour is unchanged, including the tie: a column equidistant from both edges
still goes to the leading band. Both lists keep their order, filter() preserving
the leading one and Map iteration the trailing one.

Verified by comparison rather than by the suite, because sticky: 'both' is the
case being changed and no shipped example configures one. The built resolver was
run over 4,000 deterministic pseudo-random cases before and after - 3,827 of them
containing a two-sided sticky column - comparing full band membership, every
offset and width, and the revision counter, with each case resolved twice so the
counter's change detection is exercised. Identical throughout.

Suite: 78 specs, 731 passing, 1 failing, 1 pending; the failure is the timing
test in example-0032-row-span-many-columns, which passes on its own (46/46) and
exercises a grid with no pinning or sticky columns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(grid): stop the proxy scrollbar painting over the last row after a resize

Reported by @ghiscoding on #1302 as a Chrome note beside the Firefox report; it
reproduces in both.

The proxy track is a sibling of the viewport rather than part of it, so the
viewport has to give up its height when the track appears. Only resizeCanvas()
recomputes that, and a column-resize drag never reaches it: updateCanvasWidth()
gave the track its height and left the viewport at its old height, so the track
took its strip out of the viewport's area and painted over the last data row.
It did not correct on mouseup either, so the artefact stayed for the session.
Driving the same transition through setColumns() was always fine, which is why
nothing caught it.

updateCanvasWidth() now resizes the canvas when that reservation changes, in
either direction. The reservation is recorded by
updateDockingHorizontalScrollerDimensions(), which already computes it, so the
check costs no extra layout read on a path that runs per mousemove, and
resizeCanvas() runs only on the transition. It is guarded against re-entry
because resizeCanvas() reaches updateCanvasWidth() again through
updateRowCount().

Measured on example-pinning-columns-and-rows, dragging a header from no
horizontal overflow to well past the viewport:

  before  mid-drag  viewport h=281  track 429.2..444.2  overlapping by 15px
          mouseup   viewport h=281  track 429.2..444.2  still overlapping
  after   mid-drag  viewport h=266  track 429.2..444.2  flush
          mouseup   viewport h=266  track 429.2..444.2  flush
          narrowed  viewport h=281  track h=0

Firefox 151 behaves the same with its 17px scrollbar (281 -> 264), so the fix
follows the measured scrollbar rather than a constant.

The new spec asserts the mid-drag state, not just the end state, and the reverse
transition, so the height cannot be fixed by simply never giving it back.

Suite: 79 specs, 733 passing, 0 failing, 1 pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(grid): reveal Firefox/Linux scrollbar beside docked rows

Firefox on Linux can report a zero-width overlay scrollbar even though the
visible scrollbar occupies a narrow strip. The docked-row overlay then clips
to the viewport edge and paints over the scrollbar in pinned sections.

Add an 8px clip inset only for Firefox/Linux when a vertical scrollbar is
present and its measured width is zero. Keep the existing geometry for other
browsers and for scrollbars with a measurable width.

* feat(grid): support pinned and sticky columns in right-to-left grids

A right-to-left grid created the docking scrollbar but took the non-proxy
geometry path, and the two disagreed. Measured on a 598px viewport, the
trailing pinned column rendered at -859..-779 and an activated sticky column
at -179..-99, both entirely outside the viewport; a pointer position resolved
to the column mirrored across the grid; and the pinned-row overlay showed a
different horizontal slice from the rows beneath it.

The docking geometry now works along the inline axis, which runs from the
leading edge in both reading directions, and converts to physical pixels only
where a style needs one:

- `usesStickyColumnTransformPath()` no longer excludes RTL, so both directions
  share the compositor path, and `--slick-docking-direction` carries the sign
  to the stylesheet.
- The offsets that move a band to the trailing edge change sign with the
  direction, as does the sticky column's own offset and the viewport width the
  trailing sticky transform adds.
- Chrome is positioned through `setInlinePosition()`, and the trailing band's
  start is measured from the scroller's leading edge in either direction.
- `resolveColumns()` receives the scroll position as an inline distance, since
  a right-to-left browser reports `scrollLeft` as a negative physical offset,
  and the leading band is `left` in both directions, so `sticky: true` docks at
  the edge the column actually reaches.
- The docked-row overlay is anchored on the inline start rather than by an
  explicit `left`, and its clip keeps the window measured from the leading
  edge. This is what left the pinned row showing the wrong columns.
- `getCellFromPoint()` maps x onto the inline axis, so a point resolves to the
  column drawn there. It also matches the centre band against the natural
  offsets the canvas renders, rather than the compacted ones, which was wrong
  in both directions whenever a sticky column was active.

Adds `examples/example-pinning-rtl.html` and a spec covering the edges, the
sticky transition, the pinned row's alignment and the pointer round trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(grid): cover a cross-band colspan in RTL and pin the direction to creation

The RTL example now carries a colspan that starts in the leading pinned band and
continues into the scrolling one, which is the case the colspan seam fix handles
in both reading directions and which nothing exercised: no shipped example had
both RTL and a span crossing a boundary. The new case asserts that the two
halves meet at the boundary, that the shared edge belongs to the continuation
rather than the host when the grid reads right to left, and that a click on
either half reports the same single cell.

setOptions() now drops an rtl change that differs from the current value, and
warns. The direction is applied once, by applyRTL() during initialisation, and
the docking geometry reads the option on every measurement; accepting a later
change left the option and the rendered direction disagreeing, with the
container keeping its old class and dir attribute.

Suite: 79 specs, 739 passing, 0 failing, 1 pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(grid): render docked rows beneath Firefox scrollbar

Move the docked-row overlay inside the vertical viewport as a zero-height sticky layer. This lets Firefox paint its native scrollbar above pinned rows, so the scrollbar spans the pinned sections without clipping their right edge.

Remove the Firefox-specific scrollbar-width inset and clip-path workaround. Use the viewport’s height for docked-row synchronization and bottom-row positioning, since the overlay itself no longer has height.

Co-authored-by: ChatGPT <noreply@openai.com>

* refactor(grid): place sticky and centre chrome through one branch

Audit candidate 2 from #1302, deferred until the RTL work merged because that
rewrote the method. The sticky-transform and centre branches of
placeDockedChromeElement() wrote the same five things - the chrome-offset
property, position, both inline edges, order and transform - and differed only
in which offset they used, whether a non-header element is absolutely
positioned, and whether the sticky transform follows. One branch now covers
both, with those three differences as expressions.

The saving is larger than the audit's estimate of 8 because the RTL rewrite
made each branch's setInlinePosition() call four lines long.

Verified outside the browser: both versions were run against a recording fake
element across 3,744 input combinations (header or not, sticky transform or
not, every band, pinned or not, transform path or not, both directions,
missing offsets and widths). They take the same branch in every combination
and make the same style writes and helper calls in the same order. The harness
caught all three bugs deliberately planted to check that it could.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(grid): never render docked rows without their scroll owner

The proxy scrollbar is what positions docked rows; nothing else does since the
pre-proxy scroll path was removed. Docking can still be configured before it is
applied - setOptions() with suppressColumnSet leaves the scroll owner to
setColumns(), the state the ChatGPT audit on #1302 found - and in that state
usesDockingRowRegions() already reported true, because it only checked that
docking was configured.

So the next render built docked rows with nothing to position them. Measured on
example1-simple after setOptions({ pinning: { columns: { left: 1 } } }, false,
true): 0 docked rows, then 42 after a vertical scroll rendered new rows, with no
scrollbar in the grid. The earlier invariant spec missed it because it never
re-rendered rows in that state.

usesDockingRowRegions() now also requires the scroll owner, so rows stay plain
until setColumns() applies the docking. One property read, so the per-cell
cost that made the flag a cached value is unchanged; the scroller is created
before the first render at initialisation, so initial rows still dock.

The spec now scrolls vertically in that state. Without this change it fails
with 42 docked rows against 0; with it, 4 of 4 on three runs.

This also narrows a claim made for the removal of the pre-proxy scroll path:
that proof measured the half-configured state before any re-render, when no
rows were docked. After a re-render they were, and the removed code would have
positioned them. With this change no docked row can exist without its owner,
so there is nothing for it to position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(grid): remove the sticky reparenting renderer

updateRenderedCellDocking() and the fallback in enqueueStickyColumnLayout() that
called it re-homed rendered cells between row regions when a sticky column
crossed an edge without the transform path. The ChatGPT audit on #1302 deferred
their removal because RTL was routed through that fallback; since the RTL work
merged, usesStickyColumnTransformPath() is simply "the proxy scroller exists",
so RTL takes the transform path like everything else.

What still reached the fallback was a sticky column crossing an edge with no
scroll owner: sticky set on the column definitions without setColumns(), or the
suppressColumnSet state. It was worse than unused there. Measured on
example1-simple with a sticky column and no scroller, a horizontal scroll past
the column ran it once; the reparent found no docked rows and returned false,
so the fallback's full re-render built 39 docked rows with nothing to position
them. setColumns() recovered them afterwards.

The sticky layout now returns before resolving anything when there is no scroll
owner. It has to return before refreshDockingLayout(), not after, because that
call switches row regions on. Docking is then applied by setColumns(), as it
already is for pinning in the same state. Measured again after the change: 0
docked rows until setColumns(), then 38. The helpers the removed code called
all have other callers.

A new case in the invariant spec crosses a sticky column over the edge before
setColumns() has run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(grid): assert the sticky docked-row overlay, not the clipped one

4c0f178c moved the docked-row overlay inside the viewport as a zero-height
sticky layer, so Firefox paints its scrollbar above the pinned rows. Two specs
still described the overlay it replaced and failed in CI on that commit.

example-auto-header-height expected the overlay to be as tall as the viewport
and to carry an inset() clip-path - exactly the two properties the redesign
removes on purpose. It now asserts what the new design guarantees: the overlay
is a sticky child of the viewport, aligned with its top and leading edge, takes
no layout height, needs no clip-path, and relies on the viewport's hidden
horizontal overflow; the first pinned row starts at the top of the viewport.

example-auto-scroll-when-dragging looked for .slick-group:visible after the
auto-scroll. The pinned group row is drawn inside the viewport (y 83..108 in a
viewport of 83..384) but Cypress's own visibility check, which replaces
jQuery's :visible, reports anything inside a zero-height container as hidden.
It now compares each group row's box with the viewport's box.

Worth knowing for later specs: Cypress will treat pinned-row content as hidden
from now on, so be.visible and :visible on it need a geometric check instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(grid): keep docked sticky cells opaque on unstriped rows

Reported by @ghiscoding on #1302 as an RTL bug: past the first sticky column,
the text of the columns scrolling beneath it showed through, until the row was
hovered. It is not specific to right-to-left grids.

A docked sticky cell floats over the cells scrolling beneath it and takes its
background from its row, through the row's docking region - `background:
inherit` at every step. The alpine theme stripes only odd rows, so an even row
had no colour of its own: measured on example-pinning-rtl with two cells beneath
each sticky cell, the sticky cell, its region and its row were all
rgba(0, 0, 0, 0) on even rows and rgb(251, 251, 251) on odd ones. Hover gives
every row a colour, which is why hovering hid the problem. The LTR sticky
example masks it with its own sticky-cell colour; its even rows are just as
transparent underneath.

Docked rows now have an opaque default background, with the fallback chain the
pinned rows already use. It is set on the row rather than on the scrolling
region on purpose: an opaque region would paint over the row's own colour, and
with it the theme's striping and hover, the highlight animation, the active-row
state and any row class. As a default on the row, all of those still set the
row's own background and win, and the sticky cell inherits whatever the row
ends up with.

The new case in example-pinning-rtl fails without this change (row 4:
rgba(0, 0, 0, 0)) and passes with it. example-sticky-financial-report and the
pinned-rows examples still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(grid): mask scrollbar strip in docked headers

Prevent transformed header content from appearing above the vertical scrollbar when pinning or sticky columns are enabled.
The grid now measures the body scrollbar width and overlays that strip across the affected header containers. Added regression coverage and documentation.

* fix(grid): align sticky columns during resize

Synchronize body-cell geometry and sticky transforms while resizing columns so header and cell positions remain aligned without requiring a scroll event.

Preserve Grid Menu scrollbar-cover behavior and add regression coverage for sticky column resizing.

* fix(grid): keep the grid menu button above the header scrollbar cover

The cover from baabece4 hides the strip above the vertical scrollbar at
z-index 30. The grid menu button sits in that same corner on purpose, at
z-index 9, so in example-pinning-columns-and-column-group it was painted under
the cover: still clickable, because the cover lets clicks through, but
invisible. Docked grids now lift the button to z-index 31.

The new spec checks what is painted, not only the cover's styles. The cover
lets clicks through and elementFromPoint skips anything that does, so the spec
makes pseudo-elements hit-testable while it samples. It sweeps the whole strip
at 21 scroll positions: left to right with sticky columns, left to right with
the header row, right to left, and with the classic stylesheet, where the
header is positioned and the cover anchors to the header instead of the header
root. It also reports a cover that is see-through.

The four sweeps pass on baabece4, and fail without a cover (sep, oct, nov,
dec) or with a transparent one. The grid menu case fails on baabece4 and
passes with this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(grid): draw pinning separators on the inner edge in right-to-left grids

Reported by @ghiscoding on #1302: in a right-to-left grid the separator of a
pinned band was on the wrong side of its edge column, so the band looked one
column off.

Every docking separator is an inset box-shadow with a physical offset: -1px
draws it on the right edge and +1px on the left, which is the inner edge only
when reading left to right. The offsets are now multiplied by
--slick-docking-direction, which the grid already sets to 1 or -1, so the
separator faces the scrolling columns in both directions. Left to right, the
computed values do not change.

A docked sticky column becomes the band edge, and its header cell gets
slick-column-sticky-left-edge or -right-edge. No rule styled those classes, so
the header lost its separator when a sticky column docked, in both directions.
They now share the header rules of the pinned edges.

The new case in example-pinning-rtl.cy.ts checks that every separator faces the
scrolling columns, before and after the sticky column docks. It fails on
7d9555c8 and passes with this change. example-sticky-financial-report.cy.ts
also checks the header separator at the initial viewport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: add few missing example links

* chore(tests): remove invalid test

* docs: replace the frozen-pane pages with pinning and sticky docking

Merging next-v6 brought in the VitePress site, which still documents the 3x3
frozen band model from #1238. That PR was closed without merging, and this one
removes frozen panes, so:

- docs/in-depth/frozen.md is replaced by docs/in-depth/pinning-sticky.md, the
  page this branch kept at the docs root. It now has the site's front matter, a
  v6 note, the complete list of examples and links into the reference. The
  sidebar, the In-depth index, the home page and four other pages that linked
  to or described frozen panes now point to it or describe pinning.
- The migration guide describes the move from frozen panes to pinning. Its API
  list for this PR is the actual delta against next-v6: 11 options, 2 column
  properties, 4 methods and 5 events added, and 10 options and 4 methods
  removed. validateColumnFreeze() from #1299 is removed again, so that entry is
  updated too.
- The option, event and column-property counts on the introduction pages follow
  the new API (120, 62 and 52).

Two corrections to the page itself: permanent pins are rejected only when they
are wider than the whole grid, because the column budget limits sticky columns
only; and getColumnByIdx(), getColumnHeaderByIndex() and
removeCellCssStylesBatch() are no longer listed as removed, because no 6pac
release had them.

The Docs workflow runs a strict drift gate on every push to next-v6, and it
would have failed on this merge: five new options had no JSDoc and five new
events had no reference note. The options now have JSDoc, and the events have
overlays, as the other 57 events do. The overlay for the removed option
throwWhenFrozenNotAllViewable is deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(grid): keep the pinned band's header separator when a sticky column docks

In the body, the last permanently pinned cell always draws the band's
separator, and a docked sticky cell draws its own further in. The header
instead moved its one separator from the pinned column to the sticky column,
so with a sticky column docked next to permanent pins the body showed two
lines and the header one.

The header's pinned edge is now the last permanently pinned column of each
band, as in the body. It no longer changes while scrolling, so
updateStickyColumnTransforms() no longer needs the loop that moved it.

The new case in example-pinning-rtl.cy.ts checks that every header separator
lines up with a body separator, before and after the sticky column docks. It
fails without this change: 2 header separators against 3 in the body.

The far-right test in example-sticky-financial-report.cy.ts asserted the state
from before the scroll. Q3, Q4 and YTD are sticky at scroll 0, and cy.scrollTo()
may not deliver a scroll event before the assertions run. The test now checks
the far-right state, which is false before the scroll: Q3, Q4 and YTD are
released, and Q2 is the edge of the left band, with its separator in the header
and in the body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(grid): keep a pinned column in its band whatever its sticky flag says

A permanently pinned column that also carried `sticky` was routed onto the
sticky transform path by every site that read the column flag alone, while
the docking layout kept it in its permanent band. Its header stayed docked
only through the per-scroll inline transform, its filter cell was placed at
the sticky offset, and its body cells rendered in the scrolling region and
scrolled away. One predicate, isStickyTransformColumn(), now decides the
path for cells, chrome, the region sync and the sticky transforms: a pinned
column is never a sticky candidate. The new spec fails on the previous
commit for the boundary form, column-level pins and setColumnPinning()
alike.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(grid): remove dead scroll-time transform writes and a quadratic hidden-column scan

Follows the audit posted on #1302. applyDockingProxyScrollOffsets() wrote an
inline transform to every permanently pinned header, filter and footer cell
on each horizontal scroll; the proxy-mode stylesheet rules already set those
transforms from --slick-docking-scroll-left with !important, so the writes
had no effect. scrollToX() now publishes the variable alone.
updateColumnPositionCaches() rescanned a hidden block once per hidden column
(25 million reads of `hidden` for 5,000 columns, 197 ms on every step of a
column-resize drag); each block is resolved once. The class-keyed
measurement cache in applyDockingToColumnChrome() never hit, because filter
and footer cells carry per-column l/r classes, and a working one saved
nothing measurable, so it is gone. paneBottomH and viewportBottomH were
frozen-pane remnants with no readers. appendColspanFragments() passes the
row's cached regions. The column-group examples skip their group-header
sync when scrollLeft is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): align the pinning option contracts with Slickgrid-Universal

Follows the comparison ghiscoding posted on #1302. Pins whose widths add up
to the available width are rejected, since they leave the centre band no
width. A rejected setColumns() no longer records the request's columns in
the saved pinning state, where a flag carried only by the rejected request
could later come back as a column's original pin. setColumnPinning()
records every reference as a column id, so the pins follow their columns
through a reorder and the changed column can be unpinned afterwards.
syncDockingChromeRegions() appends chrome elements only when their order
differs from the sorted one. The scrollLeft clamp, two early-return guards
and one statement order are aligned so the methods read the same in both
projects.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): re-place docked chrome on resize and apply rtl before the first layout

resizeCanvas() re-applied the docked chrome geometry only when the docking
layout changed, so after the container changed width the right-pinned
header, filter and footer cells stayed at the old edge while the body
moved. It now re-applies it on every resize, as Universal does. With
implicit initialisation the headers were built before applyRTL() set the
container's own direction, so an rtl grid on a left-to-right page laid its
trailing band out at the wrong edge; the direction is applied first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(grid): measure docked chrome against the body cells in the contracts spec

The resize and rtl cases compared the docked chrome with the viewport's
outer box, which includes the vertical scrollbar, so a correctly placed
band read as one scrollbar width off on Linux. The cases now compare the
chrome with the band's body cells and only check that the band itself lies
within the scrollbar of the viewport edge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(grid): keep pinned band geometry after restoring pinning and while resizing

Ported from Slickgrid-Universal (ghiscoding/slickgrid-universal#2782,
c4112428 and a490c253):

- applyColumnWidths() and the colspan-fragment offsets size pinned bands
  from the rendered band widths, so restoring permanent pinning while a
  sticky column is docked no longer collapses the leading pinned cell
- applyDockingToColumnChrome() clears inactive native chrome scroll
  offsets before measuring pinned header placement
- updateCanvasWidth() re-applies column widths, chrome and row band sizes
  whenever the grid has docked columns, so a resize drag that leaves the
  canvas width unchanged keeps filter, footer, cells and row bands in step

Adds quirk-pinning-restore-and-resize-geometry.cy.ts (LTR and RTL), which
fails 6/6 without the fix and passes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(grid): simplify pinned docking rules

Ported from Slickgrid-Universal f5dbce68:

- share z-index on the combined pinned-left/right cells rule
- drop the .slick-docking-chrome-right transform override; the grid sets
  that class under the same condition as slick-column-pinned-right with a
  zero chrome offset, so the generic pinned rule already applies the same
  transform
- merge two identical rules in the financial report example

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(grid): skip the docked geometry refresh when only the row count changed

updateCanvasWidth() refreshes column widths, chrome and row bands whenever
the grid has docked columns, and updateRowCount() calls it on every
row-count change, for example on each filter keystroke. updateRowCount()
passes an explicit false; that now means "no refresh unless a canvas width
changed". Calls without an argument, as during a column-resize drag, still
refresh a docked grid. Proposed by the audit on #1302; measured here at
0.7 ms (Firefox) and 2.1 ms (Chrome) per row-count update on a pinned grid,
with identical docked geometry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(grid): cache unresolved pinned and sticky row references

resolveDockingRowIndex() cached only the references it could resolve, so a
pinned or sticky row id that is not in the data was searched for again on
every vertical scroll: a full findIndex() over a plain array, or a
getRowById() call on a DataView. An unresolved reference is now cached as
undefined until the existing invalidation paths clear the cache, as in
Slickgrid-Universal 8b6d8d1. The spec counts the searches during 60 scroll
steps (120 before, none now) and checks that the pins resolve once their
rows exist and the rows are invalidated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: 6pac-ai <6pac@dharpa.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: ChatGPT <noreply@openai.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants