Skip to content

feat(grid)!: replace frozen panes with pinning and sticky docking - #1302

Open
ghiscoding wants to merge 47 commits into
next-v6from
feat/pinning-sticky
Open

ghiscoding wants to merge 47 commits into
next-v6from
feat/pinning-sticky

Conversation

@ghiscoding

@ghiscoding ghiscoding commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

supersede #1238
fixes #410
fixes #443
fixes #739
fixes #1219

Summary

Introduce a single-viewport docking architecture for permanent pinned columns/rows and
scroll-activated sticky columns/rows.

This is an intentional v6 breaking change. The previous multi-pane frozen implementation has
been removed from the runtime and replaced with one virtualized body viewport, one vertical
scroll owner, one horizontal scroll owner, and stable per-row left/center/right cell regions.

Why

The legacy frozen-pane implementation required multiple synchronized panes and scroll
containers. This increased complexity around scrolling, resizing, virtualization, editing,
grouping, and framework integrations.

The new architecture provides a simpler and more predictable model:

  • one live viewport and canvas;
  • one horizontal scroll owner;
  • one native vertical scrollbar;
  • stable left/center/right regions within each rendered row;
  • independent, non-contiguous column and row pinning;
  • shared resolution logic for permanent pinning and scroll-activated sticky docking.

Unlike the previous freeze-until-column/row behavior, users can now pin individual columns or
rows independently. For example, columns 0 and 2 can be pinned while column 1 remains in the
center region.

Changes

  • Added canonical GridOption.pinning support for:
    • columns.left / columns.right;
    • rows.top / rows.bottom.
  • Added explicit per-column Column.pinned and CurrentColumn.pinning state support.
  • Added Column.sticky and GridOption.stickyRows for scroll-activated docking.
  • Added the shared internal DockingController for permanent and sticky column/row resolution.
  • Added viewport-based sticky-row budgets, variable-height support, and conveyor/clamp
    overflow strategies.
  • Added stable left/center/right DOM regions for:
    • body rows;
    • headers;
    • header rows;
    • footers;
    • pre-header/grouped header content.
  • Added permanent right-column and bottom-row pinning.
  • Added support for non-contiguous pinned columns and rows.
  • Added cross-band colspan/rowspan rendering with one logical host cell and visual continuation
    fragments.
  • Preserved virtualization, editing, selection, grouping, resizing and auto-sizing. RTL grids
    continue to work as before without pinning or sticky columns; RTL combined with docking is
    a known gap (see Follow-up work).
  • Added sticky financial-report demonstrations:
    • example-sticky-financial-report.html
  • Updated Example pinning to demonstrate permanent left/right column and top/bottom row pinning.
  • Exposed grid.setColumnPinning(columnId, side) and grid.setColumnStickiness(columnId, side)
    so an application can build its own pinning menu commands. This repository has no Header Menu
    pinning command and no Grid State plugin, so neither is added here and pinning is not
    serialized for you; read it back from grid.getOptions().
  • Kept sticky configuration option-based because active sticky membership is scroll-dependent and
    is intentionally not serialized.
  • Added stable .slick-horizontal-scroller and .slick-vertical-scroller selectors.
  • Removed the legacy frozen options, interfaces, state fields, pane runtime branches, synchronized
    scroll branches, redundant viewport/canvas aliases, and old pane CSS classes.
  • Removed the legacy -1000px header coordinate workaround and HEADER_WIDTH_SLACK.
  • Updated the v6 migration guide and pinning/sticky documentation.
  • Added the repository pinning-sticky skill as implementation and documentation guidance.

Breaking changes

  • The old frozen-pane configuration and APIs are removed.

  • The canonical configuration is now:

    {
      pinning: {
        columns: { left, right },
        rows: { top, bottom }
      }
    }
  • Legacy flat pinning options and temporary aliases are no longer supported.

  • Sticky state is not serialized because it changes with scrolling.

  • The old multi-pane DOM structure and pane selectors are no longer available.

  • Column reordering remains within each docking band; moving a column between pinned and center
    bands is an explicit pinning operation.

  • Legacy names and theme variables are retained only as migration documentation references.

References

Ag-Grid Column Pinning was used as key concept reference for the idea of a single horizontal scroller and single vertical scroller, also for its declaration of left/center/right cell docking regions

Validation

The following checks pass on this repository:

  • tsc --noEmit.
  • eslint src.
  • npm run build:prod (bundles, declarations and Sass).
  • The full Cypress suite, including the pinning, sticky, docking, colspan/rowspan, spreadsheet,
    editing, selection, grouping, reordering, variable-row-height and RTL specs.

The accessibility review found no pinning/sticky-specific semantic-tree or keyboard-navigation
regressions. Automated axe/WCAG integration and manual screen-reader validation are not included
in this PR.

Audit

The branch was audited in three rounds and the findings were fixed on it. Highlights of what the
audit changed: row references by dataset id (including { id } for numeric ids),
pinning: null clearing pinning like undefined, the bottom band nesting sticky rows inside
permanent ones like the top band, setColumns() validating before it mutates and returning a
boolean, restoration of the applyHtmlCode / trigger / set*Visibility / onHeaderKeyDown
contracts, removal of the fork's keyboard focus routing, and the docking hot paths taken off
O(n²) chrome lookups and per-column forced layout. Weakened Cypress assertions were restored.

The third round closed the rest of that list. A colspan that crosses a pinned boundary is now
clipped to its own band, with each continuation carrying the remainder of the content at the
right offset, so it no longer hides the columns scrolling beneath it. A centre column resized
past the right edge now scrolls the grid to follow it, instead of freezing at roughly a viewport
width with the handle off screen. Four docking measurements were corrected: a vertical scrollbar
subtracted twice in scroll-into-view, two different overflow tests deciding whether a horizontal
scrollbar exists, screen pixels mixed with layout pixels in the right-pinned chrome, and a
colspan validation that only looked at rendered rows. The horizontal scroll offset is published
once on the container rather than on every docked region, sticky cell and chrome element.

Implementation status

The single-viewport rewrite and legacy runtime cleanup are complete. This is no longer a POC
that runs alongside the old frozen-pane implementation.

Measured against the base commit 179c8ac3, src/ is +6,621 / -3,888 across 26 files
(+2,733 net), of which src/slick.grid.ts is +5,463 / -3,406; that file is now 11,558 lines.
These figures exclude demos, tests and generated output.

Follow-up work

The following items are intentionally separate from the v6 implementation:

  • RTL with pinning or sticky columns. An RTL grid creates the docking scrollbar but takes the
    non-proxy geometry path, so a right-pinned column and an activated sticky column are placed
    outside the viewport, and the docked hit-test path is skipped for RTL. RTL grids without
    docking are unaffected. Documented as a limitation in docs/pinning-sticky.md.
  • optional manual UX trials for sticky transitions and held-scroll performance;
  • a separate investigation into fast vertical-scroll blanking;
  • grouped sticky header bands, such as quarterly group headers.

None of these requires restoring the legacy pane architecture or changing the current pinning/sticky
runtime design.

AI / LLM assistance

  • AI / LLM assistance used:
    • No
    • Yes
  • If Yes:
    • which tool/model: OpenAI Codex 5.6 Sol and Luna for the implementation; Claude Code
      (Opus 5 / Fable 5.1) for the two audit rounds and their fixes
    • how was it used: Architecture analysis, implementation, refactoring, debugging, demo and
      documentation updates, test maintenance, and validation support.

Checklist

  • The changes are limited to the pinning/sticky docking rewrite and required demos,
    documentation, tests, and cleanup.
  • Tests were added or updated where appropriate.
  • Documentation was updated where appropriate.
  • Legacy frozen-pane runtime behavior and compatibility branches were removed.

Print Screens

image image image

@ghiscoding

ghiscoding commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

@6pac I think you should close your previous PR #1238 since this is the new approach that includes Pinning and Sticky. Please note that I would ask if you can ask Claude to audit and verify the entire PR to detect any possible problem, there's a progress file written by AI and read by AI to keep it focused, you should tell Claude to read that file .agents/plans/pinning-sticky-progress.md so that it understand the PR and you should also tell it that the original PR was ghiscoding/slickgrid-universal#2782

Side note, with the code now you can at least start testing it out (including the new example-sticky-financial-report.html)

Also important, the +/- 1000px that we carried from the original SlickGrid lib is officially gone in this PR, I'm pretty sure that it was in place to support legacy IE browser back in the day but there's no reason to keep such old code and approach that caused alignment issues when implementing this PR and so I told the AI to remove it all, which is a lot easier to read the DOM now

Comment thread src/slick.core.ts
@6pac

6pac commented Sep 18, 2026

Copy link
Copy Markdown
Owner

OK, Claude Fable is done with the evaluation. There's a lot of it!

@6pac

6pac commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Evaluation of 6pac/SlickGrid PR #1302 — "feat(grid)!: replace frozen panes with pinning and sticky docking"

PR #1302 (head feat/pinning-sticky @ 8faa2f0e, base next-v6 @ 66e842ae)
Origin Port of ghiscoding/slickgrid-universal#2782 (461 files, +29,399/−16,859) into the flat 6pac repo (81 files, +10,281/−5,589)
Author's guide .agents/plans/pinning-sticky-progress.md (1,138 lines, written for the multi-package fork) and .agents/skills/pinning-sticky/SKILL.md
Evaluated 2026-09-17/18 by Claude, read-only. No repository file was modified; the PR build used for testing was produced with npm run build:prod.
Evidence Screenshots referenced below are in the accompanying PR-1302-evidence folder (PR build vs the published base at 6pac.github.io).

1. Verdict

Not mergeable as it stands. The architecture is sound and the big-ticket claims (one viewport, one row per data row with left/centre/right regions, one horizontal scroll owner, HEADER_WIDTH_SLACK gone, a DOM-free resolver) are true. The build, type-check, lint and GitHub CI are green. But the port carries a set of concrete regressions and correctness bugs that the test suite does not exercise, several of the PR description's claims are not true for this repository, and three renamed Cypress specs pass tautologically. The list below is ordered by what should be fixed before merge.

Top issues (details in §4):

  1. pinning.columns.left: N pins one column too many whenever column ids are numeric, because index references are also matched against column.id. Reproduced: the spreadsheet example (left: 3) pins five columns where the base frozenColumn: 3 pinned four, and the new Cypress spec asserts the wrong number.
  2. Every autoHeight: true grid, pinned or not, now renders an empty band (about one header height) below its last row. Reproduced on the plain autoHeight example against the published base.
  3. Vertical mouse-wheel scrolling now moves exactly one row per notch on every grid, because the handler always calls preventDefault() (base only did so for frozen grids).
  4. Ctrl/Meta+drag multi-selection with HybridSelectionModel({ enableMultiSelection: true }) no longer works; the grid now reads a slickgrid-universal-only selectionOptions grid option instead of the selection model's option. The example was patched to add that option so its spec stays green.
  5. The LongText editor (appended to document.body) opens about one grid-offset away from its cell because absBox()/getActiveCellPosition()/getGridPosition() now return container-relative instead of document-relative coordinates while the editors were not changed. Reproduced live on the editing example against the published base.
  6. Row references resolve inconsistently: the controller matches numeric references by data id or index, the id-to-index cache is never invalidated on a count-preserving DataView sort/filter, and a custom DataView idProperty is ignored. Pinned/sticky rows can dock the wrong row.
  7. Cell hit-testing (getCellFromPoint) is not docking-aware, so CellRangeSelector drag selection resolves the wrong cells over pinned columns/rows; wheel events over docked rows scroll the page, not the grid.
  8. Legacy frozenColumn/frozenRow/frozenBottom options are still declared with live JSDoc and silently ignored; the Grid Menu still branches on frozenColumn and can throw. No migration guide exists in this repo although the PR says one was updated.
  9. Three quirk-pinning-* specs are byte-identical renames that still configure the removed frozenRow/frozenBottom and therefore test nothing; other specs had assertions weakened in ways that lock in behaviour changes (notably auto-scroll direction while dragging).
  10. A local Cypress run on Windows: 703 passing, 1 failing. The header-menu sub-menu alignment test fails on Windows in both Electron and Chrome while the base version of the spec passes in the same environment; CI on Linux is green, so it is a platform-dependent geometry shift introduced by the PR (§3).

Nothing found requires abandoning the design. Most items are local fixes; the largest are the row-reference model (§4.1 group B) and the hit-testing/wheel routing for docked content (group D).

2. What was verified and how

Check Result
git diff --check base…PR Clean (one "new blank line at EOF" in docking.interface.ts)
tsc --noEmit (after npm ci) Exit 0
eslint . (whole repo, as CI's prebuild:prod) Exit 0
node scripts/builds.mjs --prod Exit 0, bundles, CSS and dist/types fresh. Produces a new, empty dist/browser/docking.controller.js (76 bytes, (() => {})();) — see §4.2 M5
GitHub CI on the PR "Node 24" job green (5m14s), conventional-commit green
Local Cypress, full suite (Windows, Electron) 67 specs, 703 passing, 1 failing, 1 pending. The failure (example-plugin-headermenu.cy.ts) reproduces in Electron (3/3) and in Chrome (1/1); the base version of the same spec passes 12/12 in the same environment against the published base build — see §3
Live browser check of editor positioning (PR build vs published base) PR misplaces the LongText editor by the grid's page offset — see C8
Headless Chrome screenshots of 19 example pages (PR build) plus 5 base pages from 6pac.github.io Used for the visual comparisons in §4; images in the evidence folder
Five parallel read-only code reviews by area (controller/types; DOM/chrome/scroll/styles; rows/cells/virtualization; API/options/interaction/plugins; tests/examples/docs/claims) Findings de-duplicated and, where marked Confirmed, re-verified in source or in the browser. Items marked Reasoned were derived from the code by a reviewer and not executed

Not done: no unit tests exist in this repository (the tests/ folder is legacy manual HTML benchmarks), so the "51 pinning tests / 100 % coverage" in the progress file could not be run here; no Firefox/Safari/RTL browser sessions; no screen-reader check.

3. Cypress results (local run, Windows)

Specs 67
Passing 703
Failing 1
Pending 1 (example-auto-scroll-when-dragging "MAX interval", skipped in base too)

The one failure is example-plugin-headermenu.cy.ts › "should open Pinning sub-menu and expect 2 options, then open Feedback->ContactUs sub-menus…": Expected to find element: .slick-header-menu.slick-menu-level-2.dropright, but never found it — the level-2 sub-menu opens to the left. Facts:

  • Fails deterministically on Windows in Electron (3/3) and in Chrome (1/1); GitHub CI (Ubuntu, Chrome) passes.
  • The PR changed only the labels in this spec and this example; src/plugins/slick.headermenu.ts is untouched.
  • The base spec (origin/next-v6 version) run with the same Cypress version in the same environment against the published base build (6pac.github.io, identical example and plugin) passes 12/12. So the flip is introduced by the PR and is platform-dependent.
  • The plugin decides dropleft when parentOffset.left + subMenuWidth + parentItemWidth >= getGridPosition().width. For this example that sum is within a few pixels of the 600px grid width, so a small change in header/menu geometry (the PR rewrote header layout CSS, renamed ui-state-default, and getGridPosition() now returns getBoundingClientRect().width) is enough to flip it under Windows font metrics. Whether users see a wrong alignment depends on their layout; the author should reproduce on Windows and either fix the geometry shift or make the threshold viewport-based.

Otherwise the suite is green locally, which matches CI. Note that green CI does not cover findings 1–7 above: the spreadsheet spec asserts the wrong pinned count, no spec drags a range over pinned cells, no spec uses non-contiguous row pins, pinning.rows + stickyRows together, numeric-id datasets with sorting, or a custom idProperty, and the wheel spec dispatches a synthetic cancelable event that states "1 notch === 1 row".

4. Findings

Severity: Blocker = wrong behaviour for ordinary configurations or silent regression for existing users; High = wrong behaviour for documented pinning/sticky configurations; Medium = correctness edge cases, performance, API hygiene; Low/Nit = polish. Status: Confirmed (re-verified in source or browser), Observed (seen in the browser), Reasoned (reviewer, from code only).

4.1 Blockers and High

A. Column pinning references

A1. Blocker — Numeric index references are also matched against column.id, so left: N over-pins when ids are numeric. Confirmed (DOM dump + base comparison).
src/slick.grid.ts:10094-10096 (applyColumnPinningOptions): const isLeftPinned = leftRefs.has(index) || leftRefs.has(column.id); while getPinnedColumnIndexes (10119-10130) and validation treat numbers as indexes only. normalizeColumnPinningReferences (10266-10281) expands left: 3 to indexes [0,1,2,3]; a column whose id is 3 (index 4) is pinned as well.
Evidence: examples/example-pinning-columns-and-rows-spreadsheet.html has pinning.columns.left: 3 with columns selector, 0, 1, 2, …. The rendered left header region contains five columns (selector,0,1,2,3); the base example with frozenColumn: 3 pinned four. cypress/e2e/example-pinning-columns-and-rows-spreadsheet.cy.ts:129-147 asserts leftHeaderIds.size === 5 with the comment "currently exposes five left-pinned header IDs in the rendered bundle", i.e. the spec encodes the bug. Screenshots: spreadsheet-PR-left3-pins-5-columns.png vs spreadsheet-BASE-frozenColumn3-pins-4-columns.png.
Also: no bounds check (!this.columns[-1]?.hidden is true), so negative/out-of-range numbers land in the pinned map; width validation is bypassed for the id-matched column.
Fix: one resolver for both paths; numeric array entries are indexes only, with Number.isInteger(n) && 0 <= n < columns.length; if ids must be addressable, use { id } objects. Then correct the spec to 4.

A2. High — Numeric shorthands count hidden columns; left is an inclusive boundary but right is a count. Confirmed.
normalizeColumnPinningReferences works on raw this.columns; right: 1 with the last column hidden: true pins nothing, silently. The progress file says the boundary expands to "the first three final visible columns". docking.interface.ts:14-25 documents the asymmetric semantics. Fix: normalise against visible columns, or document exactly.

B. Row references (pinned and sticky rows)

B1. High — DockingController.resolveRows matches every set by row.id or row.index, while the grid resolves a numeric reference as an index only. Confirmed in src/slick.core.ts:1660-1675 and src/slick.grid.ts:10546-10553.
With the default { id: i } datasets, after a descending sort the row at index N−1 has id 0; pinning.rows = { top: [0], bottom: [N-1] } puts both rows in the top band (topIds.has(row.id) short-circuits first). Same for stickyRows.*. Fix: resolve everything to indexes in the grid and match on row.index only.

B2. High — The id→index cache is never invalidated on a count-preserving DataView sort/filter. Confirmed in src/slick.grid.ts:10546-10577: cleared only when refreshRowDockingLayout(…, rebuildReferences=true) is called (init, setOptions, updateRowCount). The canonical wiring onRowsChanged → invalidateRows + render never reaches updateRowCount, so pinning.rows.bottom: ['net-profit'] keeps docking the pre-sort index. Fix: clear the map in invalidateRows/invalidateAllRows/setData, or simply re-resolve through getRowById each pass (O(1) with a DataView).

B3. High — Custom DataView idProperty is ignored. Reasoned (src/slick.grid.ts:10531-10541). getRowIdentity uses this._options.datasetIdPropertyName || 'id' (a universal-only option) instead of DataView.getIdPropertyName(), so with dataView.setItems(items, 'code') the DockingRow.id passed to the controller is undefined and top: ['ABC'] never matches. Fix: prefer this.data.getIdPropertyName?.().

B4. High — Bottom-pinned rows keep their natural slot in the canvas. Reasoned (getRenderedRowTop 10749-10754 shifts only for top pins; updateRowCount 6378-6383; scrollTo 7001-7008). With enableAddRow: true the add-new row's slot is hidden behind the bottom band; a non-trailing bottom: [5] leaves a blank gap at row 5 and hides the real last row. The example works around it: examples/example-pinning-columns-and-rows.html:243-245 "Keep the add-new row disabled so it cannot appear as an empty row below the bottom pin". Fix: treat bottom pins symmetrically to top pins, or reject/warn for enableAddRow + bottom pins and non-trailing bottom pins.

B5. High — Non-contiguous top pins break hit-testing and active-cell tracking. Reasoned. Unpinned rows render at natural + S(row) (height of permanent top pins with index ≥ row), but setActiveCellInternal (4017-4022, non-docked branch), getCellFromPoint (8373-8375) and scrollRowIntoView (7509-7532, uses the constant topHeight) map with natural coordinates. With top: [0, 2, 4], clicking row 1 sets activeRow = 3; editors/keys act on the wrong item. No example or spec uses non-contiguous pins. Fix: read rowNode.dataset.row for every row in setActiveCellInternal; give getCellFromPoint the inverse of getRenderedRowTop.

B6. High — Sticky-row thresholds ignore the permanent top band and subtract the bottom band twice. Confirmed in src/slick.core.ts:1671-1696: visibleBottom = scrollTop + max(0, viewportHeight − topHeight − bottomHeight), top test row.top < scrollTop (no + topHeight), and let stickyBottomHeight = bottomHeight on top of the already-reduced visibleBottom. With pinning.rows.top: [0,1] + stickyRows.top: [5], row 5 slides under the permanent band for two row-heights before docking; the bottom mirror docks early and leaves a blank gap. No example combines permanent and sticky rows.

B7. High — conveyor overflow keeps the wrong end for right columns and bottom rows. Confirmed in src/slick.core.ts:1749-1764: applyBudget ignores its _edge parameter and always reverses; for bottom/right the newest candidate is the first element. stickyRows.bottom: [r10, r20, r30] with a 60px budget keeps r20/r30 and drops the row the user is about to reach.

B8. Medium — stickyHysteresis is a fixed activation offset, not hysteresis (slick.core.ts:1527,1548,1559; no per-item previous state; rows use none). Document or implement.

C. Regressions for grids that do not use pinning at all

C1. Blocker — Every autoHeight grid gets an empty band below its rows. Observed + root cause confirmed.
resizeCanvas (src/slick.grid.ts:6253-6260) now unconditionally sets the container height to paneTopH + _headerScrollerL.offsetHeight + vbox + preHeader, where paneTopH was derived from viewportH, and in autoHeight mode getViewportHeight (6143-6157) already folds _headerRoot.offsetHeight, pre-header, header-row and footer into viewportH. Header and pre-header are therefore counted twice, and _contentRoot gets the inflated height. The base only set the container height for frozen autoHeight grids and left plain ones to size naturally.
Evidence: examples/example11-autoheight.html (no pinning) rendered on the PR build ends 32px lower than the published base with identical data, and the extra space is an empty strip between "Task 99" and the horizontal scrollbar (autoheight-plain-PR-bottom.png vs autoheight-plain-BASE-bottom.png). The pinned autoheight example shows ~40px (grid 1) and ~90px (grid 2, with pre-header) bands (autoheight-pinned-PR.png vs autoheight-frozen-BASE.png).

C2. Blocker — Vertical wheel now scrolls one row per notch on every grid. Confirmed by diff. handleMouseWheel (src/slick.grid.ts:4559-4581) always calls e.preventDefault() when the scroll was handled; the base (4446-4468) did so only when hasFrozenColumns(), so ordinary grids received the native ~100px/notch scroll plus the handler's nudge. Now the handler is the only motion source: deltaY * rowHeight (25px per notch by default). enableMouseWheelScrollHandler defaults to true, so all grids are affected; a 500k-row grid needs ~4× more notches on Windows/Chrome. The horizontal path was converted to native pixel deltas; the vertical path was not. The only wheel spec dispatches a synthetic event and asserts "1 notch === 1 row".

C3. Blocker — Ctrl/Meta+drag multi-selection regressed. Confirmed by diff. Base createDraggable() (1000-1017) read getSelectionModel()?.getOptions()?.enableMultiSelection === true and setSelectionModel() re-created the Draggable. PR (1146-1158) reads this._options.selectionOptions?.enableMultiSelection !== undefined (a slickgrid-universal option, typed any) once at init. Existing users of HybridSelectionModel({ enableMultiSelection: true }) lose Ctrl+drag. The PR patched examples/example-plugin-hybridselectionmodel.html:304 to add selectionOptions: { enableMultiSelection: true } so its spec stays green. Also !== undefined strips the modifier keys for enableMultiSelection: false.

C4. High — Undocumented rename ui-state-defaultslick-state-default. Confirmed (15 occurrences in base slick.grid.ts, 1 in PR; 11 slick-state-default). Consumer CSS/JS keyed on .slick-header.ui-state-default etc. stops matching; examples still add the old class themselves (example-column-group.html:73, example-draggable-header-grouping.html:185, example-pivot.html:218). Not in the PR's breaking list.

C5. High — destroy(true) is a silent no-op. Confirmed: src/slick.grid.ts:150 const destroyAllElementProps = (_target: object) => undefined; replaces base destroyAllElements() that nulled ~40 DOM fields. Same pattern for other universal helpers stubbed rather than ported: copyCellToClipboard = () => undefined (dead Ctrl+C branch at 11356-11365), type FormattedDataCachePlanner = any, type TrustedHTML = string.

C6. High — Plain grids pay O(columns) per rendered cell in appendRowHtml. Reasoned (5632, 5657/5661usesDockingRowRegions()hasConfiguredColumnDocking()this.columns.some(...) plus rowNode.querySelector(':scope > .slick-scrolling-cells') per cell). O(N²) per row for a 100-column grid with nothing pinned; base had none of this. Fix: evaluate once per render pass and use the cached cellRegions.

C7. Medium — Keyboard/focus contract changes not listed as breaking. Reasoned. Focus sinks moved outside the container with tabIndex: -1 (851-857, 1022-1023; base tabIndex: 0 inside the container), so getContainerNode().contains(document.activeElement) is false while the grid has focus; Shift+Tab at (0,0) now goes to header-row filters/grid menu instead of navigatePrev(); F6 focuses the header; onClick now also aborts on e.defaultPrevented (4672), so link-cell handlers that call preventDefault() suppress cell activation.

C8. Blocker — absBox() now returns container-relative coordinates; the LongText editor (and any custom editor/plugin positioned from args.position or getActiveCellPosition()) is misplaced. Confirmed live.
Base absBox (src/slick.grid.ts base 8497-8530) walked offsetParents and returned document coordinates. PR absBox (8497-8530) returns rect − containerRect, so getActiveCellPosition(), getGridPosition() (now always top: 0, left: 0) and the position/gridPosition passed to editors in makeActiveCellEditable (4210-4211) are relative to the grid container. src/slick.editors.ts is unchanged: LongTextEditor appends its wrapper to document.body with position: absolute and sets top/left from args.position (734-758, 832-835).
Evidence (live, examples/example3-editing.html, "Description" cell of row 3, grid container at page offset (8, 112)): PR build sets the editor to top: 112px; left: 79px (= container-relative cell position 117/81 minus the editor's 5/2 px inset), i.e. ~118px above and 10px left of the cell, after which the browser scrolls the page to the focused textarea. The published base sets top: 225px; left: 86px for the same cell at document position (89, 231) — correct. Any grid that is not at the page origin is affected; the composite-editor path is not (it appends inside the cell). CustomTooltip, RowDetailView and third-party editors that use these positions are at the same risk. The four menus that read getGridPosition().width still work because they only use the width.
Fix: keep the old document-relative contract for absBox/getActiveCellPosition/getGridPosition (or add the container offset back), or make the editors container-aware and document the change as breaking. Add a spec asserting editor placement on a grid with a non-zero page offset.

D. Interaction with docked content

D1. High — getCellFromPoint is not docking-aware; CellRangeSelector drag selection is wrong over pinned cells. Reasoned by three reviewers independently (src/slick.grid.ts:8373-8391; src/plugins/slick.cellrangeselector.ts:168-178, 333-336, 393-396, where the PR deleted the old frozen offset compensation). Pinned-left regions are counter-translated by +scrollLeft, right regions sit at the viewport edge, pinned rows live in the overlay outside the canvas, but the function walks natural widths and canvas row positions. Scroll right in the spreadsheet example and drag from a pinned cell: the range starts in a centre column. No pinning spec performs a drag selection.

D2. High — Wheel over a pinned/sticky row scrolls the page. Reasoned. MouseWheel is bound to the viewport only (1095-1103); the overlay is a sibling of the viewport (9995-10001) and bindDockingOverlayEvents (10004-10020) binds no wheel handler.

D3. High — Column reorder throws when a sticky column is docked (LTR proxy path). Reasoned. onEnd (2219-2236) maps dockingLayout[band] entries (which include active sticky entries) onto the band Sortable's toArray() (which keeps transform-path stickies in the centre band), leaving finalColumns[i] = undefined and then destructuring it.

D4. High — Forwarded chrome scrollLeft is treated as absolute but is a delta in proxy mode. Reasoned (forwardDockingHorizontalScroll 11043-11060). Header/header-row/footer containers are kept at scrollLeft = 0 and translated; when the browser auto-scrolls one of them (e.g. focusHeaderRowFilter focusing an off-screen filter on Shift+Tab), the forwarder assigns that small value as the absolute proxy position and the grid jumps to the left.

D5. Medium — Docked rows outside the vertical rendered range never receive new centre cells on horizontal scroll, and in-range docked rows are never cell-cleaned. Reasoned (render 6886-6897, cleanUpAndRenderCells iterates range.top..bottom only; cleanUpCells returns for pinned rows). Visible with a far bottom pin and > 2 viewport widths of columns.

D6. Medium — setColumns() can silently reject after mutating the input and firing onBeforeSetColumns, and validates the old column array. Reasoned (3697-3711; validateColumnPinning(undefined, true) defaults to this.columns). Grid Menu / Column Picker hide-column flows see a before-event with no after-event.

D7. Medium — Pinning cannot be switched off at runtime; the proxy scroller and chrome regions are created lazily but never removed. Confirmed by reading 1352-1380, 9880-9900, 9918-9923. setOptions({ pinning: undefined }) is skipped by the deep-extend; pinning: {} keeps prior edges; after one pin→unpin cycle the grid stays in proxy mode with overflow-x: hidden on .slick-viewport, which the PR's own comment (9890-9893) says breaks integrations that scroll the viewport directly.

D8. Medium — Lazy docking activation empties header/header-row/footer without firing the onBefore*CellDestroy events (updateColumnsInternal 3735-3741createDockingChromeRegionsUtils.emptyElement). HeaderMenu/HeaderButtons/CustomTooltip cleanup leaks for that transition.

D9. Medium — Cross-band colspan fragments freeze the host's selected/custom CSS classes at clone time (10990-10992; updateCellCssStylesOnRenderedRows touches only the host).

E. Legacy surface and claims

E1. High — Legacy frozen options remain declared with live JSDoc; the Grid Menu still branches on them. Confirmed. src/models/gridOption.interface.ts:276-289, 446-476 still declare frozenBottom, frozenColumn, frozenRow, frozenRightViewportMinWidth, skipFreezeColumnValidation, throwWhenFrozenNotAllViewable, invalidColumnFreeze* (no @deprecated); slick.grid.ts reads none of them (base had 311 "frozen" hits, PR has one comment). frozenColumn: 2 type-checks and silently does nothing. src/controls/slick.gridmenu.ts:179-187, 212-217 still compares frozenColumn in onSetOptions and, when the option is present, queries .slick-header-right (no longer emitted) and dereferences .style on null. Fix: delete the options (or @deprecated + one-time console.warn), remove the Grid Menu branches.

E2. High — PR description and progress file claim things that do not exist in this repository. Confirmed by grep/diff.

  • Header Menu "Column Pinning" sub-menu (pin-left, pin-right, bulk, unpin-*), headerMenu.showPinningCommands, and Column.pinnable gating: src/plugins/slick.headermenu.ts is unchanged; pinnable has zero readers in src/. The only "Pin Columns" in the tree is the header-menu example's custom command whose handler calls alert(); its spec asserts that alert. SKILL.md tells consumers pinnable "only controls whether built-in pinning commands are exposed" — false here.
  • Grid State / Presets (GridState.pinning, CurrentColumn.pinning, GridService.setPinning(), Example 11 persistence), locale strings, getColumnsInRenderedOrder(): absent (no such modules in 6pac).
  • "Updated the v11 migration guide and pinning/sticky documentation": docs/ is two stub files; no migration text anywhere; CHANGELOG.md untouched.
  • "51 / 454 / 71 focused unit tests, 100 % / 99.97 % coverage", "Example 04 … 42/46 tests": no unit runner exists; the Example 04 equivalent has 6 it().
  • "Removed … old pane CSS classes": .slick-pane/.slick-pane-header rules remain in slick.grid.scss:264-273 and slick-alpine-theme.scss:601-611 (dead).
  • --slick-pinned-* "theme variables": only var(--slick-pinned-…, fallback) reads in _slick-docking.scss; no theme defines them.
  • "--slick-docking-scroll-left registered as non-inheriting": no @property/registerProperty anywhere in src/.
  • src/docking.controller.ts "shared docking resolver": it is a 5-line re-export; the class lives in slick.core.ts, and it is public (ESM via index.ts, IIFE Slick.DockingController, global.d.ts) although SKILL.md says it must not be.
    The progress file's "Repository adaptation note" relabels paths but does not retract these; its "Suggested resume prompt" will make the next agent act on them.

E3. High — Test integrity. Confirmed by diff.

  • cypress/e2e/quirk-pinning-row-zero.cy.ts, quirk-pinning-bottom-hit-testing.cy.ts, quirk-pinning-bottom-cell-cleanup.cy.ts are R100 renames (zero content change) still configuring frozenRow/frozenBottom; e.g. row-zero asserts "rows render in the top canvas, none in the bottom" against a .grid-canvas-bottom that never exists, and cell-cleanup asserts getOptions().frozenBottom === true, which merely echoes the option. Bottom-pinned hit-testing and cleanup therefore have no coverage while three green specs remain. (quirk-pinning-row-boundary.cy.ts was ported properly.)
  • example-auto-scroll-when-dragging.cy.ts:207-300: scrollTop/scrollLeft equallte/lessThan; the "dragging up auto-scrolls up" case changed from greaterThan to equal (no upward auto-scroll with top-pinned rows is now the expected result); getIntervalUntilRow16Displayed no longer waits for the row. Commit 8faa2f0e "chore: fix cypress failures" is one real cellrangeselector fix (offsetWidth − scrollbarclientWidth/clientHeight, 11 lines) plus 46 lines of spec edits and a drag.ts fallback that affects every cy.drag().
  • Weakened elsewhere: example-auto-header-height.cy.ts dropped both scrollHeight <= clientHeight + 1 overflow checks; headers-width-scroll-sync.cy.ts no longer asserts header/body scrollLeft equality; quirk-fractional-height-bottom-render.cy.ts inverted its precondition (> 0.01< 1), so the quirk need not reproduce; dom-shape-characterization.cy.ts loosened assertions the base said not to loosen; example-plugin-hybridselectionmodel.cy.ts swapped Cypress trigger() for native MouseEvent to keep passing (suggests the new selector needs absolute coordinates).
  • Helpers: getNthCell changed from nth-child to .l{n}.r{n} semantics (cause of the (0,0)→(0,2) edits); a dead legacy branch and an unused getTransformValue were added; force: true count rose 142 → 159.
  • Coverage dropped vs the five deleted frozen specs: pre-header column-picker case, both reorder auto-scroll cases, nearly all per-band cell value assertions (now counts/ids). Deleted 41 it(), added 29 + 11 sticky.

4.2 Medium

M1. Performance on the per-scroll path. Reasoned by two reviewers (consistent with each other):

  • Proxy-mode horizontal scroll: applyDockingScrollOffsetToRow (9511-9537) reads row.offsetWidth and writes two inline transforms per cached row per scroll event; the stylesheet's !important translate3d(var(--slick-docking-scroll-left)) (_slick-docking.scss:221-231) overrides the inline transforms, so the writes are dead and the read forces a layout per row (read/write interleave). Contradicts the progress file's "no per-row writes during horizontal scrolling" for every column-pinned grid.
  • Vertical scroll with any row docking: refreshRowDockingLayout (10574-10620) calls ensureDockingOverlay()bindDockingOverlayEvents() (unbind + 6 fresh listeners) and syncDockedRowContainers() (per cached row: querySelector('.slick-cell.rowspan'), metadata lookup, ~8 DOM writes) on every event, even when the revision is unchanged. The progress file itself lists this as pending.
  • Column resize: updateCanvasWidth runs applyDockingToColumnChrome (9616-9775: O(n²) querySelectorAll(...).find, getBoundingClientRect + getComputedStyle interleaved with width writes) on every mousemove.

M2. pinning shape/merge issues. setOptions cannot remove pinning (see D7); mixinDefaults: true with a partial docking object leaves minCenterRowCount undefined for grid-side readers (806-812, 10835); enforceMinCenterRowBudget counts sticky rows although the doc says permanent-only and runs only on resize (10831-10846).

M3. Public API drift not listed as breaking. Reasoned/confirmed by call-site diff:

  • applyHtmlCode(target, value, skipEmptyReassignment = false) replaced the (target, val, { emptyTarget, skipEmptyReassignment }) overload; JSDoc still documents the object.
  • sanitizeHtmlString lost suppressLogging; logSanitizedHtml option is now dead; non-strings are coerced.
  • animate parameter removed from all set*Visibility methods; trigger() renamed to triggerEvent() and made public; validateAndEnforceOptions became protected; setColumns(cols, waitNextCycle), focus(mode) additive.
  • onHeaderKeyDown is typed OnKeyDownEventArgs ({ row, cell }) but notified with { event, column, grid } (285, 1870).
  • Removed: getFrozenColumnId, getFrozenRowOffset, validateColumnFreeze, validateColumnFreezeWidth (intended; no in-repo callers). Base's throwWhenFrozenNotAllViewable throw path has no replacement. Width validation changed from > to >= (10178-10188).
  • New public: getPinnedColumns, setColumnPinning, setColumnStickiness, validateColumnPinning, focusGridCell/Menu/HeaderColumn/HeaderMenuOrColumn/HeaderRowFilter, getColumnByIdx (unused, returns undefined not null), getColumnHeaderByIndex, removeCellCssStylesBatch; new events onHeaderMouseOver/Out, onHeaderRowMouseOver/Out; onContextMenu args gained { row, cell }.

M4. slickgrid-universal leakage into public types. Confirmed in the model diff. GridOption: allowDragFromClosest, enableGridMenu, enableRowDetailView, enableFormattedDataCache, enableExcelCopyBuffer, silenceWarnings, selectionOptions: any, datasetIdPropertyName, rowDetailView: any, columnResizingDelay, autoScrollResizeLeftDelay/RightDelay (never read); CustomDataView.setFormattedDataCachePlanner/getCellDisplayValue (this repo's DataView implements neither, so the whole formatted-cache planner path 320-353, 3872-3878, 10712-10727 is dead); Column.editorClass, exportCustomFormatter, exportWithFormatter, pinnable (dead); ColumnMetadata & { editorClass?: any }; EditorArguments.isCompositeEditor; rowDetailView?.renderMode === 'inline' branch (1554-1561) for a renderMode this repo's plugin does not have; gridHeight used in the sticky example is not a GridOption here. Undocumented, mostly untyped. Fix: remove the dead ones, type or drop the rest, and split the genuinely useful unrelated options (allowDragFromClosest, columnResizingDelay) into their own change with JSDoc.

M5. Dead file that ships as an empty bundle. Confirmed. src/docking.controller.ts (5-line re-export, referenced by nothing) is picked up by scripts/builds.mjs's per-file IIFE build and, because non-entry imports are stubbed, emits dist/browser/docking.controller.js containing only (() => {})();. Delete the file.

M6. Docked-row overlay artifact with zero-width scrollbars. Observed only in headless Chrome with scrollbars hidden (which is what overlay-scrollbar platforms such as macOS report): the last digit of each docked sticky row's rightmost cell is painted a second time, offset down-right, in the strip between the overlay clip and the grid border (sticky-report-ghost-digits-hidden-scrollbars.png). With classic Windows scrollbars the artifact is absent (sticky-report-PR.png). Cause not isolated; the metric-based "8px trailing strip" fallback (updateDockingOverlayClip) is the likely area. Needs a macOS/overlay-scrollbar check.

M7. Small controller/geometry issues. cancelScheduledAnimationFrame calls clearTimeout with a rAF id (11117-11122, separate id spaces); internalScrollColumnIntoView subtracts the vertical scrollbar twice in proxy mode (7281-7306); viewportHasHScroll and the proxy's overflow decision use different criteria (5159 vs 10883-10889); getRightDockedChromeLeft mixes getBoundingClientRect screen pixels with layout pixels (9829-9862), off under a scaled ancestor; validateColspanPinningSequence inspects only rendered rows (10210-10240); RTL passes the raw negative scrollLeft to resolveColumns (10496-10504) and example-rtl.cy.ts has no pinning/sticky assertions (unverified risk); bottom band stacks sticky rows below permanent rows while the top band stacks them inside (asymmetric, possibly intentional).

M8. Examples and docs. example-pinning-columns-and-rows.html:252-255 hard-codes bottom: [49999] on a page with a DataView filter and pager, so the pin silently disappears after filtering; example-draggable-header-grouping.html:488,498 uses rows: { left: [], right: [] }, not a valid PinnedRows shape; examples/index.html:219 labels example-pinning-rows.html as "Pinned Columns & Rows"; example-quirk-frozen-row-*.html keep "DO NOT MERGE" banners and frozen names (bodies ported); example-csp-policy.js/example-csp-header.html now carry a BrowserSync trusted-types allowance for the dev server; AGENTS.md says never modify dist/ "including when running builds", which contradicts npm run build:prod, CI and scripts/release.mjs; SKILL.md directs maintainers to unit tests under tests/ that do not exist; _slick-docking.scss is @used by slick.grid.scss and both themes, so a page loading grid CSS plus a theme gets the docking rules twice. The PR does not commit dist/, so the examples on the branch show the old frozen-pane build until npm run build:prod is run; worth a line in the PR text.

4.3 Low / Nits

  • src/global.d.ts:20 duplicate import type … from './slick.core.js'.
  • column.interface.ts:193 sticky JSDoc never says true = leading edge; docking.interface.ts:81 mentions hysteresis for "sticky item" though rows use none.
  • Progress file "Current APIs" omits docking.minCenterRowCount.
  • getRowIdentity falls back to the index for id-less items, which can collide with numeric ids in the row signature (10533-10544, slick.core.ts:1740).
  • Column revision ignores width/offset changes (slick.core.ts:1618-1622); document as membership-only.
  • Compat classes slick-viewport-top slick-viewport-left / grid-canvas-top grid-canvas-left are still emitted (976, 990) while -right/-bottom are gone; quirk-pinning-row-boundary.cy.ts still says "frozen-row boundary" in its title/describe.
  • slick.grid.scss:300-305 / alpine 615-620 .slick-header-auto-height .slick-header-columns-right {height; overflow} now targets a display: contents wrapper (ignored).
  • _handleScroll assigns _viewportScrollContainerY.scrollTop twice (7187-7191); updateRowPositions(dockedOnly) parameter has no caller; renderRows calls ensureDockingOverlay() per docked row; isPinnedRowIdx(i) || (band !== 'center') at 5885-5888 is the same predicate twice.
  • Array-backed grids with string id references rescan the whole array on every updateRowCount (10562-10577).
  • dev-watch.mjs now binds BrowserSync to 127.0.0.1 by default (BROWSERSYNC_HOST to override) — behaviour change for LAN/device testing, otherwise the script changes are sound and fix a real await subscribe bug.

5. Verified sound

  • Single live viewport/canvas; renderRows appends one row node per data row; row regions and chrome regions (display: contents) match the described DOM; HEADER_WIDTH_SLACK and the ±1000px pair are fully gone; .l{i}/.r{i} rules exist for all columns.
  • DockingController wiring across ESM/CJS/IIFE and global.d.ts; defaults equal DEFAULT_DOCKING_OPTIONS; setOptions replaces stickyRows and pinning.columns/rows arrays atomically; options pushed into the controller before every resolve.
  • Column band membership (null/hidden skipped, pinned beats sticky, two-sided candidates pick the nearer edge, left activation against the occupied sticky edge, right stickies iterated farthest-first); budgets deduct permanent sizes first; oversized candidates skipped; degenerate inputs (0 columns, empty data, NaN percents, zero viewport) do not throw; stateless resolver handles large scroll jumps; revision counters bump only on membership change.
  • Row cache vs overlay reparenting (same node moved with appendChild; rowsCache fields stay valid; rows moved back before the overlay is removed); no double rendering; fragments excluded from logical-cell caches, cleaned with their host, aria-hidden/role=presentation; clicks on fragments activate the host; updateRow/updateCell on docked rows; editor positioning on overlay rows via absBox; getCellNodeBox handles top/bottom bands; getRowFromNode uses closest('.slick-row').
  • Top-pin layout math (contiguous and non-contiguous, uniform and variable heights) lays unpinned rows contiguously; variable-row-height (RowPositionIndexer) integration; group rows render one viewport-wide cell.
  • destroy() tears down timers/rAF, Draggable/MouseWheel/Resizable, three Sortables, document capture listener, overlay listener group, focus sinks, <style>, proxy scroller and overlay; no Resize/MutationObserver anywhere. Repeated docking toggles do not accumulate listeners (D8 excepted).
  • scrollToX updates canvas, overlay, header, header-row, footer, pre-/top-header transforms synchronously, so no frame-level header/body desync; overlay clip maths correct for LTR; resizeCanvas reserves the proxy height only on real overflow; classic (non-overlay) scrollbars handled (proxy width = clientWidth).
  • Every public getter used by src/plugins/* and src/controls/* still exists with compatible semantics; no plugin/control depends on .slick-pane*, .slick-viewport-right, .grid-canvas-right, getCanvases().length > 1, getViewports(), getFrozenColumnId; getSelectionModel/sanitizeHtmlString still exist (generic signatures); slick.draggablegrouping.ts creates Sortables only for existing bands and destroys all three; slick.cellrangeselector.ts viewport dimensions and scroll tracking are sound apart from D1.
  • Navigation (goto*, navigateToPos) works in raw index space, skips hidden columns, guards pinned rows; scrollCellIntoView scrolls a sticky candidate to its natural position; invalidColumnPinning* defaults are alert(error) like the old freeze callbacks.
  • Event argument shapes for all pre-existing events unchanged (call-site diff); no dist/ committed; every href/src in the changed examples and every index.html link resolves; all spec selectors exist in the example markup; package.json/CHANGELOG.md untouched; scripts/builds.mjs change adds esbuild error detail only.

6. Recommended actions before merge

  1. Fix column reference resolution (A1, A2) and correct the spreadsheet spec to the intended count.
  2. Make row references index-only inside the controller, invalidate the id cache on data changes, and honour DataView.getIdPropertyName() (B1–B3); fix the sticky-row band thresholds and conveyor direction (B6, B7); decide bottom-pin flow semantics and non-contiguous hit-testing (B4, B5) or reject those configurations explicitly.
  3. Restore base behaviour for grids without pinning: autoHeight container sizing (C1), native vertical wheel (C2), selection-model-driven multi-select (C3), document-relative absBox/editor positions (C8), and reproduce the header-menu alignment flip on Windows (§3); either keep both ui-state-default and slick-state-default for a major or list the rename (C4); port destroyAllElements (C5); hoist the per-cell docking checks (C6).
  4. Make hit-testing and wheel routing docking-aware (D1, D2), fix reorder with docked stickies (D3) and delta forwarding (D4), make setColumns validate the incoming array and signal rejection (D6), allow pinning removal with symmetric teardown (D7).
  5. Remove the legacy frozen* option declarations (or deprecate with a runtime warning) and the Grid Menu branches (E1); rewrite the PR description, progress file and SKILL.md to what exists in this repo, and add a migration note for the removed options/methods/classes (E2).
  6. Port the three tautological quirk specs to pinning.rows.bottom, restore the weakened assertions where the old behaviour is still intended, and add specs for: drag selection over pinned cells, numeric-id sort with row pins, pinning.rows + stickyRows, non-contiguous pins, enableAddRow + bottom pin, native wheel delta, autoHeight height equality with the pre-PR value (E3).
  7. Remove the universal leakage and dead file (M4, M5); address the per-scroll layout thrash (M1); check the docked-row overlay on an overlay-scrollbar platform (M6).

7. Reproducing the confirmed findings

All steps use the repository's own scripts on a clean checkout of the PR branch (npm ci, then npm run build:prod); the base comparisons use the published examples at https://6pac.github.io/SlickGrid/examples/.

  • A1 — open examples/example-pinning-columns-and-rows-spreadsheet.html and count the headers inside .slick-header-columns-left (five: selector, 0, 1, 2, 3); compare with example-frozen-columns-and-rows-spreadsheet.html on the published site (four).
  • C1 — open examples/example11-autoheight.html (no pinning) and measure the grid's bottom edge against the published example11-autoheight.html with the same window size; the PR grid is one header-height taller with an empty strip above the horizontal scrollbar. example-pinning-columns-autoheight.html vs the published example-frozen-columns-autoheight.html shows the same with a larger band when a pre-header is present.
  • C2 — compare handleMouseWheel in src/slick.grid.ts between next-v6 and the PR: preventDefault() is now unconditional; wheel over any grid moves rowHeight px per notch.
  • C3git diff next-v6...feat/pinning-sticky -- examples/example-plugin-hybridselectionmodel.html shows the added selectionOptions: { enableMultiSelection: true }; remove it and Ctrl+drag range selection stops working.
  • C8 — on examples/example3-editing.html run in the console: grid.setActiveCell(3, 1); grid.editActiveCell(); then read document.querySelector('.slick-large-editor-text').style.top/left and compare with grid.getActiveCellNode().getBoundingClientRect() plus window.scrollY/X; on the PR build the editor is offset by the grid container's page position, on the published base it sits on the cell.
  • §3 header-menu alignment — run cypress/e2e/example-plugin-headermenu.cy.ts on Windows (Electron or Chrome) against the PR build; then run the next-v6 version of the spec against the published site with --config baseUrl=https://6pac.github.io/SlickGrid.

@6pac

6pac commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Hang on a minute, there's quite a bit of stuff in there that's specific to my computer and its environment. I'm just gonna remove that and repost.

@6pac

6pac commented Sep 18, 2026

Copy link
Copy Markdown
Owner

OK the evaluation has been updated

@ghiscoding

Copy link
Copy Markdown
Collaborator Author

wow that is a lot.... providing this to Codex, and we'll see what it's able to fix. Just curious, do you also have access to Fable 5.1? Seems like an improvement, probably more expensive though

Side note I also fixed colspan just now which can now spread on both side of the column pinning and also updated data Grouping which also spreads its grouping title (see above).

@6pac

6pac commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Yep, this review was done with Fable 5.1. It did take up about 35% of my weekly quota though! Which is fine, I usually don't use more than about 30% of it anyway.

Comment thread src/slick.grid.ts Outdated
const queueMicrotaskPolyfill = (callback: () => void) => typeof queueMicrotask === 'function' ? queueMicrotask(callback) : setTimeout(callback, 0);
const destroyAllElementProps = (_target: object) => undefined;
const destroyAllElementProps = (target: object): void => {
const elementProperties = [

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not really sure why it added all of these, this seems very overkill. Shouldn't it be able to destroy and remove whatever it needs without us having to name all functions? I assume it came from Claude report

@ghiscoding

ghiscoding commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

@6pac ok the AI is done with the audit report, the remaining things it said was basically verifying the UI myself... can you do a final audit to make sure it fixed everything. Also, can you ask it to see if it there's any areas to decrease LOC (I usually ask the AI if it's the most minimalist it can do without regressing). I'm especially concerned about the comment I left just above, I don't understand the point of listing all function names to loop and and destroy (this seems ridiculous and not minimalist to do this way). If there's anything else, I'd prefer you let it fix the rest... having a different AI model to double-check is actually a very good exercise, this will be used for the next few years, so better be good :)

ahh wait, last commit caused a few test failures, let me fix them

image

@ghiscoding

Copy link
Copy Markdown
Collaborator Author

@6pac ok I'm done and fixed Cypress failures, so would you mind addressing what I wrote above

@6pac

6pac commented Sep 18, 2026

Copy link
Copy Markdown
Owner

In the middle of a very busy workday, but I'll point Claude at it and see how it goes. The destroyAllElementProps issue looks like a reversion rather than something Claude suggested (it found an issue with that function, but that's not the suggestion it made), from what I can see. I'll query it.

@ghiscoding

Copy link
Copy Markdown
Collaborator Author

ah yeah it might have been my old code actually, but I think we can remove that or lighten it at least. Thanks.

There's no rush on it, need to make sure that we cover all angles and that the UI/UX works for you :)
Cheers

@6pac

6pac commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Here's the eval. Let me know if you're happy to go ahead and I can get Fable to do the final commits.

6pac-ai and others added 8 commits September 22, 2026 15:00
…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>
…wport 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>
…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>
- 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>
…ment

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>
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>
…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>
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>
@ghiscoding

Copy link
Copy Markdown
Collaborator Author

@6pac after replicating and pushing commits to my repo and give it a try, I can see that it's following the mockup provided above. The scroll does work as mentioned in the mockup but it added a cell border showing where the pinning crosses, but that is not what we would want, if I click a cell that crosses boundary it should be 1 clickable cell (the z-index 21 was hiding that fact before the recent changes). So it fixes a bug but also introduces a new unexpected regression

image

@6pac-ai

6pac-ai commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

@ghiscoding RTL is done — opened as #1306, stacked on feat/pinning-sticky so the diff there is only the RTL work. Take it or leave it as you prefer; it does not need to hold up this PR.

This was the one item the audit rounds deliberately left open. A right-to-left grid created the docking scrollbar but usesStickyColumnTransformPath() returned false for RTL, so the layout mixed the proxy scrollbar with the non-proxy geometry. On a 598px viewport the right-pinned column rendered at -859…-779 and an activated sticky column at -179…-99, both outside the viewport, the pinned row showed a different horizontal slice from the rows beneath it, and a point over a docked band resolved to the column mirrored across the grid.

The fix measures the docking geometry 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. getInlineDirection() is the single place the sign lives, and --slick-docking-direction carries it into the stylesheet. Six sites changed: the transform-path test, the trailing-band and sticky offsets, chrome placement (now through a setInlinePosition() helper), resolveColumns() taking the scroll position as an inline distance (a right-to-left browser reports scrollLeft as a negative offset), the docked-row overlay (anchored on the inline start and clipped from the leading edge), and getCellFromPoint().

One thing worth your eye, because it affects LTR too: getCellFromDockedPoint() was matching the centre band against the compacted offsets the band list carries, while the canvas renders centre columns at their natural offsets on the transform path. Those differ exactly when a sticky column is active, so a point could resolve to the neighbouring column. That is fixed in #1306 rather than here, but it is not an RTL-only bug.

The bands stay named for the reading order rather than the screen, so one configuration describes both directions:

Option Left to right Right to left
columns.left pinned at the left edge pinned at the right edge
columns.right pinned at the right edge pinned at the left edge
sticky: true docks at the left edge docks at the right edge

A stylesheet targeting slick-column-pinned-left therefore keeps working when the direction changes. docs/pinning-sticky.md has this, and the RTL limitation note is gone.

New examples/example-pinning-rtl.html (a pinned column at each edge, a sticky column between them, a pinned row, runtime pinning controls) and cypress/e2e/example-pinning-rtl.cy.ts with six cases, including a pointer round trip over every uncovered cell. Full suite 78 specs, 733 passing, 0 failing, 1 pending.

One trap that cost me some time and may catch users: a sticky column that never activates is usually the width budget rather than a layout bug. Pinned and sticky columns share docking.maxColumnViewportWidthPercent (60% by default), and permanent pins can consume all of it silently — my first example had 410px of pins against a 410px budget.

@6pac

6pac commented Sep 22, 2026

Copy link
Copy Markdown
Owner

@ghiscoding OK almost done, will deal with that last bug and request LOC reduction

@ghiscoding

Copy link
Copy Markdown
Collaborator Author

Also not sure if you've heard or not but both Claude and ChatGPT released new models today (Claude Opus 5.5 and ChatGPT 6 Sol/Luna), both of them decreased rates as well, so good news

6pac-ai and others added 2 commits September 23, 2026 10:57
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>
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>
@6pac

6pac commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Can the pinning/sticky PRs be made smaller?

Evaluation of #1302 (feat/pinning-sticky) and #1306 (feat/pinning-rtl) against
one question: which lines can go without costing readability or performance? Written
2026-09-23, updated the same day as items 1–4 were implemented.

Status: items 1, 2 and 4 done. Item 3 was implemented, measured, and reverted. Item 5 is open.
Net −71 lines in slick.grid.ts, all of it item 1.

Correction to this document's first draft. It estimated items 3 and 4 would save about 26
lines. Measured after implementing them, they cost 24: item 4 is line-neutral and item 3 cost
the lot. The original estimate counted the repeated expressions but not what replacing them takes
— a shared descriptor type, and branch scaffolding longer than the early-return branches it
replaces. Item 3 was reverted on those grounds, since it worked against the brief it was proposed
under. Item 4 was kept: it removes a triplication for nothing. See "What each item actually cost".

What the feature costs

Measured against the merge base 66e842ae, counting code, comment and blank lines separately:

File Base Head (before this work) Delta of which code
src/slick.grid.ts 9,589 11,658 +2,069 +1,760
src/slick.core.ts 1,545 1,883 +338 +298
src/models/docking.interface.ts 135 +135 +69
src/styles/_slick-docking.scss 378 +378 +281

About 2,900 lines, roughly 2,400 of them code. #1306 adds a further +135/−64 in
slick.grid.ts, a net +71 for complete right-to-left support.

Two things this ruled out before looking at any method:

  • It is not comment padding. Comment density in slick.grid.ts fell over the PR, 21.0% to
    19.6%, and in slick.core.ts 26.3% to 22.4%. Round 2 already condensed the narrative comments
    (11,727 → 11,558 lines). Trimming comments further would buy tens of lines and cost the reader.
  • It is not copy-paste. A duplicate-block scan over slick.grid.ts (5-line window, normalised
    whitespace) finds no repeated block introduced by the PR except two small pre-existing ones in
    resizeCanvas and the autosize loops. The docking code is dense, not repetitive.

The headroom was structural and modest. The realised figure is −71 lines, about 2.4% of the
feature — well under the 150–200 this document first projected, because four of the five items
turn out to be readability changes that shorten nothing, and one of those made the file longer.

Done

1. The non-proxy horizontal-scroll path was unreachable — 71 lines removed

applyDockingScrollOffsets() and applyDockingChromeScrollOffsets() both began with
if (this.hasDockingHorizontalScroller()) { return; }, and applyDockingScrollOffsetToRow() had a
second half that only ran in that same state. They were the pre-proxy design: they positioned
pinned rows and chrome by writing a transform per scroll event, which the compositor path replaced.

That state cannot occur. Docking-configured and proxy-scroller-exists are held in lockstep in
three places:

Site Code
init activateSingleViewportLayout(), which creates the scroller if (this.hasConfiguredDocking())
setColumns/pinning change if (this.hasConfiguredDocking() && !this.hasDockingHorizontalScroller()) { activate… }
setOptions if (!this.hasConfiguredDocking() && this.hasDockingHorizontalScroller()) { deactivate… }

Verified at runtime before removing anything. A probe instrumented the three methods to count
only invocations that would do work, then forced applyDockingScrollOffsets() to run in 25 states
across five examples — as loaded, scrolled, pinning added, pinning removed, pinning re-added:

example-pinning-columns-and-rows as loaded:     configured=true  scroller=true  invariant=holds dockedRows=16 bands=3+1 -> rowWork=0 chromeWork=0
example-pinning-columns-large as loaded:        configured=true  scroller=true  invariant=holds dockedRows=44 bands=3+0 -> rowWork=0 chromeWork=0
example-pinning-columns-large pinning removed:  configured=false scroller=false invariant=holds dockedRows=0  bands=0+0 -> rowWork=0 chromeWork=0
example1-simple pinning added:                  configured=true  scroller=true  invariant=holds dockedRows=26 bands=3+0 -> rowWork=0 chromeWork=0
…25 states, invariant held in every one, zero work in every one

A first attempt at this probe counted calls during scrolling and reported all zeros — but the call
counters were also zero, so the scroll never reached the code and the run proved nothing. The
version above calls the method directly, which is why it is evidence rather than absence of it.

Removed: applyDockingScrollOffsets(), applyDockingChromeScrollOffsets(),
applyDockingScrollOffsetToRow() and its four call sites, and the dispatch in _handleScroll.
The row helper went entirely rather than keeping its proxy half: that half only cleared inline
transforms on the cell regions, and the deleted tail was their only writer — the proxy stylesheet
sets those transforms with !important, so an inline value never had any effect anyway.

cypress/e2e/quirk-docking-scroll-owner.cy.ts now guards the invariant that made this safe,
across both a docking and a non-docking example.

2. The chrome offset formula existed twice — resolved by item 1

placeDockedChromeElement() and applyDockingChromeScrollOffsets() each computed a column's
natural and docked offsets, the second being the first generalised to both bands. Two copies of
the same geometry is how a mirroring change goes half-applied — the RTL work had to touch exactly
these expressions. The duplicate went with the method; grep now finds one site.

4. One rejection helper replaces three copies — line-neutral

if ((forceAlert || !this._invalidPinningAlerted) && this._options.invalidColumnPinningXCallback) {
  this._options.invalidColumnPinningXCallback(this._options.invalidColumnPinningXMessage!);
  this._invalidPinningAlerted = true;
}
return false;

This appeared three times in the validation methods, differing only in callback and message. It is
now rejectPinning(callback, message, forceAlert), which returns false so each site is a single
return. The helper costs 12 lines and the three sites give back 12, so the file is the same
length and the alert-once rule lives in one place instead of three.

Tried and reverted

3. A placement descriptor for placeDockedChromeElement() — cost 25 lines, reverted

placeDockedChromeElement() is 73 lines in four branches (sticky transform, centre, left band,
right band), each assigning position, left, right, order, transform and
--slick-docking-chrome-offset in its own way before returning. The proposal was for each branch
to produce a DockedChromePlacement that one writer at the end applies, so that a branch cannot
silently omit one of the six — today that is guaranteed only by reading all four.

It was implemented and it worked, with no performance change: every branch already wrote all six
properties, and they were still written once each with no layout read between them. But it made
the file 25 lines longer, so it was reverted.

Where the estimate went wrong is worth recording, because it applies to this kind of refactor
generally:

Attempt Method Plus interface Against 73
Resolve and write as two methods 101 +15 +43
One method, if/else chain, single writer 84 +15 +25

The two-method split repeats the eight-line parameter list, which costs more than the repetition
it removes. Even collapsed, a descriptor object literal per branch is longer than the sequence of
element.style.x = assignments it replaces, and the shared type has to be declared somewhere. The
original estimate of −18 counted only the repeated property names.

The invariant argument still stands, so this is worth revisiting if the method ever grows a fifth
branch — at which point the scaffolding is amortised over five branches instead of four.

Still open

5. resolveColumns() two-sided reconciliation — about 12 lines

The block that stops a sticky: 'both' column occupying both bands builds two Maps and then
splices each list by findIndex. Comparing the two distances while partitioning in one pass over
activeLeft would be shorter and would not need the maps. Left alone for now; worth doing only if
that method is being touched anyway. On the evidence of items 3 and 4, treat the 12 as optimistic
until measured.

Not worth doing

  • Nesting the stylesheet. 29 top-level rules in _slick-docking.scss begin with
    .slick-row-docked. Collapsing them into one nested block trades repeated selector text for
    indentation and braces — close to line-neutral, and it makes a full selector no longer greppable.
  • Merging the symmetric sticky-activation loops in resolveColumns(). The left loop walks
    forward and the right one backward with unshift; one direction-parameterised loop is shorter
    and materially harder to read.
  • Merging validatePinnedColumnIndexes() and validateColspanPinningSequence(). They answer
    different questions and the second is already called by the first.
  • The three-pass split in applyDockingToColumnChrome(). It looks verbose but it is the
    reason the pass forces one layout instead of one per column, and the getComputedStyle()
    memoisation is what removed two calls per column. This is performance, not ceremony.
  • The RTL PR. +135/−64 for the whole feature, and its three new helpers are each used at
    several call sites (getInlineDirection() 6, setInlinePosition() 4,
    getInlineOffsetFromLeft() 2). Nothing to take out.
  • Dead types or options. Every type in docking.interface.ts and every field of
    DockingOption is referenced; there is no unused surface to delete.

What each item actually cost

Item First estimate Measured Status
1. Unreachable non-proxy scroll path −55 −71 Done; runtime-verified across 25 states, invariant spec added
2. Duplicated chrome offset formula −10 0 Done, subsumed by item 1 rather than separate
3. placeDockedChromeElement descriptor −18 +25 Implemented, measured, reverted
4. Pinning rejection helper −8 0 Done; triplication gone at no cost
5. Two-sided reconciliation −12 not attempted Open

slick.grid.ts goes from +2,069 over base to +1,998, and the feature from about 2,900
lines to about 2,829.

The honest summary: one item was a real removal and the rest are readability. If the goal is
strictly fewer lines, item 1 is the whole story — item 3 worked against it and was reverted, and
items 2 and 4 cost nothing but buy nothing either in line terms.

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>
@ghiscoding

ghiscoding commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator Author

I ran similar audit with ChatGPT Astra and .... ran out of my 6 hours tokens, so I'll provide the final result tomorrow, but there's still potential. Below is what it wrote so far:

Audit in Progress Summary

The local branch includes the reported 71-line removal. I also found the two-sided reconciliation pattern in both the column and row resolvers, which gives us another candidate to measure. I’ll test temporary refactors outside the source tree so the audit can distinguish measured savings from estimates.

There’s a stronger candidate in the scroll path: it still walks rendered rows and pinned headers to write transforms that CSS already supplies. Removing that work may reduce both code and per-scroll cost; I’ll check it in the browser.
One discrepancy in the pasted report: the claimed rejectPinning() helper is absent from this checkout. I’ll base the audit on what’s actually here.

The resolver prototype matched the current implementation across 240,000 layout comparisons, including revision counters. Its line savings are small: six lines for the column resolver and none for the row version. The local timing runs were slightly faster, though they don’t establish browser performance.
I also inspected the cached RTL branch. It may make a larger fallback renderer removable, but that depends on lifecycle checks and is separate from what can safely shrink on the current branch.

The browser check caught a reason not to delete the whole scroll helper: a column with both pinned and sticky set still relies on its inline header transform. The proposed deletion moved that header.
I’m narrowing the candidate to the rendered-row scan and keeping the header updates. This is exactly the kind of edge case that a line-count reduction needs to survive.

@6pac

6pac commented Sep 23, 2026

Copy link
Copy Markdown
Owner

re the rejectPinning() comment: It's on this branch, but it's probably absent from the RTL branch because it was based on an earlier version of this branch. Rebasing the RTL branch now.

@ghiscoding

ghiscoding commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator Author

ok so here's the final report, please note that when I started the audit, I didn't have your last commit without the 71 lines removal and it might be part of the audit, so below is the full audit and I haven't changed any file, it's just meant to provide to your Claude and let it analyze it (it could be wrong) and if Claude verified it, then let it do the rest. I also can't run these audits too often because just this single audit burned 40-45% of my small weekly plan, so anyway here it goes...

Pinning/sticky library LOC audit

Scope and conclusion

Audited local feat/pinning-sticky at commit 3cb40e181, plus the cached RTL branch at 61fc00a94.

Scope includes library implementation and styles. Examples, demos, Cypress files, interfaces, and generated output are excluded from findings and savings. Existing example pages were used only as browser fixtures.

Another 54 physical source lines can potentially be removed, beyond the previous 71-line cleanup, without removing features or compressing formatting.

These savings were measured using temporary candidate files. No changes were applied to the repository, and its working tree was restored to clean.

The candidates passed focused checks, but this is not a guarantee against every regression or performance impact. The normal regression suite should run before implementation is merged.

Measured opportunities

Candidate Lines saved
Remove the rendered-row scan in applyDockingProxyScrollOffsets() 14
Merge only the sticky/center chrome-placement branches 8
Reuse getVisibleColumnIndexes() in numeric pinning normalization 8
Simplify two-sided sticky reconciliation 6
Share identical header geometry declarations 11
Share pinned/sticky separator pseudo-element setup 7
Total 54

Counts are physical source lines, including block boundaries. The candidates preserve explanatory comments.

1. Remove the redundant rendered-row scan — 14 lines

Location: src/slick.grid.ts, applyDockingProxyScrollOffsets().

The method enumerates rowsCache, identifies full-width group cells, and writes their horizontal transforms.

The stylesheet already provides that transform through the inherited --slick-docking-scroll-left custom property.

The candidate removes the row enumeration and its unused value variable, while retaining:

  • The shared scroll-variable update.
  • The permanent-column header-transform loop.

This eliminates an array allocation, row checks, group-cell lookups, and associated inline writes from each horizontal scroll.

Do not remove the entire method. A broader deletion failed browser comparison when a column had both pinned and sticky configured. Its header could lack the pinned CSS classes while remaining in the permanent docking list, making the inline header transform significant.

2. Merge only the sticky/center placement branches — 8 lines

Location: src/slick.grid.ts, placeDockedChromeElement().

The previous review tried introducing a placement descriptor, which increased LOC. That does not rule out a narrower refactor.

The sticky-transform and center branches share:

  • Clearing the chrome-offset custom property.
  • Left/right coordinate writes.
  • Resetting order and transform.

They can share one block while preserving:

  • Natural versus compacted offsets.
  • Absolute positioning for sticky non-header elements.
  • The conditional applyStickyColumnTransform() call.

The measured candidate saves eight lines without a new type, descriptor object, additional DOM reads, or extra style writes.

3. Reuse visible-column normalization — 8 lines

Location: src/slick.grid.ts, normalizeColumnPinningReferences().

The numeric-reference path contains the same reduction already implemented by getVisibleColumnIndexes().

Replace it with the existing helper.

Its separate empty-visible-list return is also redundant: the existing count === 0 guard handles that case before either slice operation.

This preserves the algorithm and avoids adding another abstraction.

4. Use one ordered map for reconciliation — 6 lines

Location: src/slick.core.ts, resolveColumns() and resolveRows().

Currently, reconciliation builds maps for both bands and removes conflicts with findIndex() and splice().

The candidate:

  1. Builds a map for the trailing band.
  2. Filters the leading candidates using the existing distance comparison.
  3. Deletes trailing entries when the leading candidate wins.
  4. Uses the remaining map values as the trailing candidates.

It preserves insertion order and the existing leading-edge tie rule.

Measured savings:

  • Columns: six lines.
  • Rows: line-neutral.

The row change is optional if minimizing diff churn is the priority.

This makes the reconciliation step linear rather than potentially quadratic. It does not make the entire resolver linear: other existing operations, including sorting and right-side activation, remain.

5. Share header geometry declarations — 11 lines

Location: src/styles/slick.grid.scss.

The .ui-state-default and .slick-state-default header rules repeat substantially identical geometry declarations.

Group those selectors for the shared geometry, while keeping display and float specific to the legacy selectors.

The candidate preserves selector specificity and retains the comments.

6. Share separator pseudo-element setup — 7 lines

Location: src/styles/_slick-docking.scss.

Pinned and sticky separator pseudo-elements repeat the same declarations:

  • content
  • position
  • inset
  • pointer-events
  • z-index

Group their existing selectors for those declarations. Keep the left/right shadow rules separately ordered.

This saves lines without nesting the stylesheet or changing selector specificity.

Verification

Completed against temporary candidates:

  • Production build of the original checkout passed.
  • Candidate TypeScript passed the repository type-check and ESLint configuration.
  • 240,000 complete resolver comparisons matched across 20,000 deterministic input sets, including revision counters.
  • 229,348 normalization comparisons matched, covering visibility masks through 12 columns, both edges, empty lists, and valid/invalid numeric references.
  • 3,024 placement combinations matched final style properties and downstream helper calls.
  • 240 browser-state comparisons matched cell/chrome bounds and computed transforms for the narrowed row-scan deletion.
  • Both stylesheet candidates compiled with Sass. Ordered selector/value/priority entries matched separately for every CSS property, normalizing equivalent 0px and 0 values where needed.

Browser coverage used six fixture pages, both bundled themes, configuration changes, and multiple scroll positions. It included grouping, variable-height rows, clearing/re-adding docking, and columns configured as both pinned and sticky.

Cypress's Electron process exited with status 132 before running tests, including outside the sandbox. Browser comparisons therefore used installed headless Brave through CDP.

These focused checks do not replace the full regression suite or cross-browser testing.

Performance evidence

Five alternating local Node timing rounds of mixed column/row resolution measured approximately:

  • Original: 203–210 ms
  • Candidate: 192–196 ms

This supports the reconciliation change but is not a browser frame-rate or allocation benchmark.

The stronger performance argument is structural: the scroll candidate removes per-rendered-row work, and reconciliation removes repeated searches and splices.

Corrections and qualifications to the previous review

The reported rejection helper is absent locally

The claimed rejectPinning() extraction is not present at audited commit 3cb40e181; the three callback blocks remain.

It is not counted in this audit’s savings.

The proxy-scroller invariant is not universal

On a freshly loaded ordinary grid, this public call produced configured docking without a proxy scroller:

grid.setOptions(
  { pinning: { columns: { left: 0 } } },
  false,
  true
);

Observed:

configured: true
scroller: false

The third argument suppresses setColumns(), which performs lazy proxy creation.

This qualifies the earlier dead-code argument. It does not, by itself, prove that the previous 71-line deletion introduced a regression.

“Three passes” does not prove one layout flush

placeDockedChromeElement() still reaches geometry reads through getRightDockedChromeLeft() during the write pass.

Retain the existing batching and measurement cache, but do not treat the pass labels as proof that every path forces at most one layout.

Larger conditional opportunity after RTL

The old sticky DOM-reparenting renderer deserves a separate reachability audit after RTL integration:

  • updateRenderedCellDocking()
  • The fallback block in enqueueStickyColumnLayout()

Those chunks account for 88 physical lines in the audited checkout, before surrounding simplification.

They are not included in the 54-line total:

  • The current branch still routes RTL through that fallback.
  • Suppression flags and queued callbacks require lifecycle verification.
  • The configured-docking/proxy invariant cannot currently be assumed universally.

Recommendation

Consider the 54-line set as modest, concrete library simplifications.

Keep the activation loops, virtualization, geometry guards, reference caches, and measurement batching. Avoid broad descriptor frameworks or comment trimming solely to meet a LOC target.

The previous review was broadly correct that the remaining headroom is modest, but its conclusion that the original 71-line removal was effectively the whole opportunity was too restrictive.

@ghiscoding
ghiscoding added this pull request to stack #1307 September 23, 2026 20:26
@6pac

6pac commented Sep 23, 2026

Copy link
Copy Markdown
Owner

@ghiscoding They are all fairly small savings, but happy to go ahead. Do you want to do it, or will I feed this into Claude?

@ghiscoding

ghiscoding commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator Author

@ghiscoding They are all fairly small savings, but happy to go ahead. Do you want to do it, or will I feed this into Claude?

can you just feed it to Claude? I'd like Claude to confirm the logic and make the change if possible.... I'm working on another bug I found on Firefox

6pac-ai and others added 2 commits September 24, 2026 11:17
…lly 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>
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>
@6pac-ai

6pac-ai commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

@ghiscoding Confirmed and implemented. Thanks for running that audit — it was worth the weekly budget, because it caught something I had genuinely got wrong.

The correction first: your invariant counterclaim was right, mine was over-stated

When I removed the 71 lines I claimed in the commit message, and asserted in quirk-docking-scroll-owner.cy.ts, that configured docking and the proxy scrollbar are always in step. They are not, exactly as you found — setOptions(…, false, true) skips the lazy creation in setColumns(), while internal_setOptions() only ever deactivates. Reproduced and extended with a real pin count rather than left: 0:

state configured scroller bands chrome map work the removed code would do
plain grid false false 0 0 none
left: 2 + suppressColumnSet true false 3 0 none
…then a horizontal scroll true false 3 0 none
after setColumns() true true 3 6 guard returns first

You were also right that this doesn't prove a regression, and the last two columns are why: in that state the chrome map is empty and no row is docked, so the deleted code had nothing to act on. The deletion stands, but the claim about it was too broad.

The spec now asserts the narrower property the single scroll path actually relies on — nothing is rendered as docked unless the scroll owner exists: no docked rows, no pinned header classes, and no band entry resolving to a chrome element. It covers your suppressColumnSet state directly, including a scroll while half-configured. That's e672f2f7.

One thing that surfaced while fixing it: dockingChromeByColumn still holds element references after setOptions({ pinning: null }) — 11 in my run. Harmless, since no band resolves to them, but it's a cache holding DOM references until the next render. Noted, not fixed.

Implemented: candidates 1, 3 and 6 — d17e1046

1. The rendered-row scan (14 lines). Confirmed. _slick-docking.scss already applies the identical translate3d from the custom property the method publishes a line earlier.

Your instinct to flag the narrow-vs-broad deletion was the right one, and there was a second trap in it worth recording: the CSS needs the cell to be a direct child of the row, while the JS looked it up through cellNodesByColumnIdx first. A nested cell would have been missed by the CSS and the inline write would have been doing real work. It can't be nested — getRowDockingRegion() returns the row itself when the row carries slick-row-full-width-group — so the CSS rule and the deleted branch have identical preconditions. The permanent-column header loop stays, as you said.

3. Reusing getVisibleColumnIndexes() (9 lines). Confirmed; the inline reduce is character-for-character that helper. The redundant early return checks out too: with an empty visible list count is Math.min(requestedCount, 0), which is 0 on both sides, so the existing count === 0 guard already returned.

6. The separator pseudo-elements — 13 lines, not 7. The five base declarations repeat as you say, and so does the box-shadow: the two left-edge rules carry an identical value, as do the two right-edge ones. Those merge as well, left with left and right with right, so cascade order is unchanged. I checked this one on the built CSS rather than the source, since regrouping selectors is exactly the change that reads fine in SCSS and differs in output — all 12 separator selectors across the three stylesheets keep the same six declarations, with inset -1px 0 0 on every left selector and inset 1px 0 0 on every right one.

The second correction: candidate 2 should wait

Merging the sticky/center placement branches is sound in itself, and a fair narrowing of the descriptor refactor I tried and reverted for costing 25 lines. But it shouldn't be done against the audited commit: #1306 has already rewritten placeDockedChromeElement() and removed getRightDockedChromeLeft() entirely. Writing it now means writing something the RTL merge will largely undo. Worth doing afterwards, against the RTL version.

That also resolves your "three passes" point, which is correct as stated — getRightDockedChromeLeft() does two getBoundingClientRect() calls and reads offsetWidth/clientWidth, from inside the write pass. On the RTL branch that method is gone and the placement path has no geometry reads at all, so the interleaving disappears when #1306 lands. The batching and the class-signature cache stay either way.

The rest

Candidate 4 (two-sided reconciliation) I've left open rather than guessed at — no shipped example configures sticky: 'both', so it needs exercising deliberately rather than leaning on the suite. Happy to take it next.

Candidate 5 (header geometry in slick.grid.scss) I'd rather not do here: those rules are pre-existing and not part of this PR, and regrouping shared theme selectors risks specificity and ordering changes across both bundled themes and any downstream theme, for 11 lines. Separate PR if you want it.

The 88-line reparenting renderer — agreed on both the deferral and the reason. The right moment is after #1306 merges, using the same method as the 71-line removal: instrument for work actually done, force the path, then delete.

So about 36 lines landed here against your 54, with the remainder real but belonging elsewhere. Your closing judgement was right that calling the 71-line removal the whole opportunity was too restrictive.

Also, separately — the colspan boundary you reported

Sorry for the slow reply on that one. Both seams are fixed in d517c058:

  • The active-cell outline was the one in your screenshot. 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. Fixed in logical properties, so it holds in both reading directions.
  • A second, theme-dependent seam you couldn't have seen: every piece is a .slick-cell, so each draws the theme's border-right, and the host's lands mid-span. Every shipped colspan example uses alpine, which draws no cell border at all. With the stock slick.grid.css it shows. The piece whose right edge is shared now hides it — colour, not width, so nothing moves.

On the second half of your comment, though: it was already one clickable cell. onClick and the active cell both report {row: 1, cell: 1} from either half, because the continuation is cloned from the host and carries its l1 r3 classes. getCellFromPoint does return the geometric column over the continuation, but that isn't a pinning regression — on the same example with no pinning at all it returns 1, 2, 3 across the span. The base hit test has never been colspan-aware.

The case that encoded the old behaviour (expect(hostActiveStyle.borderRightStyle).to.eq('solid')) is corrected, and #1306 now has an RTL grid with a span crossing the pinned boundary, which nothing exercised before.

Both branches are green: #1302 at 78 specs / 732 passing, #1306 at 79 / 739.

@ghiscoding

ghiscoding commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

ahh Claude reported couple of min after my post, so let me repost after Claude comment....

@6pac I was trying to fix the new Firefox bug issue that I found but I don't have enough credit to fix the rest and the cheap models aren't too good at fixing it, but I asked it to provide summary of the problems. Also it seems that both Claude and my ChatGPT completely ignored Firefox and that's the start of the problem, so would you mind providing this summary to Claude and hopefully it can fix it better than my AI. Hopefully this is the last thing left to do on the PR since I think you have completed on your side, is that correct?

Here's a print screen of the Firefox scrollbar problem

image

and below is the AI summary of the problem, please give this to your Claude

Firefox scrollbar issue

Reproduction

In examples/example-pinning-columns-and-rows.html, Firefox shows scrollbar and pinned-row artifacts that do not appear in Chrome or Brave. The example has 50,000 rows, a filter row, two pinned columns on the left, one on the right, two pinned rows at the top, and the last row pinned at the bottom.

Firefox hides its scrollbars until hover. When the scrollbar appears, the grid can show what looks like a second scrollbar or “ghost” thumb. The native vertical thumb can also look partly obscured: scrolling down by 1–2 pixels reveals the full thumb at its expected position. This suggests a paint, clipping, or stacking issue; the thumb itself may not be positioned incorrectly.

Other visible artifacts include a misaligned bottom pinned-row border while the grid is idle, white strips around the scrollable area, and changes to those artifacts on hover. The horizontal scrollbar region also leaves a strip at the bottom.

Scroll structure

The grid’s .slick-viewport is the vertical scroll owner. A separate .slick-docking-horizontal-scroller synchronizes horizontal scrolling; it has overflow: auto hidden, a 15px height, and a spacer wider than its viewport. That bottom region is part of the horizontal scrolling setup and may account for some reserved space, but it should be evaluated separately from the vertical scrollbar artifacts.

Relevant source locations:

  • src/slick.grid.ts:144 — default docking scrollbar height.
  • src/slick.grid.ts:9928 — horizontal docking scroller creation.
  • src/slick.grid.ts:10975updateDockingOverlayDimensions.
  • src/slick.grid.ts:10988updateDockingOverlayClip.
  • src/slick.grid.ts:11006 — horizontal scroller sizing.
  • src/styles/_slick-docking.scss:225 — docking overlay styles.
  • src/styles/_slick-docking.scss:234 — horizontal scroller styles.

The current updateDockingOverlayClip does not inset for scrollbar width. Its comment explains that a guessed inset had clipped right-pinned cells and allowed a duplicate sliver to paint.

Fixes previously attempted

  1. A Firefox-specific scrollbar width was guessed as 8px, and a fake gutter element was added inside the viewport to imitate the scrollbar region and pinned-row backgrounds.
  2. The docking overlay clip was inset by that guessed width to prevent overlay content from painting over the native scrollbar.
  3. The horizontal scrollbar proxy was given a 15px fallback height.
  4. The gutter and clip changes were removed, which made the display worse; they were restored.
  5. Further speculative adjustments tried to align the bottom pinned-row border, replicate its border in the gutter, hide the gutter on viewport hover, and change the hover clip by another 4px.

These approaches did not resolve the artifacts. The guessed scrollbar dimensions and compensating offsets were not validated against Firefox’s actual scrollbar geometry and hover behavior.

What to investigate next

Measure the viewport, overlays, clip paths, and scrollbar bounds in Firefox both before and after hovering, and compare their stacking and paint order. Check whether the native scrollbar changes width or opacity on hover, and whether pinned overlays or the horizontal proxy overlap its area. Verify whether the apparent second track is an actual scroll container or only an overlay/paint artifact.

Keep the bottom horizontal scrollbar region separate from the vertical scrollbar investigation. Avoid moving the native thumb by an arbitrary pixel offset: the 1–2px scroll observation points toward something obscuring its initial rendering.

Extra Notes - Chrome also has vertical bug problem

When resizing any columns larger than the viewport, the overlay horizontal scrollbar will show over the data row which is partially hiding the data row behind it. The horizontal scrollbar should be shown below the data row, not over it.

@6pac

6pac commented Sep 24, 2026

Copy link
Copy Markdown
Owner

OK, I'll add that to the list. However I am running into a couple of issues with the LOC removal. One is that the RTL PR refactors some of the sections that are being trimmed. I split that out so you could evaluate it separately. Could you have a look and if you are happy with it I might merge it back into this PR so that the LOC reductions are no longer blocked.

My 2c: I think it's very important to support RTL. We've just never had anyone with the expertise to know how to come up with a good spec for it (or the time to make the changes).

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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

3 participants