Skip to content

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

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

feat: 3x3 frozen band model - right-frozen columns + simultaneous top/bottom frozen rows (ViewportMgr Phase 4)#1238
6pac wants to merge 43 commits into
masterfrom
viewportmgr-phase4

Conversation

@6pac

@6pac 6pac commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

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

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

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

Hardening

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

Testing

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

🤖 Generated with Claude Code

@6pac

6pac commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Freeze activation options — ViewportMgr refactor (SlickGrid)

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

Option surface

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

Activation model

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

Plugin-facing band API (for completeness)

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

@6pac

6pac commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

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

@ghiscoding

Copy link
Copy Markdown
Collaborator

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

image

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

6pac commented Jul 16, 2026

Copy link
Copy Markdown
Owner Author

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

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

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

@ghiscoding

ghiscoding commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6pac and others added 9 commits July 18, 2026 18:15
…lestone 15)

New public band facade on the grid — getFrozenBandCounts() (copy of the band-count
view) and getFrozenRightStartIndex() (safe membership boundary) — and the
cellrangeselector plugin now drives its band logic from it instead of raw frozen
options. Beyond the migration this fixes two real gaps:
- the plugin's cross-canvas measurement now respects the 'frozenBottom is inert
  when frozenBottomRow is set' rule (it previously read the raw flag and would
  measure the wrong canvas in simultaneous mode);
- drags starting in right-frozen or bottom-frozen canvases get correct pixel
  offsets, and range extension is clamped at the right-freeze and bottom-freeze
  boundaries (previously zero offsets and no clamping — those bands postdate the
  plugin).
Preserved exactly: the degenerate frozenRow: 0 clamp semantics (documented raw
activity flag) and the original clamp's operator-precedence shape.

Full suite verified green BEFORE this commit — twice (interrupted run completed
green + clean re-run): 632 tests, 631 pass / 1 pending each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hase 4, milestone 16)

internal_setOptions now carries the same cacheCssForHiddenInit/
restoreCssFromHiddenInit wrap that initialize() and autosizeColumns have always
used — a no-op for visible grids, and correct layout measurements when options
change (including band materialization) while the container or an ancestor is
display:none. Closes the hidden-init item deferred from Phase 3.

The other deferred item — pane removal on un-freeze — is formally closed as
WON'T-DO: removal would violate the element-identity invariant the adversarial
audits established as load-bearing (plugins cache canvases at init; rowsCache row
nodes live inside band canvases), would require slot re-indexing and group-scoped
unbinding, and offers no functional gain over the historical hidden-pane
behaviour. Recorded in KNOWN-QUIRKS.md as final design decision #9.

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

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

Moves the ViewportMgr class (~1,500 lines) out of slick.grid.ts into slick.core.ts
— the one file every script-tag page already loads before the grid — using the
established cross-file pattern: exported from core, registered on the SlickCore
namespace object (alongside DragExtendHandle, the live precedent for a grid-support
class in core; TreeColumns is the historical one), consumed by the grid via
'import { ViewportMgr as ViewportMgr_ }' + 'IIFE_ONLY ? Slick.ViewportMgr :
ViewportMgr_'. The five geometry/state interfaces move to
src/models/viewportMgr.interface.ts per house convention (type-only, excluded from
iife builds), re-exported from models/index.ts; the Slick global declaration in
global.d.ts gains the member.

Zero consumer impact: no new script tag (a standalone slick.viewportmgr.js would
have broken script-tag pages — the Phase 1 rationale, now resolved by core
placement), DOM byte-identical, esm/cjs pick the export up via index.ts.
ViewportMgr is now independently importable (e.g. for future unit tests).

Verified: iife smoke test (Slick.ViewportMgr resolves and instantiates like
DragExtendHandle), gates across script-tag and esm pages, and full suite green
BEFORE this commit: 632 tests, 631 pass / 1 pending.

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

Implements the dual-labelling scheme (BAND-LABELLING.md): the historical
positional css classes stay untouched as a fixed legacy skin, and every
pane/viewport/canvas that currently participates in the layout carries
data-colband (left | main | right-frozen) and data-rowband (header |
top-frozen | body | bottom-frozen) attributes stating its CURRENT role.
Inactive elements carry no markers, so [data-colband=main] etc. uniquely
select live elements with no mode logic. Markers refresh on every freeze
application (applyBandMarkers, called from applyPaneVisibility — the same
trigger as the dynamic frozen class), covering init-time and runtime changes.

This classifier becomes the single source of truth for the M18 pane-matrix
loops, so the legacy-name mapping cannot drift from the internal band model.
Six new spec assertions pin the markers across non-frozen, frozen-both,
right-frozen and simultaneous configurations.

Full suite verified green BEFORE this commit: 636 tests, 635 pass / 1 pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All four creation paths (classic build, lazy secondary materialization, the
right-frozen band, the bottom-frozen band and its shared corner) now flow
through one buildPaneSet primitive driven by the structural (row x column)
pane matrix and the legacy-class tables; shared element arrays rebuild IN
PLACE in canonical order via syncElementArrays (strengthening the shared-array
identity invariant — the array objects now never change), which also derives
the dynamic-band slot registry. The 60 named element fields become one-line
compat getters over the matrix with identical runtime semantics (undefined
until built), so every consumer — grid aliases, geometry, visibility,
markers — compiled unchanged.

Preserved verbatim: pane sibling order, per-pane child order, the left
pre-header anonymous leading div, footer R-before-L creation and its
init-vs-materialization spacer-width difference, hide-on-create option
handling, and lazy/materialized insert positions.

Net -133 lines (+277/-410). DOM byte-identical: all five DOM-shape suites
passed first-run against the matrix build; full suite verified green BEFORE
this commit (636 tests, 635 pass / 1 pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three-branch width distribution (left-frozen / right-frozen-only / plain,
plus the bottom-frozen and right-frozen blocks) collapses to one loop over
per-column geometry records. Historical value rules preserved verbatim and now
explicit: header element widths write whenever the element exists (a plain
grid writes headerR = 0 — historical); the main band chrome width is the FULL
row width when no left freeze is active; the classic bottom row width-sizes
its left pane only under a left freeze and gives its middle pane left-but-no-
width, while the bottom-frozen band sizes both everywhere; spacers remain the
classic left/right pair only, never RF.

Net -64 lines (+78/-142). Geometry gate 77/77 first-run; full suite verified
green BEFORE this commit (636 tests, 635 pass / 1 pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and rule (M18d)

- applyPaneHeights: two-phase loop over the pane matrix. Phase 1 places
  paneTopL (offset WITH the historical fallback), reads the shared bottom
  offset from paneTopL.offsetTop mid-sequence, then places the secondary
  columns (offset recomputed WITHOUT the fallback - historical asymmetry
  preserved). Bottom row keeps the plain-layout quirk of width:100% on the
  left pane; frozen-band canvas heights and the bottom-frozen band loop
  over the same column list.
- applyBandMarkers deleted: visibility and data-colband/data-rowband
  markers now derive from one (row, col) activity rule inside
  applyPaneVisibility. The historically never-toggled left header/top
  panes remain exempt from show/hide.
- syncVerticalFollowers: Y-follower bands loop via followerViewport(col).

No behavior change: tsc clean, eslint clean, gate suites 72/72, full
suite 636 tests / 0 failing. Net -103 lines (M18 running total -300).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three mode-dependent document.querySelector dances in handleDragInit now
use the band-truth markers (BAND-LABELLING.md):
- frozen classic-band height measurement: grid-canvas-{bottom|top} ternary ->
  [data-rowband=bottom-frozen|top-frozen] (degenerate frozenRow: 0 yields a
  null match and the offset stays 0, same as measuring the 0-height canvas)
- left-band width: .grid-canvas-left -> [data-colband=left]
- right-frozen offset: the positional left + conditional right pair collapses
  to a mode-independent left + main sum (left contributes 0 when absent)

The four pane-IDENTITY flags stay positional-class based, with a comment
explaining why: markers state band role, which conflates the legacy
frozenBottom classic canvas with a bf-band canvas and both degenerate
frozenRow: 0 canvases - the offset/clamp math needs pane identity.

No behavior change: tsc/eslint clean, drag+RF/BF gate 87/87, full suite
636 tests / 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six-dimension adversarial equivalence audit of the M18 series (creation
paths, widths, heights/visibility/markers, compat getters + shared arrays,
cellrangeselector, cross-cutting invariants) against pre-M18 (484988d^).
Everything verified equivalent except:

- cellrangeselector: add a positional fallback to the frozen-band marker
  query. Restores byte-equivalence in the degenerate frozenRow: 0 variants
  (incl. the frozenBottom body-height quirk) and covers stale-marker
  suppressColumnSet transition windows.
- materializeRightFrozenBand: drop the ensureBottomFrozenRightVariant call
  from the already-exists early return (restores exact pre-M18 shape). The
  state it guarded is unreachable, and if it ever fired the corner would be
  created without pane events (the grid caller binds nothing on that path).

Accepted deltas documented as KNOWN-QUIRKS #10-13: canonical shared-array
band order, impossible-state TypeErrors -> silent skips, read-only compat
getters, markers-stale-when-visibility-stale.

Gates: tsc/eslint clean, shape+band+drag gate 74/74, full suite 636 / 0
failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@6pac-ai
6pac-ai force-pushed the viewportmgr-phase4 branch from 71d79e1 to 6250f44 Compare July 18, 2026 09:48
6pac-ai and others added 10 commits July 18, 2026 22:15
First slice of the ViewportMgr facade (FACADE-FEASIBILITY.md). slick.core.ts
gains BandSet (per-role l/r/rf collections: at/pick/forEach/empty/width/
setStyle/query, iterating only materialized bands) and CellSet (pane matrix
cells), 16 cached collection getters wrapping the SAME live shared arrays
(identity contract intact), and live scroll-container getters that recompute
via selectScrollContainers per access - the stale-owner failure mode is gone
rather than mitigated. Four raw arrays renamed *Arr to free the getter names.

slick.grid.ts deletions, compiler-proven complete: ~55 alias fields (incl.
three dead _groupHeaders* fields), the 61-line syncViewportMgrAliases, the
per-alias destroy nulling block, setScroller. 170 alias reads re-pointed at
the facade. Semantic conversions in this slice (broadcast-write / geometry /
public-api categories): createColumnHeaders reads like the pre-frozen
original (headers.empty() + headers.width({l,r,rf})); footer resets keep the
historical asymmetries explicit (pick(l,r); R gated on hasFrozenColumns
inline, per-band event/empty interleave preserved); sort-indicator clearing
via headers.query(); panel toggles feed collection elements; measurement
[0] reads become named first() calls.

Preserved contracts: getFooterRow still throws without createFooterRow;
Sortable.create(headerR) undefined-tolerance; getCanvases()/getViewports()
return the same live array objects. Behavior notes: post-destroy alias reads
were null, now dereference the nulled ViewportMgr (unreachable - handlers
unbind first); getHeadersWidth() called once instead of once per band in the
init broadcast (idempotent recompute).

tsc/eslint clean; gate 98/98; full suite 654 / 0 failing. Net -105 lines.

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

Pins, at exact boundary indices across three freeze configurations:
- cross-band getHeaderColumn/getHeaderRowColumn/getColumnByIndex routing at
  the left-freeze and right-frozen boundaries
- the INCLUSIVE <= frozenColumn / <= frozenRow comparisons, incl. the
  historical off-by-one where the row equal to frozenRow carries the frozen
  class but renders in the scrollable canvas (classic and simultaneous modes)
- the frozen-class split: header cells left-band-only, data cells left OR
  right band
- scrollCellIntoView early-outs for both frozen sides (boundary inclusive)

All 14 tests pass against current code before any routing logic moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BandSet gains the cross-band column-cell conventions: cells()/cellAt()/
forEachCell() thread the continuous visible-column index across containers
in band order; containerForColumn() is the historical three-way
bandElementForColumn pick with elements supplied internally; columnCell()
adds the band-local child index (deliberately not optional-chained - the
historical code throws on a missing container).

ViewportMgr gains per-semantic predicates (doctrine: one predicate per
HISTORICAL semantic, transcribed from its call sites, never unified):
- isColumnInAnyFrozenBand: the appendCellHtml cell class + cleanUpCells
  exemption pair (header cells keep left-band-only isColumnInFrozenBand)
- isColumnAlwaysHorizontallyVisible: scrollCellIntoView early-outs with the
  INCLUSIVE <= frozenColumn boundary
- isRowFrozenClassed: appendRowHtml row css incl. the inclusive-boundary
  off-by-one (named for the css semantic, not band membership)

slick.grid.ts conversions: getHeaderColumn/getHeaderRowColumn/
getFooterRowColumn collapse to columnCell one-liners (the owner's goal
example); getColumnByIndex -> cellAt; getHeaderChildren -> cells();
5 creation-loop band picks -> containerForColumn; applyColumnHeaderWidths
walk -> forEachCell; 4 predicate call sites. The scrollCellIntoView left
test moves from a live options.frozenColumn read to the freeze snapshot,
joining its RF twin (per the documented snapshot-timing invariant).

Guarded by viewportmgr-band-routing.cy.ts (committed first, 14 boundary
pins). Deferred to M19d: columnBandGeometry/applyColumnWidths - those sites
deliberately re-derive rfStartIdx fresh; the snapshot question lands with
the width-math golden tests. tsc/eslint clean; gate 115/115; full suite
668 / 0 failing (one ambient grid-menu flake ruled out by solo pass +
clean re-run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- canvasNodeRowOffset(node, geometry, { bfAware }): the vertical data offset
  of the row-band canvas containing a node. bfAware: true reproduces
  getCellFromEvent (bottom-frozen band rebases to its first row);
  bfAware: false reproduces setActiveCellInternal, whose historical
  bf-blindness (bf canvases carry no grid-canvas-bottom class token) becomes
  an explicit flag instead of an accident of class names. The classic bottom
  offset keeps its asymmetric source: frozenBottom measures the LIVE
  top-left canvas height, top-freeze uses the caller's cached
  frozenRowsHeight. Callers keep their own hasFrozenRows() gating - each
  gates more than the offset.
- shouldScrollRowIntoView: scrollRowIntoView's guard pair (bf rows never
  scroll; the exact actualFrozenRow - 1 boundaries) as one predicate.
- scrollableRowIndex: the frozen-top row-index rebase.

Snapshot notes: actualFrozenRow/frozenBottom/frozenRow reads move from grid
fields and live options to the freeze snapshot - identical values through
every supported path (all are set only by setFrozenOptions), per the
documented snapshot-timing invariant.

tsc/eslint clean; gate 85/85; full suite 668 / 0 failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Materializers now return PaneElementSets manifests of exactly the elements
they CREATED (null when nothing new), replacing boolean returns plus
grid-side hand-listed element arrays:

- the BFxRF corner obeys an exactly-once rule by construction: only the
  materializer whose ensureBottomFrozenRightVariant call CREATED the corner
  reports it (fresh RF path, fresh BF path, or BF idempotent-recall as a
  corner-only manifest) - the binding service does not dedupe, so this
  kills the double-bind hazard the hadCorner dances guarded against
- ensureBandsMaterialized(o) is the one runtime entry: classic -> RF -> BF
  in the load-bearing order (classic canonicalization forced by either band
  exactly as the historical wrapper-nesting did), manifests merged, with
  bodyCanvasChanged flagging classic materialization only
- allPaneElements() feeds the init-time array-wide bind pass

Grid: the three materialize wrappers (~95 lines incl. both hadCorner
dances) collapse into bindMaterialized(added), preserving the historical
per-wrapper wiring order (disableSelection -> bindPaneEvents -> pre-header
binds -> setupColumnSort -> ancestor re-anchor on bodyCanvasChanged only);
setFrozenOptions makes one ensureBandsMaterialized call instead of three
gated wrapper calls; bindPaneEvents takes the shared PaneElementSets type.

Note: merged manifests mean ONE bind pass per setFrozenOptions instead of
one per band wrapper - per-element handlers are order-independent and the
classic-then-RF-then-BF element order inside the merge preserves the
historical sequence.

tsc/eslint clean; gate 81/81 (lazy-materialization suite leading); full
suite 668 / 0 failing. Net +33 (core manifest machinery; grid -95).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

6pac commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

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


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

To illustrate what I mean, take the following examples:

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

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

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

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

  getHeadersWidth();

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

6pac master: (in createColumnHeaders())

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

this.getHeadersWidth();

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

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

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

this.getHeadersWidth();

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

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

desired:

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

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

Another example:

SlickGrid-pre-frozen:

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

SlickGrid-pre-frozen:

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

  var $footerRowTarget;

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

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

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

6pac master:

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

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

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

  return footerRowTarget.children[idx] as HTMLDivElement;
}

6pac viewportmgr-phase4:

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

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

}

desired:

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

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants