@aiquants/virtualscroll 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -89,6 +89,36 @@ export const MAX_FROZEN_LEADING_COLS = 128
89
89
  */
90
90
  export const MAX_FROZEN_LEADING_ROWS = 128
91
91
 
92
+ /**
93
+ * Hard cap for `frozenTrailingCols` (v3.5.0 trailing-freeze plan §3). The trailing band is NOT
94
+ * windowed — every trailing column materializes in every rendered row and is EXEMPT from the
95
+ * MAX_RENDERED_CELLS truncation (the scroll-band budget is reduced by the trailing count
96
+ * instead) — so the cap is what bounds the band: worst case 128 x MAX_RENDERED_ITEMS rows, and
97
+ * W_T <= 128 x MAX_TRACK_SIZE = 2^25 (the measured layout wall; practical W_T <= viewport <<
98
+ * 2^24). Violations fail fast with a RangeError.
99
+ *
100
+ * `frozenTrailingCols` の上限 (3.5.0 末尾凍結プラン §3)。末尾帯は窓化されず全描画行で具現化し、
101
+ * かつ MAX_RENDERED_CELLS の打切り対象外 (代わりにスクロール帯予算を末尾数ぶん控除) のため、
102
+ * 帯を有界にするのはこの上限そのもの: 最悪 128 × MAX_RENDERED_ITEMS 行、W_T ≤ 128 ×
103
+ * MAX_TRACK_SIZE = 2^25 (実測壁ちょうど — 実運用は W_T ≤ viewport ≪ 2^24)。違反は fail-fast の
104
+ * RangeError。
105
+ */
106
+ export const MAX_FROZEN_TRAILING_COLS = 128
107
+
108
+ /**
109
+ * Hard cap for `frozenTrailingRows` (v3.5.0 trailing-freeze plan §3). The trailing-row band is
110
+ * grid-owned and OUT of the embedded VirtualScroll (the SECOND out-of-pane band, below the
111
+ * pane): every band row materializes every rendered column and is exempt from
112
+ * MAX_RENDERED_CELLS (the budget denominator counts band rows instead), so the cap bounds the
113
+ * band itself. Violations fail fast with a RangeError.
114
+ *
115
+ * `frozenTrailingRows` の上限 (3.5.0 末尾凍結プラン §3)。末尾行帯はグリッド所有で埋め込み
116
+ * VirtualScroll の外 (ペインの**下**の第 2 帯外バンド)。帯行は描画列を全て具現化し
117
+ * MAX_RENDERED_CELLS の打切り対象外 (予算分母に帯行数を算入) のため、帯を有界にするのは
118
+ * この上限そのもの。違反は fail-fast の RangeError。
119
+ */
120
+ export const MAX_FROZEN_TRAILING_ROWS = 128
121
+
92
122
  /**
93
123
  * Maximum number of CONSECUTIVE NON-CONVERGENT self-heal flushes. A stable accessor converges in
94
124
  * ONE flush (tree == accessor afterwards), so the next collect pass is clean and RESETS this
@@ -303,6 +333,40 @@ export type VirtualGridProps<T> = {
303
333
  * 通知しゼロ描画)。
304
334
  */
305
335
  frozenLeadingRows?: number
336
+ /**
337
+ * Number of trailing columns frozen at the right edge (v3.5.0 trailing-freeze plan §3;
338
+ * default 0). Integer in [0, MAX_FROZEN_TRAILING_COLS] — RangeError otherwise (fail-fast).
339
+ * Dynamic clamp (documented contract): min(T, colCount − effectiveFrozenCols) — the LEADING
340
+ * band wins the count space. Trailing cells render in a right-anchored clip outside the
341
+ * anchor machinery (band-local lefts); `--aqvs-grid-trailing-width` is written iff T > 0.
342
+ * `scrollToCell` / `initialScrollAnchor` targeting a trailing column are horizontal no-ops
343
+ * (always visible). T = 0 is structurally identical to the pre-trailing DOM.
344
+ * 右端に凍結する末尾列数 (3.5.0 末尾凍結プラン §3。既定 0)。[0, MAX_FROZEN_TRAILING_COLS]
345
+ * の整数 — 違反は RangeError (fail-fast)。動的クランプ (文書化契約): min(T, colCount −
346
+ * effectiveFrozenCols) — カウント空間は**先頭が勝つ**。末尾セルはアンカー機構の外の
347
+ * 右アンカークリップに帯ローカル left で描画し、`--aqvs-grid-trailing-width` は T > 0 の
348
+ * ときのみ書かれる。`scrollToCell` / `initialScrollAnchor` の末尾列狙いは横 no-op
349
+ * (常時可視)。T = 0 は DOM 構造まで従来と恒等。
350
+ */
351
+ frozenTrailingCols?: number
352
+ /**
353
+ * Number of trailing rows pinned at the bottom (v3.5.0 trailing-freeze plan §3; default 0).
354
+ * Integer in [0, MAX_FROZEN_TRAILING_ROWS] — RangeError otherwise (fail-fast). Dynamic
355
+ * clamp (documented contract): min(T, rowCount − effectiveFrozenRows) — leading wins. The
356
+ * band is grid-owned BELOW the pane; the embedded scroll rows shrink at the TAIL only
357
+ * (itemCount = rowCount − R − T, getItem = i + R unchanged), so a runtime T change does NOT
358
+ * remount the embedded VirtualScroll (ADR-23). `scrollToCell` / `initialScrollAnchor`
359
+ * targeting a trailing row are vertical no-ops (always visible). T = 0 is structurally
360
+ * identical to the pre-trailing DOM.
361
+ * 下端に凍結する末尾行数 (3.5.0 末尾凍結プラン §3。既定 0)。[0, MAX_FROZEN_TRAILING_ROWS]
362
+ * の整数 — 違反は RangeError (fail-fast)。動的クランプ (文書化契約): min(T, rowCount −
363
+ * effectiveFrozenRows) — 先頭が勝つ。帯はペインの**下**のグリッド所有バンドで、埋め込み
364
+ * スクロール行は**末尾**だけが伸縮する (itemCount = rowCount − R − T、getItem = i + R
365
+ * 不変) — T の実行時変更は埋め込み VirtualScroll を再マウントしない (ADR-23)。
366
+ * `scrollToCell` / `initialScrollAnchor` の末尾行狙いは縦 no-op (常時可視)。T = 0 は
367
+ * DOM 構造まで従来と恒等。
368
+ */
369
+ frozenTrailingRows?: number
306
370
  /** Row-axis overscan (default 3 — a declared, intentional deviation from the bare VirtualScroll default 15; see the bounding contract). / 行軸 overscan (既定 3 — 素の既定 15 からの宣言済み意図的乖離)。 */
307
371
  overscanRows?: number
308
372
  /** Column-axis overscan (default 3). / 列軸 overscan (既定 3)。 */
@@ -338,7 +402,24 @@ export type VirtualGridHandle = {
338
402
  scrollTo(pos: { x?: number; y?: number }): { x: number; y: number }
339
403
  /** Relative float-accumulating path (wheel semantics; returns the applied clamped position — drag loops consume it synchronously). / 相対 float 累積経路 (ホイール同義。クランプ後位置を同期 return — ドラッグループが消費)。 */
340
404
  scrollBy(delta: { x?: number; y?: number }): { x: number; y: number }
341
- /** Cell-targeted jump. alignY uses the row vocabulary (top/bottom/center), alignX its horizontal twin (start/end/center). No "nearest". Targeting a FROZEN column is a horizontal no-op that leaves any previously armed column anchor in place (the visual status quo is preserved; manual horizontal input clears anchors as usual). / セル狙いジャンプ。"nearest" は無し。凍結列狙いの横成分は no-op で、既存の列アンカーはそのまま残る (視覚的現状維持 — 手動横入力が従来どおり解除する)。 */
405
+ /**
406
+ * Cell-targeted jump. alignY uses the row vocabulary (top/bottom/center), alignX its
407
+ * horizontal twin (start/end/center). No "nearest". Targeting a LEADING frozen or TRAILING
408
+ * frozen column is a horizontal no-op that leaves any previously armed column anchor in
409
+ * place (the visual status quo is preserved; manual horizontal input clears anchors as
410
+ * usual); targeting a leading/trailing frozen row is a vertical no-op. Band membership is
411
+ * judged at CALL time — a T change AFTER arming follows the ADR-23 clamp semantics: a
412
+ * pending row anchor whose target row lands inside the trailing band after a T increase
413
+ * clamps to the last scroll row. Overlap registration (ADR-19-1): when the leading +
414
+ * trailing band extents exceed the viewport, occluded trailing cells cannot be scrolled
415
+ * into view — the no-op is exact there too.
416
+ * セル狙いジャンプ。"nearest" は無し。先頭 / 末尾凍結列狙いの横成分は no-op で、既存の
417
+ * 列アンカーはそのまま残る (視覚的現状維持 — 手動横入力が従来どおり解除する)。先頭 /
418
+ * 末尾凍結行狙いの縦成分も no-op。帯所属の判定は**呼出し時** — 張った後の T 変更は
419
+ * ADR-23 のクランプ意味論 (T 増加で末尾帯入りする行を狙った保留行アンカーは最終
420
+ * スクロール行へクランプ) に従う。重複登記 (ADR-19-1): 先頭 + 末尾の帯寸がビューポートを
421
+ * 超えるとき、隠れた末尾セルはどのスクロール位置でも可視化できない — no-op はそこでも正確。
422
+ */
342
423
  scrollToCell(row: number, col: number, options?: { alignY?: "top" | "bottom" | "center"; alignX?: "start" | "end" | "center"; offsetX?: number; offsetY?: number }): void
343
424
  /** Synchronous-fresh position read (internal refs — safe mid-event; {-1,-1} pre-attach). / 同期・最新の位置読み (内部 ref — イベント中も安全。未接続 {-1,-1})。 */
344
425
  getScrollPosition(): { x: number; y: number }
@@ -347,7 +428,7 @@ export type VirtualGridHandle = {
347
428
  getViewportSize(): { width: number; height: number }
348
429
  getContentSize(): { width: number; height: number }
349
430
  getRange(): VirtualGridRange | null
350
- /** Pair-call seam: getRowHeight stays the truth; delegates to the row updateItemSize. / 対呼び出しシーム (getRowHeight が正) — updateItemSize へ委譲。 */
431
+ /** Pair-call seam: getRowHeight stays the truth. 3-way split — leading band rows bump the frozen-row epoch, trailing band rows bump the trailing-row epoch (accessor re-read; the bands hold no tree), scroll rows delegate to the row updateItemSize at −R. / 対呼び出しシーム (getRowHeight が正)。3 分岐 先頭帯行は凍結行 epoch、末尾帯行は末尾行 epoch の繰上げ (アクセサ再読 — 帯に木は無い)、スクロール行は −R で行 updateItemSize へ委譲。 */
351
432
  updateRowSize(row: number, px: number): void
352
433
  /**
353
434
  * Column twin: validates (MAX_TRACK_SIZE fail-fast), updates the width tree, and bumps the
@@ -360,8 +441,25 @@ export type VirtualGridHandle = {
360
441
  * バッチングで 1 コミットに合流 (設計の rAF 合流意図のコミット粒度での実現)。
361
442
  */
362
443
  updateColSize(col: number, px: number): void
363
- /** Effective frozen-band sizes: {cols, width, rows, height} in the CURRENT trees (cols/rows = dynamic clamps, width = W_F px, height = H_F px). Consumers use it for hit-test partitioning on both axes. / 実効凍結帯寸法 {cols, width, rows, height} (cols / rows は動的クランプ後、width = W_F、height = H_F)。消費側の両軸ヒットテスト分割用。 */
364
- getFrozenSize(): { cols: number; width: number; rows: number; height: number }
444
+ /**
445
+ * Effective frozen-band sizes on both axes and both ends (v3.5.0 — a non-breaking field
446
+ * extension of the {cols, width} → {cols, width, rows, height} precedent). cols/rows/
447
+ * trailingCols/trailingRows = dynamic post-clamp counts; width/height/trailingWidth/
448
+ * trailingHeight = TREE px (the trailing sizes are tree SUFFIX sums); trailingVisibleWidth/
449
+ * trailingVisibleHeight = viewport-clipped `min(tree, max(0, viewport − leading))` — THE
450
+ * single source for W_T_vis / H_T_vis that every consumer judgment/write path cites (never
451
+ * re-derive the formula). Pre-attach the grid viewport reads {0, 0}, so the vis fields are
452
+ * 0 (an initial measured state, not a sentinel) while the tree fields stay tree px.
453
+ * 実効凍結帯寸法 — 両軸・両端 (v3.5.0。{cols, width} → {cols, width, rows, height} の
454
+ * 前例に続く非破壊のフィールド追加)。cols / rows / trailingCols / trailingRows は動的
455
+ * クランプ後の実効カウント、width / height / trailingWidth / trailingHeight は**木** px
456
+ * (末尾寸は木の接尾辞和)、trailingVisibleWidth / trailingVisibleHeight はビューポート
457
+ * クリップ `min(木, max(0, viewport − 先頭))` — W_T_vis / H_T_vis の**単一情報源**で、
458
+ * 消費側の判定 / 書込み経路は全てこのフィールドを名指しで読む (式の再導出禁止)。
459
+ * プレアタッチはビューポート {0, 0} のため vis フィールドは 0 (番兵ではなく初期実測状態)、
460
+ * 木フィールドは常に木 px。
461
+ */
462
+ getFrozenSize(): { cols: number; width: number; rows: number; height: number; trailingCols: number; trailingRows: number; trailingWidth: number; trailingHeight: number; trailingVisibleWidth: number; trailingVisibleHeight: number }
365
463
  /** 2-axis wheel bridge entry: vertical → embedded VirtualScroll, horizontal → hx. Returns whether the event was consumed (WheelBridgeTarget contract). / 2 軸ホイールブリッジ口。消費有無を返す (ブリッジ契約)。 */
366
464
  applyWheel(event: WheelEvent): boolean
367
465
  /** Focuses the row wrapper at `row` (scroll rows via the −R-shifted row-side focusItemAtIndex — enableKeyboardNavigation applies; frozen band rows are focused directly and need no option). / 行ラッパーへフォーカス (スクロール行は −R シフトの行側 focusItemAtIndex — enableKeyboardNavigation が前提。凍結帯行は直接フォーカスでオプション不要)。 */
@@ -525,6 +623,58 @@ const collectFrozenColumns = (frozenCols: number, getColWidth: (col: number) =>
525
623
  return { columns, widthUpdates, width: runningLeft }
526
624
  }
527
625
 
626
+ /**
627
+ * Enumerates the trailing frozen band (v3.5.0 trailing-freeze plan §6.1-5 — the suffix twin of
628
+ * collectFrozenColumns): columns [colCount − trailingCols, colCount) with BAND-LOCAL lefts (a
629
+ * running prefix that starts at 0 — the right-anchored inner owns the placement, so trailing
630
+ * cells never take the anchor subtraction; W_T is bounded by 128 x MAX_TRACK_SIZE = 2^25),
631
+ * zero-width columns skipped (no DOM — the shared hidden-track discipline), and width
632
+ * self-heal deltas collected under the same tree-is-position / accessor-is-width split as
633
+ * collectVisibleColumns. Returns width = W_T, the TREE suffix width of the band.
634
+ *
635
+ * 末尾凍結帯の列挙 (3.5.0 末尾凍結プラン §6.1-5 — collectFrozenColumns の接尾辞双子)。列
636
+ * [colCount − trailingCols, colCount) を**帯ローカル** left (0 始まりの running 接頭辞 —
637
+ * 配置は右アンカー inner が担い、末尾セルはアンカー減算を決して取らない。W_T は 128 × 2^18 =
638
+ * 2^25 で有界) で並べ、幅 0 列は DOM 非生成 (非表示トラックの共有規律)、幅 self-heal 差分は
639
+ * collectVisibleColumns と同じ「位置は木・幅はアクセサ」分担で収集する。返す width = W_T
640
+ * (帯の**木**接尾辞幅)。
641
+ */
642
+ const collectTrailingColumns = (colCount: number, trailingCols: number, getColWidth: (col: number) => number, colTree: ReturnType<typeof useFenwickMapTree>): { columns: PlacedColumn[]; widthUpdates: Array<{ index: number; value: number }>; width: number } => {
643
+ const columns: PlacedColumn[] = []
644
+ const widthUpdates: Array<{ index: number; value: number }> = []
645
+ let runningLeft = 0
646
+ // T ≤ MAX_FROZEN_TRAILING_COLS のため素朴な O(T) 走査で十分 (幅 0 ジャンプ機構は不要)
647
+ for (let col = colCount - trailingCols; col < colCount; col += 1) {
648
+ const width = getColWidth(col)
649
+ const cached = colTree.get(col)
650
+ if (width !== cached) {
651
+ widthUpdates.push({ index: col, value: width })
652
+ }
653
+ if (width > 0) {
654
+ columns.push({ col, left: runningLeft, width })
655
+ }
656
+ runningLeft += cached
657
+ }
658
+ return { columns, widthUpdates, width: runningLeft }
659
+ }
660
+
661
+ /**
662
+ * THE single JS source for W_T_vis / H_T_vis (v3.5.0 trailing-freeze plan §2.1-4): clips a
663
+ * trailing band's TREE extent to what the viewport can show after the leading band took its
664
+ * share — `min(trailing, max(0, viewport − leading))`. Consumers are exactly three: the
665
+ * trailing-rows band template height, the hbar paddingRight, and the getFrozenSize
666
+ * trailingVisibleWidth/Height export. Never re-derive the formula elsewhere (the CSS max()
667
+ * left edge of `.aqvs-grid-row-trailing` is the registered calc() equivalent — the ONLY
668
+ * exception, annotated in the stylesheet).
669
+ *
670
+ * W_T_vis / H_T_vis の**単一 JS 源** (3.5.0 末尾凍結プラン §2.1-4)。末尾帯の**木**寸を
671
+ * 「先頭帯が取った残り」へクリップする `min(末尾, max(0, viewport − 先頭))`。消費先は
672
+ * 末尾行帯テンプレート高 / hbar paddingRight / getFrozenSize の vis export の 3 点ちょうど —
673
+ * 式を他所へ複製しない (`.aqvs-grid-row-trailing` の CSS max() 左端だけが登記済みの calc()
674
+ * 等価形で、スタイルシート側にインライン登記済み)。
675
+ */
676
+ const trailingVisibleSize = (trailingSize: number, frozenSize: number, viewportSize: number): number => Math.min(trailingSize, Math.max(0, viewportSize - frozenSize))
677
+
528
678
  /** Internal committed column-window state (anchor rides with the window — the M10 coherence unit). / コミット済み列窓状態 (アンカーは窓と一体 — M10 整合の単位)。 */
529
679
  type ColumnWindowState = {
530
680
  renderingColStart: number
@@ -575,6 +725,8 @@ const VirtualGridInner = <T,>(
575
725
  onRowFocus,
576
726
  frozenLeadingCols = 0,
577
727
  frozenLeadingRows = 0,
728
+ frozenTrailingCols = 0,
729
+ frozenTrailingRows = 0,
578
730
  horizontalKeyInputs,
579
731
  horizontalKeyStep,
580
732
  onScroll,
@@ -650,17 +802,34 @@ const VirtualGridInner = <T,>(
650
802
  /** Fresh W_F for drag loops / clamps (mirrored from the frozen memo each render). / ドラッグループ / クランプ用の鮮度 W_F (凍結 memo から毎レンダー写像)。 */
651
803
  const frozenWidthRef = useRef(0)
652
804
 
805
+ // ---- 末尾凍結列帯 (3.5.0 プラン §6.1-3) — 先頭側と同文の fail-fast。動的クランプは
806
+ // min(T, colCount − 先頭実効) で**先頭がカウント空間で勝つ** (ADR-19-1 の文書化契約) ----
807
+ if (!(Number.isInteger(frozenTrailingCols) && frozenTrailingCols >= 0 && frozenTrailingCols <= MAX_FROZEN_TRAILING_COLS)) {
808
+ throw new RangeError(`[VirtualGrid] frozenTrailingCols must be an integer in [0, ${MAX_FROZEN_TRAILING_COLS}], received ${frozenTrailingCols}.`)
809
+ }
810
+ const effectiveTrailingCols = Math.min(frozenTrailingCols, colCount - effectiveFrozenCols)
811
+ /** Fresh W_T for drag loops / clamps (mirrored from the trailing memo each render). / ドラッグループ / クランプ用の鮮度 W_T (末尾 memo から毎レンダー写像)。 */
812
+ const trailingWidthRef = useRef(0)
813
+
653
814
  // ---- 凍結行帯 (行凍結設計 §4-1) — 列と同文の fail-fast + 動的クランプ ----
654
815
  if (!(Number.isInteger(frozenLeadingRows) && frozenLeadingRows >= 0 && frozenLeadingRows <= MAX_FROZEN_LEADING_ROWS)) {
655
816
  throw new RangeError(`[VirtualGrid] frozenLeadingRows must be an integer in [0, ${MAX_FROZEN_LEADING_ROWS}], received ${frozenLeadingRows}.`)
656
817
  }
657
818
  const effectiveFrozenRows = Math.min(frozenLeadingRows, rowCount)
658
- /** Scroll-row count in the SHIFTED space (design §2 — the embedded VirtualScroll's itemCount). / シフト空間のスクロール行数 (埋め込み VirtualScroll の itemCount)。 */
659
- const scrollRowCount = Math.max(0, rowCount - effectiveFrozenRows)
819
+
820
+ // ---- 末尾凍結行帯 (3.5.0 プラン §6.1-3) — 列側と同文の fail-fast + 先頭勝ちクランプ ----
821
+ if (!(Number.isInteger(frozenTrailingRows) && frozenTrailingRows >= 0 && frozenTrailingRows <= MAX_FROZEN_TRAILING_ROWS)) {
822
+ throw new RangeError(`[VirtualGrid] frozenTrailingRows must be an integer in [0, ${MAX_FROZEN_TRAILING_ROWS}], received ${frozenTrailingRows}.`)
823
+ }
824
+ const effectiveTrailingRows = Math.min(frozenTrailingRows, rowCount - effectiveFrozenRows)
825
+ /** Scroll-row count in the SHIFTED space (design §2; the tail loses T — the embedded VirtualScroll's itemCount). / シフト空間のスクロール行数 (末尾は −T — 埋め込み VirtualScroll の itemCount)。 */
826
+ const scrollRowCount = Math.max(0, rowCount - effectiveFrozenRows - effectiveTrailingRows)
660
827
  /** Degenerate-band flag ref (H_F >= measured viewport — §3.3 の縮退帯と同じ正準空窓へ). / 縮退帯フラグ ref (H_F ≥ 実測ビューポート — §3.3 縮退帯と同じ正準空窓)。 */
661
828
  const degenerateBandRef = useRef(false)
662
829
  /** Fresh H_F for handle reads (mirrored from the band memo each render). / ハンドル読み用の鮮度 H_F (帯 memo から毎レンダー写像)。 */
663
830
  const frozenHeightRef = useRef(0)
831
+ /** Fresh H_T for handle reads (mirrored from the trailing band memo each render). / ハンドル読み用の鮮度 H_T (末尾帯 memo から毎レンダー写像)。 */
832
+ const trailingHeightRef = useRef(0)
664
833
 
665
834
  // ---- 凍結行帯の列挙 (行凍結設計 §4-2) — グリッド所有の帯外バンド。行高の真実は
666
835
  // アクセサ直読 (列帯と違い第 2 の格納庫 = 木 が無いためヒール機構は不要 — 更新経路は
@@ -683,6 +852,28 @@ const VirtualGridInner = <T,>(
683
852
  return { frozenRows: rows, frozenHeight: running }
684
853
  }, [effectiveFrozenRows, frozenRowEpoch, validatedGetRowHeight])
685
854
  frozenHeightRef.current = frozenHeight
855
+
856
+ // ---- 末尾行帯の列挙 (3.5.0 プラン §6.1-11) — 先頭行帯と同形の帯外バンド。木もヒール機構も
857
+ // 持たない (アクセサが唯一の真実 — 先頭行帯の宣言済み非対称と同文)。top は帯ローカル
858
+ // 接頭辞和 (inner の bottom 定着が右アンカーの行版を担う)。高さ 0 行は DOM 非生成 ----
859
+ const [trailingRowEpoch, setTrailingRowEpoch] = useState(0)
860
+ const { trailingRows, trailingHeight } = useMemo(() => {
861
+ void trailingRowEpoch
862
+ if (effectiveTrailingRows === 0) {
863
+ return { trailingRows: [] as Array<{ row: number; top: number; height: number }>, trailingHeight: 0 }
864
+ }
865
+ const rows: Array<{ row: number; top: number; height: number }> = []
866
+ let running = 0
867
+ for (let row = rowCount - effectiveTrailingRows; row < rowCount; row += 1) {
868
+ const height = validatedGetRowHeight(row)
869
+ if (height > 0) {
870
+ rows.push({ row, top: running, height })
871
+ }
872
+ running += height
873
+ }
874
+ return { trailingRows: rows, trailingHeight: running }
875
+ }, [effectiveTrailingRows, rowCount, trailingRowEpoch, validatedGetRowHeight])
876
+ trailingHeightRef.current = trailingHeight
686
877
  /** Shifted row-height accessor for the embedded VirtualScroll (design §2). / 埋め込み VirtualScroll 用のシフト済み行高アクセサ (設計 §2)。 */
687
878
  const scrollGetRowHeight = useCallback((index: number) => validatedGetRowHeight(index + effectiveFrozenRows), [validatedGetRowHeight, effectiveFrozenRows])
688
879
  // 行フォーカス通知はフル行空間へ +R 翻訳する (シフト漏れの是正 — 素通しは R > 0 でフル行 r の
@@ -696,6 +887,8 @@ const VirtualGridInner = <T,>(
696
887
  const shiftedRowFocus = useMemo(() => (hasRowFocus ? (index: number) => onRowFocusRef.current?.(index + effectiveFrozenRows) : undefined), [effectiveFrozenRows, hasRowFocus])
697
888
  /** Reactive key for H_F consumers (memo edge — the ref alone would not invalidate). / H_F 消費 memo の反応辺キー (ref 直読は失効しない)。 */
698
889
  const frozenHeightEpochKey = frozenHeight
890
+ /** Reactive key for H_T consumers (the trailing twin). / H_T 消費 memo の反応辺キー (末尾双子)。 */
891
+ const trailingHeightEpochKey = trailingHeight
699
892
 
700
893
  // ---- R の実行時変更 (R1 是正): シフト空間の再ペアリングは埋め込み木にとって「中間削除」で、
701
894
  // 木の changeSize (末尾意味論) では歪む (実測 +228px の総高汚染)。key 再マウントで木を
@@ -756,8 +949,8 @@ const VirtualGridInner = <T,>(
756
949
  const viewport = explicitViewport ?? measuredViewport
757
950
  const viewportRef = useRef(viewport)
758
951
  viewportRef.current = viewport
759
- /** Degenerate band: H_F fills the measured viewport — scroll rows go canonical-EMPTY (§3.3 縮退帯の縦対称). / 縮退帯: H_F が実測ビューポートを食い尽くす — スクロール行は正準空窓 (§3.3 の縦対称)。 */
760
- const degenerateBand = viewport.height > 0 && viewport.height - frozenHeight <= 0
952
+ /** Degenerate band: H_F + H_T fills the measured viewport — scroll rows go canonical-EMPTY (§3.3 縮退帯の縦対称; 3.5.0 で −H_T を一般化). / 縮退帯: H_F + H_T が実測ビューポートを食い尽くす — スクロール行は正準空窓 (§3.3 の縦対称。3.5.0 で −H_T を一般化)。 */
953
+ const degenerateBand = viewport.height > 0 && viewport.height - frozenHeight - trailingHeight <= 0
761
954
  degenerateBandRef.current = degenerateBand
762
955
  const hasExplicitViewport = explicitViewport !== null
763
956
  useLayoutEffect(() => {
@@ -793,10 +986,21 @@ const VirtualGridInner = <T,>(
793
986
  root.style.setProperty("--aqvs-grid-hx-residual", `${committedAnchorRef.current - hxRef.current}px`)
794
987
  }, [])
795
988
 
989
+ /**
990
+ * Fresh scroll-band width from refs (v3.5.0 trailing-freeze plan §6.1-6 — the ONE ref-space
991
+ * source): viewport minus BOTH band extents. Replaces the three former inline
992
+ * `max(0, viewport − W_F)` sites (applyHx / refreshColumns / resolveColumnAnchorHx) so a
993
+ * W_T change can never go stale in just one of them. T = 0 reduces to the pre-trailing form.
994
+ * ref 空間のスクロール帯幅 (3.5.0 末尾凍結プラン §6.1-6 — ref 面の単一源)。viewport から
995
+ * 両端の帯寸を控除する。旧 3 箇所のインライン `max(0, viewport − W_F)` を置換し、W_T 変化が
996
+ * 1 箇所だけ stale 化する余地を構造的に断つ。T = 0 は従来式へ恒等縮退。
997
+ */
998
+ const scrollBandWidthRef = useCallback(() => Math.max(0, viewportRef.current.width - frozenWidthRef.current - trailingWidthRef.current), [])
999
+
796
1000
  // ---- 列窓の再計算 (setState は窓 / アンカー変化時のみ — key 比較で bail) ----
797
1001
  const refreshColumns = useCallback(() => {
798
- // 凍結帯 (§8-a): 窓探索は木座標 W_F + hx 始まり・帯幅 = viewport − W_F
799
- const bandWidth = Math.max(0, viewportRef.current.width - frozenWidthRef.current)
1002
+ // 凍結帯 (§8-a): 窓探索は木座標 W_F + hx 始まり・帯幅 = viewport − W_F − W_T (3.5.0 一般化)
1003
+ const bandWidth = scrollBandWidthRef()
800
1004
  const placement = computeColumnPlacement(frozenWidthRef.current + hxRef.current, bandWidth, overscanCols, colCount, validatedGetColWidth, colTree, totalWidthRef.current, columnWindowRef.current.colAnchor)
801
1005
  // 幅 0 の凍結列があると indexAt(W_F + hx) が F 未満へ届く — スクロール窓に凍結列を
802
1006
  // 再出現させない床クランプ (§8-c: 凍結帯の描画は凍結 subtree の専有)。床は F そのもの:
@@ -806,21 +1010,31 @@ const VirtualGridInner = <T,>(
806
1010
  if (effectiveFrozenCols > 0) {
807
1011
  placement.renderingColStart = Math.max(placement.renderingColStart, effectiveFrozenCols)
808
1012
  placement.visibleColStart = Math.max(placement.visibleColStart, effectiveFrozenCols)
809
- // 縮退帯 (帯幅 0 — W_F ≥ viewport) もスクロールセルを描かない状態: 範囲通知を
810
- // 全列凍結と同じ空窓正準形へ揃える (非空の可視窓を報告するとヘッダー同期 /
811
- // liveRegion 消費側がセル無き列を描く R3 実証の非対称の解消)
812
- if (bandWidth <= 0) {
813
- placement.renderingColStart = colCount
814
- placement.renderingColEnd = colCount - 1
815
- placement.visibleColStart = colCount
816
- placement.visibleColEnd = colCount - 1
817
- }
1013
+ }
1014
+ // 末尾帯の ceiling clamp (3.5.0 プラン §6.1-6 — F 床クランプの鏡像・anti-#4454):
1015
+ // stale-bandWidth や幅 0 末尾列で窓末端が末尾帯へ届いても、スクロール窓に末尾列を
1016
+ // 再出現させない。天井は colCount T_eff − 1 そのもの
1017
+ if (effectiveTrailingCols > 0) {
1018
+ const ceiling = colCount - effectiveTrailingCols - 1
1019
+ placement.renderingColEnd = Math.min(placement.renderingColEnd, ceiling)
1020
+ placement.visibleColEnd = Math.min(placement.visibleColEnd, ceiling)
1021
+ }
1022
+ // 空窓ガードの一般化 (3.5.0 プラン §6.1-6): F > 0 または T > 0 のとき、縮退帯
1023
+ // (帯幅 ≤ 0 — W_F + W_T ≥ viewport) と clamp の結果 start > end になった窓は
1024
+ // 全列凍結と同じ空窓正準形へ整形する (非空の可視窓を報告するとヘッダー同期 /
1025
+ // liveRegion 消費側がセル無き列を描く — R3 実証の非対称の解消。T のみ縮退の
1026
+ // phantom 窓もここで封じる)
1027
+ if ((effectiveFrozenCols > 0 || effectiveTrailingCols > 0) && (bandWidth <= 0 || placement.renderingColStart > placement.renderingColEnd)) {
1028
+ placement.renderingColStart = colCount
1029
+ placement.renderingColEnd = colCount - 1
1030
+ placement.visibleColStart = colCount
1031
+ placement.visibleColEnd = colCount - 1
818
1032
  }
819
1033
  const key = `${placement.renderingColStart}:${placement.renderingColEnd}:${placement.colAnchor}`
820
1034
  if (key !== columnWindowRef.current.key) {
821
1035
  setColumnWindow({ ...placement, key })
822
1036
  }
823
- }, [overscanCols, colCount, validatedGetColWidth, colTree, effectiveFrozenCols])
1037
+ }, [overscanCols, colCount, validatedGetColWidth, colTree, effectiveFrozenCols, effectiveTrailingCols, scrollBandWidthRef])
824
1038
 
825
1039
  // ---- 横スクロールバーの追従 (rAF 合流の軽量 state — React 毎フレーム再レンダー回避) ----
826
1040
  const [barHx, setBarHx] = useState(0)
@@ -866,9 +1080,10 @@ const VirtualGridInner = <T,>(
866
1080
  // 見積り基点は現在の行窓先頭 (到達済みなら) — マウント前は初期アンカー行。
867
1081
  // リサイズ時に初期アンカー基点のままだと現在位置と別領域の行高で見積もってしまう
868
1082
  const basisRow = rowRangeRef.current?.renderingStartIndex ?? initialAnchorRow
869
- // 凍結行帯はスクロール行の見積り高から控除する (帯外バンド — 設計 §4-3)
870
- return estimateRenderedRowCount(Math.max(0, viewport.height - frozenHeightRef.current), validatedGetRowHeight, rowCount, basisRow, overscanRows)
871
- }, [viewport.height, validatedGetRowHeight, rowCount, initialAnchorRow, overscanRows, frozenHeightEpochKey])
1083
+ // 凍結行帯 (先頭 + 末尾) はスクロール行の見積り高から控除する (帯外バンド — 設計 §4-3
1084
+ // 3.5.0 プラン §6.1-12: −H_T 欠落は高い末尾帯のマウントコミットで偽打切りを発火させる)
1085
+ return estimateRenderedRowCount(Math.max(0, viewport.height - frozenHeightRef.current - trailingHeightRef.current), validatedGetRowHeight, rowCount, basisRow, overscanRows)
1086
+ }, [viewport.height, validatedGetRowHeight, rowCount, initialAnchorRow, overscanRows, frozenHeightEpochKey, trailingHeightEpochKey])
872
1087
  const effectiveRenderedRows = notifiedRowCount ?? estimatedRowCount
873
1088
 
874
1089
  const buildRange = useCallback((): VirtualGridRange | null => {
@@ -889,8 +1104,9 @@ const VirtualGridInner = <T,>(
889
1104
  scrollX: hxRef.current,
890
1105
  scrollY: vyRef.current,
891
1106
  totalWidth: totalWidthRef.current,
892
- // 帯高は鮮度加算 (格納はスクロール木のみ — 帯行リサイズ後も総高が正しい。R1 是正)
893
- totalHeight: rows.totalHeight + frozenHeightRef.current,
1107
+ // 帯高は鮮度加算 (格納はスクロール木のみ — 帯行リサイズ後も総高が正しい。R1 是正。
1108
+ // 3.5.0: 末尾帯高 H_T も同じ鮮度規律で加算)
1109
+ totalHeight: rows.totalHeight + frozenHeightRef.current + trailingHeightRef.current,
894
1110
  }
895
1111
  }, [])
896
1112
 
@@ -904,10 +1120,11 @@ const VirtualGridInner = <T,>(
904
1120
  // ---- applyHx — 横論理位置の唯一の書き口 (clamp → ref → 残差 var → 列窓 → 通知 → クランプ後値 return) ----
905
1121
  const applyHx = useCallback(
906
1122
  (next: number): number => {
907
- // 凍結帯 (§8): スクロール帯幅 = viewport − W_F。maxHx = (総幅 − W_F) − 帯幅で、
908
- // W_F < viewport のとき従来式 (総幅 − viewport) と一致する (F = 0 で恒等)
909
- const bandWidth = Math.max(0, viewportRef.current.width - frozenWidthRef.current)
910
- const maxHx = Math.max(0, totalWidthRef.current - frozenWidthRef.current - bandWidth)
1123
+ // 凍結帯 (§8): スクロール帯幅 = viewport − W_F W_T (3.5.0 一般化)。maxHx =
1124
+ // (総幅 − W_F − W_T) − 帯幅で、W_F + W_T < viewport のとき従来式 (総幅 − viewport)
1125
+ // と一致する (F = T = 0 で恒等)
1126
+ const bandWidth = scrollBandWidthRef()
1127
+ const maxHx = Math.max(0, totalWidthRef.current - frozenWidthRef.current - trailingWidthRef.current - bandWidth)
911
1128
  const clamped = minmax(next, 0, maxHx)
912
1129
  if (clamped !== hxRef.current) {
913
1130
  hxRef.current = clamped
@@ -918,7 +1135,7 @@ const VirtualGridInner = <T,>(
918
1135
  }
919
1136
  return clamped
920
1137
  },
921
- [writeResidual, refreshColumns, scheduleBarSync],
1138
+ [writeResidual, refreshColumns, scheduleBarSync, scrollBandWidthRef],
922
1139
  )
923
1140
  const applyHxRef = useRef(applyHx)
924
1141
  applyHxRef.current = applyHx
@@ -936,7 +1153,7 @@ const VirtualGridInner = <T,>(
936
1153
  if (rowRangeRef.current !== null) {
937
1154
  emitRangeChange()
938
1155
  }
939
- }, [frozenHeight, emitRangeChange])
1156
+ }, [frozenHeight, trailingHeight, emitRangeChange])
940
1157
 
941
1158
  // ---- 保留列アンカー (行側 scrollToIndex のアンカー張りの列対): initialScrollAnchor 復元と
942
1159
  // scrollToCell が張り、self-heal / 総幅同期 / リサイズで列左が動くたび paint 前に再ピン留め
@@ -951,13 +1168,14 @@ const VirtualGridInner = <T,>(
951
1168
  const prefix = colTree.prefixSum(safeCol, { materializeOption: { materialize: false } })
952
1169
  const colLeft = prefix.cumulative - prefix.currentValue
953
1170
  const colWidth = prefix.currentValue
954
- // 凍結帯 (§8): 整列先はスクロール帯 [W_F, viewport) — 列座標から W_F を引き、
955
- // 幅もスクロール帯幅で測る (F = 0 では従来式と恒等)
956
- const bandWidth = Math.max(0, viewportRef.current.width - frozenWidthRef.current)
1171
+ // 凍結帯 (§8): 整列先はスクロール帯 [W_F, viewport − W_T) — 列座標から W_F を引き、
1172
+ // 幅もスクロール帯幅 (3.5.0 一般化: −W_T 済み) で測る (F = T = 0 では従来式と恒等。
1173
+ // "end" は目標右端を viewport W_T へ着地させる — §8 の VALUE 行がピンする形)
1174
+ const bandWidth = scrollBandWidthRef()
957
1175
  const base = anchor.alignX === "end" ? colLeft + colWidth - frozenWidthRef.current - bandWidth : anchor.alignX === "center" ? colLeft + colWidth / 2 - frozenWidthRef.current - bandWidth / 2 : colLeft - frozenWidthRef.current
958
1176
  return base + anchor.offsetX
959
1177
  },
960
- [colTree, colCount],
1178
+ [colTree, colCount, scrollBandWidthRef],
961
1179
  )
962
1180
 
963
1181
  // ---- 幅 self-heal (窓内の getColWidth と木の乖離をバッチ適用 — 行 memo の toUpdateHeights 双子)。
@@ -1023,6 +1241,54 @@ const VirtualGridInner = <T,>(
1023
1241
  }
1024
1242
  }, [frozenWidth, effectiveFrozenCols])
1025
1243
 
1244
+ // ---- 末尾列帯の列挙 (3.5.0 プラン §6.1-7 — 先頭帯 memo の接尾辞双子)。self-heal 差分は
1245
+ // 共有バッチへ append 合流し、暴走ガードは帯専用の独立カウンタ (T = 0 でエピソードを
1246
+ // 閉じる — 先頭帯の「越境リーク遮断」と同文) ----
1247
+ const trailingHealBurstRef = useRef({ count: 0, warned: false })
1248
+ const { trailingColumns, trailingWidth } = useMemo(() => {
1249
+ // widthEpoch は木内容バージョンの反応辺 (先頭帯 memo と同じ規律)
1250
+ void widthEpoch
1251
+ if (effectiveTrailingCols === 0) {
1252
+ // 末尾凍結解除は帯ガードのエピソードも閉じる (先頭帯の R3 実証と同文の遮断)
1253
+ trailingHealBurstRef.current.count = 0
1254
+ trailingHealBurstRef.current.warned = false
1255
+ return { trailingColumns: EMPTY_PLACED_COLUMNS, trailingWidth: 0 }
1256
+ }
1257
+ const { columns, widthUpdates, width } = collectTrailingColumns(colCount, effectiveTrailingCols, validatedGetColWidth, colTree)
1258
+ if (widthUpdates.length > 0) {
1259
+ const burst = trailingHealBurstRef.current
1260
+ if (burst.count >= WIDTH_HEAL_BURST_LIMIT) {
1261
+ if (!burst.warned) {
1262
+ burst.warned = true
1263
+ Logger.warn("[VirtualGrid] getColWidth keeps disagreeing with its own previous values inside the trailing band; width self-heal is suspended until a converged pass re-arms it (unstable accessor).", {
1264
+ trailingCols: effectiveTrailingCols,
1265
+ pendingUpdates: widthUpdates.length,
1266
+ })
1267
+ }
1268
+ } else {
1269
+ burst.count += 1
1270
+ // 共有バッチへ append 合流 (同 tick の先頭帯 / スクロール窓の治癒を上書きで落とさない)
1271
+ pendingWidthUpdatesRef.current = [...(pendingWidthUpdatesRef.current ?? []), ...widthUpdates]
1272
+ Promise.resolve().then(flushWidthUpdates)
1273
+ }
1274
+ } else {
1275
+ trailingHealBurstRef.current.count = 0
1276
+ trailingHealBurstRef.current.warned = false
1277
+ }
1278
+ return { trailingColumns: columns, trailingWidth: width }
1279
+ }, [effectiveTrailingCols, colCount, widthEpoch, validatedGetColWidth, colTree, flushWidthUpdates])
1280
+ trailingWidthRef.current = trailingWidth
1281
+ // 末尾幅 var の書き手 (3.5.0 プラン §6.1-8 — **単一書き手規律**: この effect だけが書く)。
1282
+ // 消費者はクリップ右端 (.aqvs-grid-row-scroll の right)・末尾クリップの max() 左端と inner 幅。
1283
+ // T = 0 は除去 (T = 0 恒等 — CSS 側 0px フォールバックで安全)
1284
+ useLayoutEffect(() => {
1285
+ if (effectiveTrailingCols > 0) {
1286
+ rootRef.current?.style.setProperty("--aqvs-grid-trailing-width", `${trailingWidth}px`)
1287
+ } else {
1288
+ rootRef.current?.style.removeProperty("--aqvs-grid-trailing-width")
1289
+ }
1290
+ }, [trailingWidth, effectiveTrailingCols])
1291
+
1026
1292
  // ---- ビューポート / 総幅 / 幅エポック変化での再クランプ + 列窓追随。保留列アンカーが
1027
1293
  // あれば位置はアンカーから再導出 (単なる再クランプでは推定空間の hx が残留する) ----
1028
1294
  useLayoutEffect(() => {
@@ -1033,7 +1299,7 @@ const VirtualGridInner = <T,>(
1033
1299
  applyHxRef.current(resolveColumnAnchorHx(pending))
1034
1300
  }
1035
1301
  refreshColumns()
1036
- }, [viewport.width, totalWidth, widthEpoch, frozenWidth, refreshColumns, resolveColumnAnchorHx])
1302
+ }, [viewport.width, totalWidth, widthEpoch, frozenWidth, trailingWidth, refreshColumns, resolveColumnAnchorHx])
1037
1303
 
1038
1304
  // ---- 初期アンカー復元 (横) — 生 px でなく {col, offsetX} (行と同一契約)。マウント時 1 回。
1039
1305
  // 保留列アンカーとして張り、以後の self-heal による列左の実測化へ追随させる ----
@@ -1048,10 +1314,15 @@ const VirtualGridInner = <T,>(
1048
1314
  if (targetCol < effectiveFrozenCols) {
1049
1315
  return
1050
1316
  }
1317
+ // 末尾凍結列狙いも同文の no-op (3.5.0 プラン §6.1-16 — appliedRef を先に立てる現行順序は
1318
+ // 仕様 §7 の登記済み未検証防御と同じ姿勢で維持)
1319
+ if (targetCol >= colCount - effectiveTrailingCols) {
1320
+ return
1321
+ }
1051
1322
  const anchor = { col: targetCol, alignX: "start" as const, offsetX: Math.max(0, initialScrollAnchor.offsetX) }
1052
1323
  pendingColAnchorRef.current = anchor
1053
1324
  applyHxRef.current(resolveColumnAnchorHx(anchor))
1054
- }, [initialScrollAnchor, colCount, resolveColumnAnchorHx, effectiveFrozenCols])
1325
+ }, [initialScrollAnchor, colCount, resolveColumnAnchorHx, effectiveFrozenCols, effectiveTrailingCols])
1055
1326
 
1056
1327
  // ---- 縦軸の結線 ----
1057
1328
  const handleVerticalScroll = useCallback((position: number) => {
@@ -1097,8 +1368,9 @@ const VirtualGridInner = <T,>(
1097
1368
  }, [])
1098
1369
 
1099
1370
  // ---- 描画列の列挙 (配置ごとに 1 回 — 行間で共有) + セル数上限 ----
1100
- // 凍結行帯は全帯行 × 全描画列を具現化する — 予算分母に帯行数を算入 (行凍結設計 §4-7)
1101
- const maxColsPerRow = resolveMaxColsPerRow(__maxRenderedCells, effectiveRenderedRows + frozenRows.length)
1371
+ // 凍結行帯 (先頭 + 末尾) は全帯行 × 全描画列を具現化する — 予算分母に帯行数を算入
1372
+ // (行凍結設計 §4-7、3.5.0 プラン §6.1-13)
1373
+ const maxColsPerRow = resolveMaxColsPerRow(__maxRenderedCells, effectiveRenderedRows + frozenRows.length + trailingRows.length)
1102
1374
  // self-heal の暴走ガード: 乖離→治癒→再乖離の非収束が連続する = getColWidth が自身の
1103
1375
  // 過去値と不一致を繰り返す契約違反 (振動アクセサ)。放置すると治癒→エポック→再走査の
1104
1376
  // マイクロタスクループが無限旋回するため、上限で治癒を停止し 1 回だけ警告する。
@@ -1108,9 +1380,9 @@ const VirtualGridInner = <T,>(
1108
1380
  const { placedColumns, truncatedCols } = useMemo(() => {
1109
1381
  // widthEpoch は木の内容バージョンの反応辺 (木は identity 安定のため値経由では失効しない)
1110
1382
  void widthEpoch
1111
- // 凍結帯 (§8): スクロール帯幅 ≤ 0 (W_F ≥ viewport の縮退) はスクロール列を描かない —
1112
- // 凍結帯自体は独立 memo が描き続ける定義済み縮退
1113
- const bandWidth = Math.max(0, viewport.width - frozenWidth)
1383
+ // 凍結帯 (§8): スクロール帯幅 ≤ 0 (W_F + W_T ≥ viewport の縮退 — 3.5.0 一般化)
1384
+ // スクロール列を描かない — 凍結帯 / 末尾帯自体は独立 memo が描き続ける定義済み縮退
1385
+ const bandWidth = Math.max(0, viewport.width - frozenWidth - trailingWidth)
1114
1386
  if (colCount === 0 || bandWidth <= 0) {
1115
1387
  return { placedColumns: [] as PlacedColumn[], truncatedCols: 0 }
1116
1388
  }
@@ -1118,8 +1390,8 @@ const VirtualGridInner = <T,>(
1118
1390
  if (columnWindow.renderingColStart > columnWindow.renderingColEnd) {
1119
1391
  return { placedColumns: [] as PlacedColumn[], truncatedCols: 0 }
1120
1392
  }
1121
- // セル予算は凍結列ぶんを控除 (凍結セルも行ごとに具現化するため — 上限は行の総セル数)
1122
- const scrollColsBudget = Math.max(1, maxColsPerRow - frozenColumns.length)
1393
+ // セル予算は凍結列 + 末尾列ぶんを控除 (両帯セルも行ごとに具現化するため — 上限は行の総セル数)
1394
+ const scrollColsBudget = Math.max(1, maxColsPerRow - frozenColumns.length - trailingColumns.length)
1123
1395
  const { columns, widthUpdates, truncated } = collectVisibleColumns(minmax(columnWindow.renderingColStart, 0, colCount - 1), minmax(columnWindow.renderingColEnd, 0, colCount - 1), validatedGetColWidth, colTree, scrollColsBudget)
1124
1396
  if (widthUpdates.length > 0) {
1125
1397
  const burst = healBurstRef.current
@@ -1147,7 +1419,10 @@ const VirtualGridInner = <T,>(
1147
1419
  healBurstRef.current.warned = false
1148
1420
  }
1149
1421
  return { placedColumns: columns, truncatedCols: truncated }
1150
- }, [columnWindow, colCount, viewport.width, frozenWidth, frozenColumns.length, widthEpoch, validatedGetColWidth, colTree, maxColsPerRow, flushWidthUpdates])
1422
+ // deps trailingWidth / trailingColumns.length を明示追加 (3.5.0 プラン §6.1-6 / F4):
1423
+ // bandWidth 式とスクロール列予算が同 memo 内で両値を読む — 追加を怠ると列窓キー不変の
1424
+ // T トグルで memo が stale 化し、予算が trailing 分だけ過大に残る
1425
+ }, [columnWindow, colCount, viewport.width, frozenWidth, trailingWidth, frozenColumns.length, trailingColumns.length, widthEpoch, validatedGetColWidth, colTree, maxColsPerRow, flushWidthUpdates])
1151
1426
 
1152
1427
  // 打切りは無言にしない (設計 §3.2 — 行側の病的ガードと違い正当構成で到達し得る)
1153
1428
  const lastTruncationKeyRef = useRef("")
@@ -1156,10 +1431,11 @@ const VirtualGridInner = <T,>(
1156
1431
  const key = `${columnWindow.key}:${truncatedCols}`
1157
1432
  if (key !== lastTruncationKeyRef.current) {
1158
1433
  lastTruncationKeyRef.current = key
1159
- onRenderTruncatedRef.current?.({ renderedCells: (placedColumns.length + frozenColumns.length) * (effectiveRenderedRows + frozenRows.length), droppedCols: truncatedCols })
1434
+ // 打切り分子は両帯セル / 両帯行込みの exactness を維持する (3.5.0 プラン §6.1-13)
1435
+ onRenderTruncatedRef.current?.({ renderedCells: (placedColumns.length + frozenColumns.length + trailingColumns.length) * (effectiveRenderedRows + frozenRows.length + trailingRows.length), droppedCols: truncatedCols })
1160
1436
  }
1161
1437
  }
1162
- }, [truncatedCols, columnWindow.key, placedColumns.length, frozenColumns.length, effectiveRenderedRows, frozenRows.length])
1438
+ }, [truncatedCols, columnWindow.key, placedColumns.length, frozenColumns.length, trailingColumns.length, effectiveRenderedRows, frozenRows.length, trailingRows.length])
1163
1439
 
1164
1440
  // ---- 行レンダラー (VirtualScroll children) — セル left は「絶対 − コミット対象アンカー」 ----
1165
1441
  const colAnchor = columnWindow.colAnchor
@@ -1181,20 +1457,23 @@ const VirtualGridInner = <T,>(
1181
1457
  </div>
1182
1458
  )
1183
1459
  })
1184
- // 凍結帯なし (既定) は従来 DOM と恒等 — 行が残差 translate を運ぶ
1185
- if (effectiveFrozenCols === 0) {
1460
+ // 横帯なし (既定) は従来 DOM と恒等 — 行が残差 translate を運ぶ (中身はバイト恒等)
1461
+ if (effectiveFrozenCols === 0 && effectiveTrailingCols === 0) {
1186
1462
  return (
1187
1463
  <div {...rowProps} className={twMerge("aqvs-grid-row", rowProps?.className)} style={rowProps?.style}>
1188
1464
  {scrollCells}
1189
1465
  </div>
1190
1466
  )
1191
1467
  }
1192
- // 凍結モード (§8-c): 行は静的化し、スクロールセルは左端 W_F の静的クリップ +
1193
- // −W_F 原点シフト付き残差 translate の内側へ。凍結セルは行直下の独立 subtree で
1194
- // 木絶対 left の直接描画 — DOM は**前置** (論理列順 = 読み上げ / フォーカス順)。
1195
- // 描画順は自由: クリップが [0, W_F) への侵入を構造的に遮断するため重なり自体が無い
1468
+ // 横帯モード (§8-c + 3.5.0 プラン §6.1-15): 行は静的化し、スクロールセルは左端 W_F
1469
+ // 右端 W_T の静的クリップ + −W_F 原点シフト付き残差 translate の内側へ。凍結セルは
1470
+ // 行直下の独立 subtree で木絶対 left の直接描画 — DOM は**前置** (論理列順 = 読み上げ /
1471
+ // フォーカス順)。末尾セルは右アンカー inner の帯ローカル left で**後置** (同じく論理列順)
1472
+ // class はモードマーカー (描画トレイト) のまま、data-aqvs-frozen-row は F_eff > 0 のみ
1473
+ // (ADR-25 — 末尾のみのグリッドが凍結列ゼロで属性を出すフック面の嘘の封じ)。
1474
+ // 描画順は自由: クリップが帯への侵入を構造的に遮断するため重なり自体が無い
1196
1475
  return (
1197
- <div {...rowProps} data-aqvs-frozen-row="" className={twMerge("aqvs-grid-row aqvs-grid-row-frozen-host", rowProps?.className)} style={rowProps?.style}>
1476
+ <div {...rowProps} data-aqvs-frozen-row={effectiveFrozenCols > 0 ? "" : undefined} className={twMerge("aqvs-grid-row aqvs-grid-row-frozen-host", rowProps?.className)} style={rowProps?.style}>
1198
1477
  {hidden
1199
1478
  ? null
1200
1479
  : frozenColumns.map(({ col, left, width }) => {
@@ -1211,10 +1490,25 @@ const VirtualGridInner = <T,>(
1211
1490
  <div className="aqvs-grid-row-scroll-inner">{scrollCells}</div>
1212
1491
  </div>
1213
1492
  )}
1493
+ {hidden || effectiveTrailingCols === 0 ? null : (
1494
+ <div className="aqvs-grid-row-trailing">
1495
+ <div className="aqvs-grid-row-trailing-inner">
1496
+ {trailingColumns.map(({ col, left, width }) => {
1497
+ const cellProps = getCellProps?.(rowIndex, col)
1498
+ const style: CSSProperties = { ...cellProps?.style, left, width }
1499
+ return (
1500
+ <div {...cellProps} data-aqvs-trailing-cell="" key={getCellKey ? getCellKey(rowIndex, col) : col} className={twMerge("aqvs-grid-cell", cellProps?.className)} style={style}>
1501
+ {children(getCell(rowIndex, col), rowIndex, col)}
1502
+ </div>
1503
+ )
1504
+ })}
1505
+ </div>
1506
+ </div>
1507
+ )}
1214
1508
  </div>
1215
1509
  )
1216
1510
  },
1217
- [placedColumns, frozenColumns, effectiveFrozenCols, colAnchor, getRowProps, getCellProps, getCellKey, getCell, children, validatedGetRowHeight],
1511
+ [placedColumns, frozenColumns, trailingColumns, effectiveFrozenCols, effectiveTrailingCols, colAnchor, getRowProps, getCellProps, getCellKey, getCell, children, validatedGetRowHeight],
1218
1512
  )
1219
1513
 
1220
1514
  // ---- liveRegion (2D — 文言は消費側所有、静定デバウンス) ----
@@ -1313,15 +1607,17 @@ const VirtualGridInner = <T,>(
1313
1607
  if (inner !== null && rowCount > 0) {
1314
1608
  const targetRow = minmax(Math.trunc(row), 0, rowCount - 1)
1315
1609
  // 凍結行狙いの y は no-op (行凍結設計 §3 — 常時可視。列の §8 no-op と同文)。
1610
+ // 末尾帯行狙いも同文の縦 no-op (帯所属は**呼出し時**判定 — 3.5.0 プラン §3)。
1316
1611
  // スクロール行はシフト空間へ −R 翻訳して委譲する
1317
- if (targetRow >= effectiveFrozenRows) {
1612
+ if (targetRow >= effectiveFrozenRows && targetRow < rowCount - effectiveTrailingRows) {
1318
1613
  inner.scrollToIndex(targetRow - effectiveFrozenRows, { align: options?.alignY ?? "top", offset: options?.offsetY })
1319
1614
  }
1320
1615
  }
1321
1616
  if (colCount > 0) {
1322
1617
  const targetCol = minmax(Math.trunc(col), 0, colCount - 1)
1323
- // 凍結列狙いの x は no-op (§8 — 常時可視。スクロールで到達性が変わらない)
1324
- if (targetCol >= effectiveFrozenCols) {
1618
+ // 凍結列狙いの x は no-op (§8 — 常時可視)。末尾列狙いも横 no-op かつ既存の
1619
+ // 列アンカー残置 (分岐スキップのみ pendingColAnchorRef は触らない。3.5.0 §3)
1620
+ if (targetCol >= effectiveFrozenCols && targetCol < colCount - effectiveTrailingCols) {
1325
1621
  // 列アンカーを張る (行 scrollToIndex のアンカー張りと同義): 以後の self-heal で
1326
1622
  // 列左が実測化されても狙った列に留まり続ける。手動横入力で解除
1327
1623
  const anchor = { col: targetCol, alignX: options?.alignX ?? "start", offsetX: options?.offsetX ?? 0 }
@@ -1357,15 +1653,19 @@ const VirtualGridInner = <T,>(
1357
1653
  return { row: rowAnchor.index + effectiveFrozenRows, col: found.index, offsetX: Math.max(0, bandStart - colLeft), offsetY: rowAnchor.offsetPx }
1358
1654
  },
1359
1655
  getViewportSize: () => ({ width: viewportRef.current.width, height: viewportRef.current.height }),
1360
- // 縦はインセット込みの行側 getContentSize を単一情報源にする (range.totalHeight は木総高のみ)
1361
- getContentSize: () => ({ width: totalWidthRef.current, height: scrollHandleRef.current === null ? 0 : frozenHeightRef.current + Math.max(0, scrollHandleRef.current.getContentSize()) }),
1656
+ // 縦はインセット込みの行側 getContentSize を単一情報源にする (range.totalHeight は木総高のみ)
1657
+ // 3.5.0: height = H_F + 埋め込み + H_T
1658
+ getContentSize: () => ({ width: totalWidthRef.current, height: scrollHandleRef.current === null ? 0 : frozenHeightRef.current + trailingHeightRef.current + Math.max(0, scrollHandleRef.current.getContentSize()) }),
1362
1659
  getRange: buildRange,
1363
1660
  updateRowSize: (row, px) => {
1364
1661
  validateTrackSize(px, "row", row)
1365
1662
  // 凍結行はグリッド所有バンド — epoch 繰上げでアクセサ再読 (帯に第 2 の格納庫は
1366
- // 無いため木更新は不要)。スクロール行はシフト空間へ −R 翻訳して委譲する
1663
+ // 無いため木更新は不要)。末尾帯行も同文の epoch 繰上げ (3.5.0 — 3 分岐。T = 0 は
1664
+ // 分岐自体が不成立で従来挙動と恒等)。スクロール行はシフト空間へ −R 翻訳して委譲する
1367
1665
  if (Math.trunc(row) < effectiveFrozenRows) {
1368
1666
  setFrozenRowEpoch((epoch) => epoch + 1)
1667
+ } else if (effectiveTrailingRows > 0 && Math.trunc(row) >= rowCount - effectiveTrailingRows) {
1668
+ setTrailingRowEpoch((epoch) => epoch + 1)
1369
1669
  } else {
1370
1670
  scrollHandleRef.current?.updateItemSize(Math.trunc(row) - effectiveFrozenRows, px)
1371
1671
  }
@@ -1377,23 +1677,38 @@ const VirtualGridInner = <T,>(
1377
1677
  // 窓キー不変の幅変更でもセル配置を失効させる (バッチループは React 自動バッチングで 1 コミット)
1378
1678
  setWidthEpoch((epoch) => epoch + 1)
1379
1679
  },
1380
- getFrozenSize: () => ({ cols: effectiveFrozenCols, width: frozenWidthRef.current, rows: effectiveFrozenRows, height: frozenHeightRef.current }),
1680
+ getFrozenSize: () => ({
1681
+ cols: effectiveFrozenCols,
1682
+ width: frozenWidthRef.current,
1683
+ rows: effectiveFrozenRows,
1684
+ height: frozenHeightRef.current,
1685
+ trailingCols: effectiveTrailingCols,
1686
+ trailingRows: effectiveTrailingRows,
1687
+ trailingWidth: trailingWidthRef.current,
1688
+ trailingHeight: trailingHeightRef.current,
1689
+ // vis は単一 JS 源ヘルパーの呼び出しのみ (式の再導出禁止 — 3.5.0 プラン §2.1-4)
1690
+ trailingVisibleWidth: trailingVisibleSize(trailingWidthRef.current, frozenWidthRef.current, viewportRef.current.width),
1691
+ trailingVisibleHeight: trailingVisibleSize(trailingHeightRef.current, frozenHeightRef.current, viewportRef.current.height),
1692
+ }),
1381
1693
  applyWheel: (event) => scrollHandleRef.current?.applyWheel(event) ?? false,
1382
1694
  focusRowAtIndex: (row, options) => {
1383
- // 凍結行はバンド行 DOM へ直接フォーカス、スクロール行は −R 翻訳して委譲
1695
+ // 凍結行はバンド行 DOM へ直接フォーカス、末尾帯行も帯行 DOM へ直接フォーカス
1696
+ // (3.5.0 — 3 分岐。T = 0 は分岐不成立で従来挙動と恒等)、スクロール行は −R 翻訳して委譲
1384
1697
  const targetRow = Math.trunc(row)
1385
1698
  if (targetRow < effectiveFrozenRows) {
1386
1699
  rootRef.current?.querySelector<HTMLElement>(`[data-aqvs-frozen-band-row="${targetRow}"]`)?.focus()
1700
+ } else if (effectiveTrailingRows > 0 && targetRow >= rowCount - effectiveTrailingRows) {
1701
+ rootRef.current?.querySelector<HTMLElement>(`[data-aqvs-trailing-band-row="${targetRow}"]`)?.focus()
1387
1702
  } else {
1388
1703
  scrollHandleRef.current?.focusItemAtIndex(targetRow - effectiveFrozenRows, options)
1389
1704
  }
1390
1705
  },
1391
1706
  }),
1392
- [rowCount, colCount, colTree, buildRange, commitTotalWidth, resolveColumnAnchorHx, effectiveFrozenCols, effectiveFrozenRows],
1707
+ [rowCount, colCount, colTree, buildRange, commitTotalWidth, resolveColumnAnchorHx, effectiveFrozenCols, effectiveFrozenRows, effectiveTrailingCols, effectiveTrailingRows],
1393
1708
  )
1394
1709
 
1395
1710
  return (
1396
- <div ref={rootRef} className={twMerge("aqvs-grid", effectiveFrozenRows > 0 ? "aqvs-grid-has-frozen-rows" : "", className)} data-testid={testId}>
1711
+ <div ref={rootRef} className={twMerge("aqvs-grid", effectiveFrozenRows > 0 ? "aqvs-grid-has-frozen-rows" : "", effectiveTrailingRows > 0 ? "aqvs-grid-has-trailing-rows" : "", className)} data-testid={testId}>
1397
1712
  {effectiveFrozenRows > 0 ? (
1398
1713
  // 凍結行帯 (行凍結設計 §4-2): グリッド所有の帯外バンド — 行本体は renderRow を
1399
1714
  // そのまま再利用する (F > 0 の凍結セル / クリップ / hx 残差 var は継承で無償追随
@@ -1421,7 +1736,7 @@ const VirtualGridInner = <T,>(
1421
1736
  getItemKey={(i) => i + effectiveFrozenRows}
1422
1737
  getItemHeight={scrollGetRowHeight}
1423
1738
  overscanCount={overscanRows}
1424
- viewportSize={viewport.height > 0 ? Math.max(0, viewport.height - frozenHeight) : undefined}
1739
+ viewportSize={viewport.height > 0 ? Math.max(0, viewport.height - frozenHeight - trailingHeight) : undefined}
1425
1740
  callbackThrottleMs={callbackThrottleMs}
1426
1741
  behaviorOptions={behaviorOptions}
1427
1742
  scrollBarOptions={scrollBarOptions}
@@ -1439,7 +1754,7 @@ const VirtualGridInner = <T,>(
1439
1754
  ? toggleAnchorRef.current !== null && toggleAnchorRef.current.row >= effectiveFrozenRows && scrollRowCount > 0
1440
1755
  ? { index: toggleAnchorRef.current.row - effectiveFrozenRows, offsetPx: toggleAnchorRef.current.offsetPx }
1441
1756
  : undefined
1442
- : initialScrollAnchor !== undefined && scrollRowCount > 0 && Math.trunc(initialScrollAnchor.row) >= effectiveFrozenRows
1757
+ : initialScrollAnchor !== undefined && scrollRowCount > 0 && Math.trunc(initialScrollAnchor.row) >= effectiveFrozenRows && Math.trunc(initialScrollAnchor.row) < rowCount - effectiveTrailingRows
1443
1758
  ? { index: Math.trunc(initialScrollAnchor.row) - effectiveFrozenRows, offsetPx: initialScrollAnchor.offsetY }
1444
1759
  : undefined
1445
1760
  }
@@ -1451,16 +1766,37 @@ const VirtualGridInner = <T,>(
1451
1766
  {renderRow}
1452
1767
  </VirtualScroll>
1453
1768
  </div>
1454
- <div className="aqvs-grid-hbar-strip" style={{ height: scrollBarWidth, paddingRight: scrollBarWidth }} data-testid={testId ? `${testId}-hbar` : undefined}>
1769
+ {effectiveTrailingRows > 0 ? (
1770
+ // 末尾行帯 (3.5.0 プラン §6.1-17): .aqvs-grid-main の**後**・hbar の**前**の兄弟
1771
+ // (AT には「最終行が最後」の DOM 順正しさ。縦バーは帯の上で自然に終端)。帯クリップ高は
1772
+ // インライン H_T_vis (単一 JS 源 — 帯寸の CSS 変数は幅 2 つのみで高さ変数は導入しない)、
1773
+ // inner はインライン木高 H_T の bottom 定着 — 帯上端 = extent − H_T_vis が全構成で
1774
+ // 成立し、重複縮退では帯の**末尾**行が可視に残る。行本体は renderRow 逐語再利用
1775
+ // (先頭列 / 末尾列の帯構造を継承し 4 象限コーナー全種が無償で成立)
1776
+ <div className="aqvs-grid-trailing-rows" style={{ height: trailingVisibleSize(trailingHeight, frozenHeight, viewport.height) }} data-testid={testId ? `${testId}-trailing-rows` : undefined}>
1777
+ <div className="aqvs-grid-trailing-rows-inner" style={{ height: trailingHeight }}>
1778
+ {trailingRows.map(({ row, top, height }) => (
1779
+ // 帯行は帯ローカル top + right: scrollBarWidth (縦バーコーナーの予約 — 先頭帯行と
1780
+ // 同文) + tabIndex −1 + onFocus フル行 index (focusRowAtIndex ↔ onRowFocus 往復)。
1781
+ // biome-ignore lint/a11y/noStaticElementInteractions: role は消費側の複合ウィジェット契約の所有物 (先頭帯行と同文)
1782
+ <div key={row} data-aqvs-trailing-band-row={row} className="aqvs-grid-trailing-band-row" style={{ top, height, right: scrollBarWidth }} tabIndex={-1} onFocus={() => onRowFocus?.(row)}>
1783
+ {renderRow(row)}
1784
+ </div>
1785
+ ))}
1786
+ </div>
1787
+ </div>
1788
+ ) : null}
1789
+ <div className="aqvs-grid-hbar-strip" style={{ height: scrollBarWidth, paddingRight: effectiveTrailingCols > 0 ? scrollBarWidth + trailingVisibleSize(trailingWidth, frozenWidth, viewport.width) : scrollBarWidth }} data-testid={testId ? `${testId}-hbar` : undefined}>
1455
1790
  <ScrollBar
1456
1791
  horizontal
1457
1792
  enableHorizontalTapCircle
1458
- contentSize={Math.max(0, totalWidth - frozenWidth)}
1459
- viewportSize={Math.max(0, viewport.width - frozenWidth)}
1793
+ contentSize={Math.max(0, totalWidth - frozenWidth - trailingWidth)}
1794
+ viewportSize={Math.max(0, viewport.width - frozenWidth - trailingWidth)}
1460
1795
  scrollPosition={barHx}
1461
1796
  // タップ速度の log10(colCount) 則 (設計 §3.3 — B7 の対策そのもの)。未配線だと
1462
- // 兆列でも基礎倍率 2.2x に縮退し、サム粒度の到達性が死ぬ
1463
- itemCount={Math.max(0, colCount - effectiveFrozenCols)}
1797
+ // 兆列でも基礎倍率 2.2x に縮退し、サム粒度の到達性が死ぬ。−F / −T 写像は
1798
+ // aria 面に露出せずピン不能 (仕様 §7 非ピン面 (3) の登記済み面)
1799
+ itemCount={Math.max(0, colCount - effectiveFrozenCols - effectiveTrailingCols)}
1464
1800
  tapScrollCircleOptions={scrollBarOptions?.tapScrollCircleOptions}
1465
1801
  onScroll={(request, previous) => {
1466
1802
  // バー操作は手動横入力 — 列アンカーの張りを解除する