@aiquants/spreadsheet 0.8.0 → 0.10.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,110 @@
2
2
 
3
3
  All notable changes to `@aiquants/spreadsheet` are documented here.
4
4
 
5
+ ## 0.10.0 (2026-09-22)
6
+
7
+ v0.10 は読み取り専用セル (isCellReadOnly) — 統治文書は
8
+ [v0.10 実装計画](<./docs/plans/2026.09.18 [AI] spreadsheet-v0.10-readonly-cells-plan.md>) (以下「v0.10 計画 §N」)。
9
+ 同乗スコープはタップサークル素通し (H2) と貼り付けフィードバック (onPasteResolved)。
10
+
11
+ - **新機能 — 読み取り専用セル (ADR-27..31)**: `SpreadsheetDataAdapter.isCellReadOnly?: (row, col) => boolean`
12
+ (11 番目の optional・非 aspect 能力)。true のセルは編集モードに入れず (全 6 入口 + OSK 招待前置 G2)、
13
+ 開いているセッションの確定はライブ再検査され (G16 — keep / focusout discard の帰結つき)、対象に読み取り
14
+ 専用セルを含む書込操作 (削除 / 貼り付け / cut 二重壁 / フィル (対角 L 分解) / 書式 / 計算 / 注釈) は
15
+ adapter 到達前に**操作全体**が拒否される (G1..G16 — per-cell skip なし、Excel 同等)。拒否は新設の単一
16
+ チャネル `onReadOnlyRejected(rejection: ReadOnlyRejection)` (edit 変種は必須 `editSession: "none" | "kept" |
17
+ "discarded"`、operation 変種は明示 7 リテラル op + overlap-naive `cellCount`) と
18
+ `formatReadOnlyAnnouncement` (live region 経由・デバウンス継承) で通知 — `onEditRejected` /
19
+ `onOperationRejected` へは流用しない (チャネル純度)。塗りセルは `aria-readonly="true"` +
20
+ `data-aqsp-readonly=""` を帯同 (パッケージ CSS なし — ADR-31)、announce payload / popover context は宣言
21
+ ゲートキー `readOnly` / `cellReadOnly` を得る (既定 note / comment / link コンポーネントは確定操作を
22
+ disabled 化 = 閲覧者モード、注釈チョードは内容有で viewer 開通・内容無で消費拒否 — G15 両腕)。能力未宣言
23
+ = 従来挙動とバイト恒等 (能力分岐 — フォールバックではない)。デモは新設 `/readonly` ルート。
24
+ - **新機能 — `tapScrollCircleOptions` プロップ新設 (位置・有効 / 無効の調整口)**: エンジン
25
+ `scrollBarOptions.tapScrollCircleOptions` へのエンジン検証素通し (3 ファイル鎖 — 検証 SSOT はエンジンの
26
+ `resolveTapScrollCircleOptions`、未指定 = エンジン既定)。barrel は `ScrollBarTapCircleOptions` を型のみ
27
+ 再 export (v0.10 計画 §2)。
28
+ - **新機能 — `onPasteResolved(outcome, channel)` (貼り付け結果の要約)**: menu チャネルは `paste(options)` の
29
+ resolve 値の 1:1 鏡 (読み拒否・書込時再ゲート込み)、event チャネルは貼り付けパイプライン到達ゲスチャ毎
30
+ 1 回 (画像のみクリップボードの `"empty"` 込み)。ドラッグ凍結消費と `clipboardData` null の到達前 2 分岐は
31
+ 意図的無言、空グリッドはリスナ不装着 (構造的到達不能)。原因特定系 callback (`onClipboardUnavailable` /
32
+ `onOperationRejected` / `onReadOnlyRejected`) が先、outcome は後追いの要約 (v0.10 計画 §3)。
33
+ - **DOM 属性 census 追補 (分冊 05 §10 末尾追加)**: `data-aqsp-readonly` の 1 種を追加 — `data-aqsp-*` 属性は 79 → 80
34
+ (新規 1 — 分冊 05 §10 / 分冊 06 §4)。リテラル census は 80 種 (dataset 直書きの同値抑止署名キー 4 種は
35
+ grep に掛からず、DOM に現れる属性名の総数は 84)。
36
+ - **消費面 — 統合 2 軸タップサークル (上流エンジン v3.6.0)**: グリッドのタップサークルが 1 つに統合 —
37
+ 縦横バー別の 2 サークルは消滅し、グリッド所有の 1 サークル (`[data-aqvs-grid-tap-circle]`) が 1 本の
38
+ ドラッグベクトルで両軸を連続スクロールする。位置はコーナーアンカーのエンジン ADR-30 の 3 アーム配置則
39
+ (root 寸・scrollbar 幅・size・オフセットのみに依存 — 帯幅非依存で凍結トグル不変、W_F = 0 の
40
+ クリップ危険クラスは消滅)。ブラウザ実証は新設 `tests/e2e/tap-circle.spec.ts` (12 テスト — エンジン 3.6 設計 §6.3:
41
+ 1 サークル / 3 アーム則 ±1 px + 小ビューポートのアーム Q / 対角・純軸ドラッグ / キャンセル 3 系 /
42
+ 末尾帯フロートとディスク外クリック到達 (大 W_F 先頭凍結込み) / ルートスワップ再マウント)。
43
+ - **公開面 — 型のみ再 export (ADR-33 縮小形)**: `TapScrollCircleRenderProps` を `@aiquants/virtualscroll`
44
+ から型のみ再 export — consumer がエンジンを直接 import せずに `renderVisual` を型付ける options
45
+ 型付け面 (値 `tapScrollCircleSampleVisual` は意図的に非再 export)。v0.10 の追加 (`ReadOnlyRejection` +
46
+ `ScrollBarTapCircleOptions` の型 2) と併せ、公開面は `index.ts` の値 39 + 型 97 = 136 export
47
+ (`src/index.spec.ts` の census がコンパイル証明つきでピン)。
48
+ - **検証 — 走行台帳 (census の錨は本未リリース節)**: 単体 / 統合 **78 ファイル 3,806 テスト** 全緑 —
49
+ 0.9.0 出荷時の 74 ファイル 3,631 に v0.10 進行分 +175 (新規 4 ファイル 52 = `readOnly` 28 +
50
+ `readOnlyCensus` 8 (2026-09-22 の側 (iv) raise 漏斗 census 追補 +1 込み — 計画 §6.4 の raise 単一性の機械封印) +
51
+ `tapCirclePassthrough` 6 + `readonlyStore` 10 (`/readonly` デモストアの実書込 — 宣言済み全側面の編集可対照、計画 §6.13)、既存 16 ファイル +116 —
52
+ 2026-09-22 の登記過少是正 (`labels` 実測 22 / `NotePopover` 実測 13 — 計画 §4.1 の per-file 台帳規律) と分冊 07 §2 台帳 literal census 追補 (`hxCensus` +1 — 登記過少をソース錨で red にする常設ゲート) と
53
+ 収束ラウンドの環境特性登記ピン (`htmlInterop` +1 — jsdom `DOMParser` の入れ子深さ二次 parse 壁時計は ADR-12 の保護の外、分冊 03 §3.3 / §7.1-5 の登記の機械ピン) 込み、是正 +7 は v0.9 step 5e 登記の
54
+ 過少: `SelectionOverlay` 実測 61 (旧登記 54)。3,631 + 175 = 3,806、ファイルは 74 → 78) 込み。
55
+ E2E は **23 ファイル 263 テスト** 全緑 (2026-09-18 — 読み取り専用セルの新設 `tests/e2e/readonly.spec.ts` 14 +
56
+ タップサークルの新設 `tests/e2e/tap-circle.spec.ts` 12 + trailing-freeze 過少登記の是正 +1 で、0.9.0 出荷時の
57
+ 21 ファイル 236 から 23 ファイルへ)。出荷済み 0.9.0 節は出荷時リリースノートに凍結 (`hxCensus.spec.ts` の本文 SHA-256 ピン — 0.8.0 凍結の鏡)。
58
+
59
+ ## 0.9.0 (2026-09-18)
60
+
61
+ v0.9 は末尾凍結 (trailing freeze) と強い凍結分割線 (freeze divider) — 統治文書は
62
+ [v0.9 実装計画](<./docs/plans/2026.09.11 [AI] spreadsheet-v0.9-trailing-freeze-divider-plan.md>)。エンジンは
63
+ `@aiquants/virtualscroll` 3.5.0 (`frozenTrailingCols` / `frozenTrailingRows` + `getFrozenSize()` 10 フィールド)。
64
+
65
+ - **新機能 — 末尾凍結 (ADR-18 / ADR-19)**: `SpreadsheetProps.frozenTrailingColumns` / `frozenTrailingRows`
66
+ (既定 0、LIVE、エンジン検証の素通し — 第二検証層なし)。座標分割は先頭 / 末尾 / 中央の 3 分岐
67
+ (`frozenPartition.ts` の必須 ctx オブジェクト `AxisForwardCtx` / `AxisInverseCtx` へクリーンカットオーバー —
68
+ 位置引数形は削除)、overlay は {m, f, s, t}² 象限へ一般化 (旧互換属性は mm / fm / sm に据置き)、ヘッダーは
69
+ 3 帯 (末尾クリップは登記 calc() 形)。末尾帯のリサイズ / 非表示復元は近端ストリップ系
70
+ (`buildTrailingResizeStrips` — ADR-26: 反転デルタ / クランプ済み動く辺ガイド / `data-aqsp-resize-anchor`)。
71
+ - **新機能 — 凍結分割線 (ADR-20 / ADR-24)**: 全凍結境界に consumer 所有の強い分割線
72
+ (`[data-aqsp-freeze-divider]`、既定 2px `#9ca3af`、CSS 変数 `--aqsp-divider-color` / `--aqsp-divider-width` のみ —
73
+ prop なし)。存在 = 実効件数、可視 = 帯 px の等値抑制 display heal、幾何 = 象限 / ヘッダークリップ所有。
74
+ バーは不活性 (`pointer-events: none`) でリサイズ operand は帯側隣接ストリップ。`--aqsp-divider-width: 0px` は
75
+ 逃げ道 (fill 断片の継ぎ目が再露出、forced-colors も無描画 — 登記済み)。
76
+ - **挙動変更 — autofit の測定集合 (ADR-21、ユーザー承認済み)**: 列 autofit の測定行集合が可視窓のみから
77
+ **先頭凍結帯 ∪ 可視窓 ∪ 末尾凍結帯** (各 `sizeOf(row) > 0` でフィルタ) へ広がる — 末尾帯 (合計行 = 最幅セルの典型)
78
+ の包含が本命の欠陥修正で、先頭帯の包含は既存 `frozenRows > 0` 利用者へのマイナーリリース内の挙動変更
79
+ (v0.9 計画 §0.2 保留 2 の確定)。`measuredCells` 予算ゲートは帯行を数える。R = T = 0 の恒等は不変。
80
+ - **逸脱の要約 (完全列挙は README「Deviations from Excel」)**: 分割線は不活性 — バー上のドラッグはヘッダー選択を
81
+ 開始し、リサイズストリップは線の帯側にすぐ隣接 (Sheets の凍結ドラッグハンドルではない)。分割線は論理 px で
82
+ ズームと共に縮尺 (z = 0.25 で 0.5 視覚 px)。leading + trailing の extent が viewport を超える overlap では遮蔽された
83
+ 末尾セルはスクロールで可視化できず (件数クランプは leading 勝ち)、中央帯全崩壊では分割線ゼロ + f / t 突合継ぎ目が
84
+ 再露出。凍結対応 Ctrl+Home / Ctrl+End は範囲外 (ネイティブ素通しのまま — 記録済み将来機能)。末尾ヘッダーストリップは
85
+ pinned-right イディオム (遠側トラック制御・ドラッグした辺が動く辺・dblclick autofit も遠側)。末尾行は位置的 (同一
86
+ データの末尾 N 行) — ソート実装 consumer は合計行を自前免除する。凍結帯シャドウは無し (導出可能性は
87
+ `getFrozenSize()` の trailing フィールドで担保)。
88
+ - **DOM 属性 census 追補 (分冊 05 §10 末尾追加)**: `data-aqsp-freeze-divider` / `data-aqsp-col-header-trailing` /
89
+ `data-aqsp-row-header-trailing` / `data-aqsp-trailing-header` / `data-aqsp-trailing-row-header` /
90
+ `data-aqsp-resize-anchor` の 6 種を追加 — リテラル census は 79 種 (dataset 直書きの同値抑止署名キー 4 種
91
+ `data-aqsp-anchor-sig` / `data-aqsp-clip-kind-last` / `data-aqsp-outset-sig` / `data-aqsp-drag-mode-last` は
92
+ grep に掛からず、DOM に現れる属性名の総数は 83)。
93
+ - T = 0 (両軸・未宣言含む) は v0.8 と DOM byte 恒等 (能力分岐 — `trailingFreeze.spec.tsx` の outerHTML 封印)。
94
+ - **検証 — 走行台帳 (census の錨は本未リリース節)**: 単体 / 統合 **74 ファイル 3,631 テスト** 全緑 — 0.8.0 出荷時の
95
+ 72 ファイル 3,456 に v0.9 進行分 +175 (step 5e (2026-09-16) の行別再導出 — 新規 `trailingFreeze` 統合 spec
96
+ (`src/trailingFreeze.spec.tsx`) 34 + リリースパス docs census (`src/releaseCensus.spec.ts`) 10 + 既存 17 ファイル +131
97
+ (うち +2 は v0.8 出荷時登記の過少是正: `ListPopover` 実測 12 / `richCells` 実測 46)。3,456 + 175 = 3,631、ファイルは
98
+ 72 → 74) 込み。E2E は **21 ファイル 236 テスト** 全緑 (2026-09-16 — 新設 `tests/e2e/trailing-freeze.spec.ts` 18 +
99
+ `styles.spec.ts` の UNGATED 書込カウンタ / バブルブラケット +1。帯件数ゲートは計画 §5.8 の選択分岐列 = 単一 45 /
100
+ 16 レンジ 450)。ADR-22 step 5c 認証は横レッグ発火 (per-event p90 > 2.0 ms @ pre-derate 720 ノード) — 選択分岐
101
+ (`bandRangeCapFor` 9 象限 cap 10 = 450 帯ノード) の認証記録 (縦 in-callback rAF p90 1.35 ms / 横 per-event p90 1.80 ms /
102
+ events-per-frame 分布 {"1": 60} / HeadlessChrome 148.0.7778.96) は分冊 08 §9。step 5e 登記パス再実測 (デレート済み
103
+ 9 象限アンビエント、2 solo run 最小値): 自前 copy 26.5 ms / 自前 Ctrl+V 29.2 ms / メニュー paste 61.2 ms / 外来 Sheets
104
+ 99,900 セル 1,001.9 ms。TF 変異台帳は全 24 行 ✅ red 実測 (分冊 07 §5.10 の判定列 — 生存者 0、TF-5 の初回生存は
105
+ §5.3 (c) スキャナ強化で kill)。
106
+ 出荷済み 0.8.0 節は出荷時リリースノートに凍結 (`hxCensus.spec.ts` の本文 SHA-256 ピン) — 台帳が動いたとき更新する
107
+ のは本節と分冊 07 §2 台帳だけで、released ノートは遡及更新しない。
108
+
5
109
  ## 0.8.0 (2026-09-14)
6
110
 
7
111
  v0.8 はリッチセル — 宣言式のセル書式 / 注釈 / 検証 / 条件付き書式、Excel / Google Sheets クリップボード相互運用、
package/README.md CHANGED
@@ -19,6 +19,13 @@ deep-reach proof route.
19
19
  - 🛡️ **Never silently freezes**: every bulk operation (copy / cut / delete / paste / fill / resize fan-out / autofit) is size-estimated **before** any expansion and rejected past its `maxOperationCells`-derived ceiling with a typed callback
20
20
  - ♿ **Screen-reader announcements (opt-in)**: provide the formatter props and cell/selection changes are announced via a debounced live region — the package ships no built-in strings
21
21
  - 🧊 **Frozen columns & rows** (v0.5 / v0.6): `frozenColumns` pins the first N columns as a static left band and `frozenRows` pins the first N rows as a static top band — two-band headers, a Cartesian-product 4-quadrant clipped selection overlay, partition-aware hit-testing and ensure-visible on both axes, all riding the engine's anchor-free frozen bands
22
+ - 📌 **Trailing freeze & strong freeze dividers** (v0.9): `frozenTrailingColumns` / `frozenTrailingRows` pin the **last** N columns / rows (a fixed totals row is the canonical use) as static right / bottom bands over the **same data** (count-based, Glide-style — no extra-row API), with three-band headers,
23
+ a {main, frozen, scroll, trailing}² clipped selection overlay, three-way partitioned hit-testing / ensure-visible, near-edge resize strips with pinned-right semantics, and band-side unhide markers; **every** freeze boundary (leading and trailing, both axes) draws a strong Excel / Sheets-style
24
+ divider over the scroll band's edge pixels — 2px `#9ca3af` by default, consumer-tunable through the CSS variables `--aqsp-divider-color` / `--aqsp-divider-width` (no prop), `pointer-events: none` — see "Trailing freeze and the freeze divider"
25
+ - 🔒 **Read-only cells as a capability** (v0.10 — ADR-27..31): one optional adapter predicate `isCellReadOnly(row, col)` locks cells — no edit session can open on them (all six entries, the touch on-screen-keyboard invitation included), an open session's commit is live re-checked,
26
+ and every write whose target set touches a read-only cell (Delete / paste / cut both walls / fill with diagonal L-legs decomposed / format / self-calc / annotations) refuses the **whole operation** before the adapter is reached (Excel-style, no per-cell skip); painted cells carry
27
+ `aria-readonly="true"` + `data-aqsp-readonly` (style it yourself — no package CSS), refusals speak through the single sealed `onReadOnlyRejected` (+ opt-in `formatReadOnlyAnnouncement`), and copying or viewing notes / comments / links stays legal (content-aware chords,
28
+ viewer-mode popovers with the commit affordance disabled); undeclared ⇒ byte-identical v0.9 behavior
22
29
  - 🎨 **Rich cells as capability declarations** (v0.8 — ADR-13): eight optional adapter reads (`getCellFormat` / `getCellFormula` / `getCellNote` / `getCellComments` / `getCellHyperlink` / `getCellValidation` / `getConditionalFormats` / `getRangeExtrema` + `evaluateFormula`) unlock the full cell
23
30
  format model (value format, alignment, vertical alignment, wrap, background / font colour, bold / italic / underline / strikethrough, font family / size in pt, per-side borders with Excel-style ownership folding), note / comment / link indicators, paint-time validation marks and sheet-level
24
31
  conditional formats (cell value / text / empty / colour scale / custom formula, 2-D bucket index, first-match-per-property precedence) — every undeclared aspect keeps the v0.7 DOM byte-identical, and writes go through a patch-typed `applyEdits(edits, context)`
@@ -107,6 +114,7 @@ The host element must have a resolvable height — the grid fills its parent.
107
114
  | `getCellValidation(row, col)` | **Optional capability** (v0.8) — the cell's `DataValidationRule` (`list` / `number` / `textLength` / `date` / `text` / `checkbox` / `custom`, each with `strict?` / `inputMessage?` / `errorMessage?`) or `undefined`; return a **stable rule object per cell** (list Sets are interned by identity). A commit that changes the text is validated (`validateCellText`): a non-strict rule accepts the value and paints the `data-aqsp-invalid` mark, a `strict: true` rule refuses the commit (`onValidationRejected` + the message popover — the editing-column Ctrl+Enter `fillSelection` runs the same gate on every selected cell's own rule before its single `applyEdits`), a blank is always valid, a half-written rule (unparseable bound, missing `b`) is inert, and `custom` rules are evaluated only at commit through `evaluateFormula` (never at paint). Unlocks the list picker (`Alt+ArrowDown`), the input-message popover on active-cell change, `handle.setValidation` and the validation paste aspect. |
108
115
  | `getConditionalFormats()` | **Optional capability** (v0.8) — the sheet's `ConditionalFormatRule[]` in priority order (`{ id, ranges, condition, style, stopIfTrue? }`; conditions `cellValue` / `text` / `empty` / `notEmpty` / `colorScale` / `customFormula`; style = background / color / bold / italic / underline / strikethrough). **Requires `getCellFormat`** (declaring it alone throws a `RangeError` at mount). Keep the array identity stable and publish changes through `dataVersion`; the grid rebuilds a lazy 2-D 256 × 256 bucket index per (rules identity, render version) and, inside each bucket, a lazily materialised per-column slice, so per-cell cost is O(rules intersecting the cell's column within its 256-row band) — for whole-column rules exactly the rules on that column, whatever the sheet width (a sheet of ≤ 256 columns has a single column band, so the bucket alone would be the whole rule set). Every true rule contributes, an earlier rule wins per property, `stopIfTrue` ends the walk. Pasted rules arrive as `context.conditionalFormats` (`ConditionalFormatRuleInput` with `sourceRuleId` — you mint the new id). |
109
116
  | `getRangeExtrema(range)` / `evaluateFormula(formula, cell)` | **Optional capabilities** (v0.8) consumed by the rules above: `getRangeExtrema` returns `{ min, max }` (or `null` = no numeric value) for a `colorScale` point without `value` — called once per rule range per render version and memoised; `evaluateFormula` receives a `customFormula` verbatim (no reference shifting — you own relative semantics) and a `custom` validation formula at commit, truth = `true` or a finite non-zero number. Undeclared ⇒ the rules that need them are skipped (nothing is substituted). |
117
+ | `isCellReadOnly(row, col)` | **Optional capability** (v0.10 — the 11th optional member, a non-aspect capability). `true` marks the cell read-only: no edit session can open on it (all six entries, including the touch on-screen-keyboard invitation, refuse **before** any side effect), an already-open session's commit is live re-checked, and every write producer whose target set contains one read-only cell — Delete, paste, cut (both the Ctrl+X arming and the move-time source re-check), fill (diagonal L-legs decomposed exactly), format, self-calc, annotations — refuses the **whole operation** before the adapter is reached (Excel-style, never a per-cell skip). Painted read-only cells carry `aria-readonly="true"` + `data-aqsp-readonly` (no package CSS — style the attribute yourself); refusals fire the single sealed callback `onReadOnlyRejected` (+ opt-in `formatReadOnlyAnnouncement` for the live region) and never reuse `onEditRejected` / `onOperationRejected`. Reading stays legal: copying from read-only cells, filling **from** a read-only source into editable cells, and viewing existing notes / comments / links (content-aware chords open viewer-mode popovers with the commit affordance disabled). The predicate is a **live read** — no memoization; publish changes through `dataVersion` (or an adapter swap) like every other read. It is called with in-grid coordinates only, on gesture paths and for painted cells, so budget O(1) per call without allocating; a throw propagates uncaught (fail-fast, never swallowed). Undeclared ⇒ every cell is editable and the DOM / callbacks are byte-identical to v0.9 (a capability branch, not a fallback). Reference: the `/readonly` demo. |
110
118
 
111
119
  - **Adapter identity is the sheet identity.** Replacing the adapter object means "sheet switch": selection resets to A1, any in-flight drag, editing session and open popover are terminated (value-discarding, exactly like Excel switching sheets — nothing is ever committed across a swap), an armed
112
120
  **copy** is discarded, and an armed **cut** is kept as a *foreign* clip (`onClipChange({ kind: "cut", range, foreign: true })`, its marquee hidden) so the next `Ctrl+V` on the new sheet is a **cross-sheet move** — the target sheet is written first, then the source sheet is cleared under the same
@@ -145,8 +153,64 @@ gesture: `"edit"` (cell commit, list pick, `insertDate` / `insertTime` while idl
145
153
  `sameSheet` = the armed payload came from this adapter, and `conditionalFormats` = the pasted rules translated per tile, `[]` when
146
154
  none), `"fill"` (fill handle, `fillDown` / `fillRight` / `fillSelection`), `"delete"` (Delete / Backspace), `"format"` (format commands,
147
155
  `applyFormat` / `setBorders`), `"calc"` (`multiplyBy100` … `signReversal`), `"annotate"` (note / comment / link / validation writes). The
148
- `/styles` demo store (`demo/src/pages/styles/richStore.ts`) is the reference for a patch-applying store that mints comment ids and
149
- folds a transaction's inverse patches into one Undo entry.
156
+ `/styles` demo store (`demo/src/pages/styles/richStore.ts` — source checkout only, not in the npm tarball) is the reference for a patch-applying store that mints comment ids and
157
+ folds a transaction's inverse patches into one Undo entry. A minimal inline version of that store (the shape most consumers start from):
158
+
159
+ ```ts
160
+ // Map-backed store + inverse-patch undo grouped by EditContext.transaction (package owns no history — ADR-6).
161
+ type Inverse = { edits: CellEdit[] } // inverse patches of ONE transaction (first-seen pre-values)
162
+ const cells = new Map<string, string>() // "row:col" -> text (undefined = default)
163
+ const undoStack: Inverse[] = []
164
+ const redoStack: Inverse[] = []
165
+ let dataVersion = 0 // pass to <Spreadsheet dataVersion={dataVersion}> and bump per external write
166
+
167
+ const keyOf = (row: number, col: number) => `${row}:${col}`
168
+ const textOf = (row: number, col: number) => cells.get(keyOf(row, col)) ?? ""
169
+
170
+ const adapter: SpreadsheetDataAdapter = {
171
+ rowCount: 200,
172
+ colCount: 26,
173
+ getCellText: textOf,
174
+ applyEdits: (edits, context) => {
175
+ // one applyEdits call = one transaction = one undo entry; record the FIRST-seen pre-value per cell
176
+ // (edits apply in array order with last-wins per key — the inverse must restore the value before the
177
+ // first edit of the transaction, which is what makes a cut-move's clear+write fold correctly)
178
+ const inverse: Inverse = { edits: [] }
179
+ const seen = new Set<string>()
180
+ for (const edit of edits) {
181
+ const key = keyOf(edit.row, edit.col)
182
+ if (!seen.has(key)) {
183
+ seen.add(key)
184
+ inverse.edits.push({ row: edit.row, col: edit.col, text: textOf(edit.row, edit.col) })
185
+ }
186
+ if (edit.text !== undefined) {
187
+ cells.set(key, edit.text)
188
+ }
189
+ }
190
+ // context.transaction groups the two applyEdits calls of a cross-sheet cut-move into ONE undo entry
191
+ // on the consumer side (both calls carry the same id — see the facts below); a single-sheet gesture
192
+ // is always one call, so pushing per call and merging equal ids both work.
193
+ undoStack.push(inverse)
194
+ redoStack.length = 0
195
+ void context
196
+ },
197
+ }
198
+
199
+ const undo = () => {
200
+ const entry = undoStack.pop()
201
+ if (entry === undefined) return
202
+ const redo: Inverse = { edits: entry.edits.map((e) => ({ row: e.row, col: e.col, text: textOf(e.row, e.col) })) }
203
+ for (const e of entry.edits) cells.set(keyOf(e.row, e.col), e.text ?? "")
204
+ redoStack.push(redo)
205
+ dataVersion += 1 // publish the external write
206
+ }
207
+ ```
208
+
209
+ Three facts already registered above interlock with this store: a **rejected** `applyEdits` call consumes its transaction id too
210
+ (ids name attempts, not successes); a **cross-sheet cut-move** is two `applyEdits` calls under ONE id (fold them into one undo
211
+ entry by the id); and **track sizes are not undoable** (`resizeTracks` geometry never reaches `applyEdits`, so a paste that also
212
+ resized columns undoes its cell writes only). The store helpers stay consumer-owned by design (ADR-6) — the package exports no
213
+ undo utilities.
150
214
 
151
215
  - **In-grid commits** (Enter, Tab / Shift+Tab, PageUp / PageDown, clicking or right-clicking another cell, or a primary-button
152
216
  press on a header / corner / scrollbar band / resize strip — a secondary-button press on those surfaces is a native-menu
@@ -281,8 +345,11 @@ When the pressed track belongs to a **full row/column selection** (header select
281
345
  every selected track — hidden tracks included, which makes fan-out an implicit unhide (Excel-compatible). Fan-outs and
282
346
  hides are gated (see the limits table above; row hides are additionally capped at 1,000 hidden rows per axis).
283
347
 
284
- **Double-click autofit** (columns): each selected column is measured individually over the cells of the **current visible
285
- window** (not all data) and set to the widest text + padding; columns whose visible cells are all empty are left untouched.
348
+ **Double-click autofit** (columns): each selected column is measured individually over the cells of the **leading frozen
349
+ band ∪ current visible window ∪ trailing frozen band** (not all data; each set filtered to visible rows — v0.9 ADR-21:
350
+ a pinned totals row is typically the widest cell and used to go unmeasured, and the leading band joins the set as a
351
+ deliberate behaviour change for existing `frozenRows > 0` users) and set to the widest text + padding; columns whose
352
+ measured cells are all empty are left untouched. The measured-cell budget gate counts band rows.
286
353
  The measured text is the **formatted display string** (what the screen actually shows — v0.7): a currency column autofits
287
354
  to `"¥203,428"`, not to the raw `"203428"`, which used to left-clip the most significant digits of right-aligned cells.
288
355
  Row autofit restores the default row height (cells render single-line clipped). Autofit is disabled entirely when
@@ -323,6 +390,51 @@ transaction. On success the selection becomes the box (anchor and active stay pu
323
390
  `enableFillHandle={false}` removes the handle and inerts the machine — recommended for read-only adapters, where every fill
324
391
  attempt would otherwise end in `onEditRejected`.
325
392
 
393
+ ## Trailing freeze and the freeze divider (v0.9)
394
+
395
+ `frozenTrailingColumns` / `frozenTrailingRows` pin the **last** N tracks of the same data as static right / bottom bands
396
+ (count-based, Glide-style `freezeTrailingRows` asymmetry — deliberately **not** an extra-rows API, which would break the
397
+ flat address space). Trailing cells are ordinary cells: selection, fill, clipboard, editing and popovers cross the
398
+ boundary with zero special-casing (fragments are clipped per quadrant); a trailing cell edits in place like any other.
399
+ Trailing rows/columns are **positional** — always the last N of the adapter's data — so a consumer that implements
400
+ sorting must exempt its totals rows itself (see deviations). Effective counts clamp with **leading wins**
401
+ (`min(N, count − effectiveLeading)`), and when the leading + trailing pixel extent exceeds the viewport (overlap), the
402
+ occluded trailing cells cannot be scrolled into view — registered consequence, not an error state.
403
+
404
+ **Freeze divider.** Every freeze boundary — leading and trailing, both axes, body and headers — draws a strong
405
+ Excel / Sheets-style divider line over the **scroll band's** edge pixels (content scrolls in under the line; frozen band
406
+ cells are never occluded). The package owns the nodes (`[data-aqsp-freeze-divider]`, `pointer-events: none`) and reads
407
+ two CSS custom properties — the only consumer surface (there is no prop):
408
+
409
+ | Variable | Default | Meaning |
410
+ | :--- | :--- | :--- |
411
+ | `--aqsp-divider-color` | `#9ca3af` (gray-400 — one step darker than every gridline) | Divider colour |
412
+ | `--aqsp-divider-width` | `2px` (Sheets strength) | Divider thickness |
413
+
414
+ ```css
415
+ /* consumer stylesheet — any scope that contains the grid root */
416
+ .my-sheet {
417
+ --aqsp-divider-color: #475569;
418
+ --aqsp-divider-width: 3px;
419
+ }
420
+ ```
421
+
422
+ - Setting `--aqsp-divider-width: 0px` is the escape hatch: the per-quadrant fill-fragment seams at each freeze boundary
423
+ re-appear (they are normally absorbed by the divider), and the forced-colors rule paints nothing either — intended.
424
+ - The divider scales in **logical px** with the package zoom (at z = 0.25 a 2px divider renders 0.5 visual px —
425
+ registered deviation).
426
+ - The divider is **inert**: a drag on the bar starts a header selection; the resize strip sits immediately adjacent on
427
+ the band side of the line (freeze-by-drag stays out of scope). Under forced colors the divider paints `CanvasText`.
428
+ - Under full overlap (middle band collapsed) no divider renders at all — there are no scroll pixels to overlay — and the
429
+ leading / trailing fill fragments butt directly, re-exposing that seam (registered artefact).
430
+
431
+ **Trailing resize idiom (pinned-right).** Trailing header strips sit at each trailing track's left / top boundary and
432
+ resize the track on their **far side** (column: right, row: below); the dragged edge is the moving edge, so the pending
433
+ size is `initialSize − delta` (dragging left / up grows the track). Double-click autofits the same far-side track. At
434
+ T = 1 every grow gesture pins the guideline at the band edge while the size follows the pointer. Hidden trailing runs
435
+ restore from the **band-side** near-edge unhide markers (the scroll-side markers pointing into the band are deduplicated
436
+ away while the band is visible; with every trailing track hidden the scroll-side marker remains the only restore path).
437
+
326
438
  ## Accessibility model and trade-offs
327
439
 
328
440
  The grid renders cells as a **non-focusable virtualized surface** with a hidden 1px textarea ("sink") owning focus and
@@ -575,6 +687,8 @@ Keymap notes:
575
687
  | `defaultRowHeight` / `defaultColumnWidth` | `number` | Integer px (defaults 24 / 100). Per-column widths are applied through the imperative `resizeColumn` (the v0.3 `initialColumnWidth` prop was retired in v0.4). |
576
688
  | `frozenColumns` | `number` | Freezes the first N columns at the left edge (v0.5; default 0, LIVE). Engine-validated (integer in [0, 128] — `RangeError` otherwise; values beyond `colCount` freeze every column). Frozen columns stay visible at any horizontal scroll; `scrollToCell` / keyboard ensure-visible treat them as horizontal no-ops. |
577
689
  | `frozenRows` | `number` | Freezes the first N rows at the top edge (v0.6; default 0, LIVE — the row-axis twin of `frozenColumns`). Engine-validated (integer in [0, 128] — `RangeError` otherwise; values beyond `rowCount` freeze every row). Frozen rows stay visible at any vertical scroll; keyboard ensure-visible treats them as vertical no-ops. |
690
+ | `frozenTrailingColumns` | `number` | Freezes the **last** N columns at the right edge (v0.9; default 0, LIVE). Engine-validated pass-through, no second validation layer (integer in [0, 128] — `RangeError` otherwise); the effective count is clamped so the leading freeze wins the count space (`min(N, colCount − effectiveFrozenColumns)`). Trailing columns stay visible at any horizontal scroll — **except** when the leading + trailing extent exceeds the viewport: occluded trailing cells cannot be scrolled into view and keyboard focus draws no visible frame there (registered overlap consequence). `scrollToCell` / keyboard ensure-visible treat trailing targets as horizontal no-ops. An in-flight change of any of the four freeze props cancels every drag without restore. |
691
+ | `frozenTrailingRows` | `number` | Freezes the **last** N rows at the bottom edge (v0.9; default 0, LIVE — the row-axis twin of `frozenTrailingColumns`; a pinned totals row is the canonical use). Same engine validation and leading-wins clamp; trailing rows stay visible at any vertical scroll (same overlap exception); keyboard ensure-visible treats them as vertical no-ops. |
578
692
  | `renderCell` | `(ctx: RenderCellContext) => ReactNode` — `RenderCellContext = { row; col; text }` (exported) | Custom cell rendering. **Must be identity-stable** (`useCallback`) — an inline arrow defeats the cell memo and re-renders every visible cell each pass. |
579
693
  | `maxOperationCells` | `number` | Bulk-operation ceiling (default 100,000). Row fan-out resize gates at 1/10 of it and autofit at 1/4 — see "Operation limits". |
580
694
  | `enableResizeUI` | `boolean` | Default `true`. `false` removes resize strips **and unhide markers** — hidden tracks then have no in-package restore path (imperative API only). |
@@ -588,7 +702,7 @@ Keymap notes:
588
702
  | `onUndoRequest` / `onRedoRequest` | `() => void` | `Ctrl+Z` / `Ctrl+Y` / `Ctrl+Shift+Z` relays. |
589
703
  | `formatCellAnnouncement` / `formatSelectionAnnouncement` | formatter | Opt-in screen-reader announcements (see above). |
590
704
  | `formatCommandAnnouncement` | `(ctx: { command: SpreadsheetCommand; state: boolean \| null }) => string` | Opt-in (v0.8) live-region text after a successful format command; `state` is the toggle's post-state (`bold` / `italic` / `underline` / `strikethrough` / colour toggles), `null` for non-toggle commands. Also makes the live region render on its own. |
591
- | `labels` | `SpreadsheetLabelOverrides` (any subset of the 24 `SpreadsheetLabels` keys) | v0.8 — replaces the `sinkAriaLabel` / `rootAriaLabel` props (folded in) and names every popover / indicator string (`noteTitle`, `noteSave`, `noteEscapeHint`, `noteRejectedHint`, `commentTitle`, `commentReply`, `commentPost`, `commentCount` (string or `(n) => string`), `linkTitle`, `linkTextField`, `linkUrlField`, `linkOpen`, `linkSave`, `listTitle`, `listEmpty`, `listMore` (string or `(n) => string` — the validation-list picker mounts at most 500 options and announces the hidden-match count on this row), `messageTitle`, `validationRejected`, `popoverClose`, `indicatorNote`, `indicatorComment`, `indicatorInvalid`). Omitted / `undefined` keys keep the English `DEFAULT_LABELS`; `sinkAriaLabel` must mention `Escape` (`RangeError` during render otherwise). |
705
+ | `labels` | `SpreadsheetLabelOverrides` (any subset of the 25 `SpreadsheetLabels` keys) | v0.8 — replaces the `sinkAriaLabel` / `rootAriaLabel` props (folded in) and names every popover / indicator string (`noteTitle`, `noteSave`, `noteEscapeHint`, `noteRejectedHint`, `noteViewerHint` (v0.10 — the note hint in the read-only viewer mode, which never promises a save), `commentTitle`, `commentReply`, `commentPost`, `commentCount` (string or `(n) => string`), `linkTitle`, `linkTextField`, `linkUrlField`, `linkOpen`, `linkSave`, `listTitle`, `listEmpty`, `listMore` (string or `(n) => string` — the validation-list picker mounts at most 500 options and announces the hidden-match count on this row), `messageTitle`, `validationRejected`, `popoverClose`, `indicatorNote`, `indicatorComment`, `indicatorInvalid`). Omitted / `undefined` keys keep the English `DEFAULT_LABELS`; `sinkAriaLabel` must mention `Escape` (`RangeError` during render otherwise). |
592
706
  | `shortcuts` | `ShortcutOverrides` = `{ [command]?: readonly string[] \| null }` | v0.8 — rebinds command chords (array = replace the default list, `null` = unbind); validated during render with `RangeError`s (see "Command shortcuts"). Identity-stable — consulted through a ref. |
593
707
  | `colors` | `{ fill?: readonly string[]; font?: readonly string[] }` | v0.8 — the five `#rrggbb` slots of `fillColorCustom1..5` / `fontColorCustom1..5` (exactly 5 six-digit hex strings per axis, `RangeError` otherwise); an omitted axis uses `DEFAULT_CUSTOM_COLORS` (`#FF0000` / `#00FF00` / `#0000FF` / `#FFFF00` / `#FF00FF`, exported). |
594
708
  | `formats` | `{ currency: string; locale?: string }` | v0.8 — the host currency (ISO code) and rendering locale for `formatCurrency` and for recovering bare currency symbols (`$`, `¥`) in pasted Excel / Sheets HTML; undeclared ⇒ `formatCurrency` passes through and bare symbols are not recovered. |
@@ -602,6 +716,10 @@ Keymap notes:
602
716
  | `onPasteSpecialRequest` | `(origin: { kind: "keyboard"; cellRect }) => void` | v0.8 — `Ctrl+Alt+V` asks the host for its Paste Special UI (the active cell's viewport rect in visual px, the `onCellContextMenu` keyboard shape); undeclared ⇒ `Ctrl+Alt+V` is a formats-only paste through the menu path. |
603
717
  | `onClipboardUnavailable` | `(cause: "insecure-origin" \| "permission-denied" \| "empty" \| "unreadable") => void` | v0.8 — the menu path's refusal channel (`handle.paste`, `Ctrl+Alt+V` without a request handler, the `paste*` commands); whenever it fires the paste resolves `"rejected"`. `"unreadable"` also fires on the paste event when the detected source cannot be parsed (the package never slides to another reader). |
604
718
  | `now` | `() => number` | v0.8 — clock seam of `insertDate` / `insertTime` (declared default `Date.now`). |
719
+ | `onPasteResolved` | `(outcome: PasteOutcome, channel: ClipChannel) => void` | v0.10 — paste-outcome summary. The menu channel mirrors `handle.paste(options)`’s resolve value 1:1 (read refusals and the write-time re-gate included); the event channel fires once per gesture that reaches the paste pipeline (incl. the image-only-clipboard `"empty"`). The drag-freeze consumption and a null `clipboardData` stay deliberately silent, and an empty grid attaches no paste listener at all (structurally unreachable). Cause-specific callbacks (`onClipboardUnavailable` / `onOperationRejected` / `onReadOnlyRejected`) fire FIRST — a `"rejected"` outcome can be the trailing summary of one of them. |
720
+ | `onReadOnlyRejected` | `(rejection: ReadOnlyRejection) => void` | v0.10 — the single read-only refusal channel: fired once per refused gesture, the adapter was never called (neither an `EditRejection` nor an operation limit). The edit variant carries the cell and the required `editSession: "none" \| "kept" \| "discarded"` fate; the operation variant carries the explicit 7-literal `op` (`"cut"` has two producers — the Ctrl+X arming refusal and the move-time source re-check) and the producer’s overlap-naive `cellCount`. Never fired when `isCellReadOnly` is undeclared. |
721
+ | `formatReadOnlyAnnouncement` | `(rejection: ReadOnlyRejection) => string` | v0.10 — opt-in live-region text for a read-only refusal (inherits the 200 ms announce debounce + rAF collapse, so autorepeat / IME refusal storms cannot spam the live region). Omit for silence (no built-in strings). |
722
+ | `tapScrollCircleOptions` | `ScrollBarTapCircleOptions` | v0.10 — position / enable-disable tuning of the touch tap-scroll circle; an engine-validated pass-through onto VirtualGrid’s `scrollBarOptions.tapScrollCircleOptions` (the engine’s `resolveTapScrollCircleOptions` is the validation SSOT — no second layer here). LIVE prop. |
605
723
  | `testId` / `className` | `string` | Test hook / root class extension. |
606
724
 
607
725
  ## Imperative handle
@@ -662,7 +780,7 @@ importers with the exact same Excel-compatible semantics the grid uses. `enumera
662
780
  `onOperationRejected` op), and `EditRejection` (the `onEditRejected` context — see "Editing, Undo, and rejection") are exported for
663
781
  consumer-side annotations.
664
782
 
665
- v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/index.ts` exports 39 values and 94 types in total — every named type an exported declaration reaches is exported too, so nothing has to be extracted with `Awaited<ReturnType<>>` / `Extract<>`; `src/index.spec.ts` pins it):
783
+ v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/index.ts` exports 39 values and 97 types in total — every named type an exported declaration reaches is exported too, so nothing has to be extracted with `Awaited<ReturnType<>>` / `Extract<>`; `src/index.spec.ts` pins it):
666
784
 
667
785
  - **Clip payload** — `serializeClipPayload(payload)` / `parseClipPayload(json)` (the lossless wire format `{ v: 1, rows, cols, clipId, formats, validations, cells, colWidths?, rowHeights?, wholeRows, wholeCols, conditionalFormats }`; the parser is total and returns `null` on ANY malformed input, the serializer validates the same way and throws a `RangeError`),
668
786
  `detectClipSource(flavours, channel)` (`"aqsp" | "sheets" | "excel" | "html" | "tsv" | "none"` — the ADR-14 detection over `{ types, getData }`), and the types `ClipPayload` / `ClipCell` / `ClipComment` / `ClipSource` / `ClipInfo` / `ClipFlavours` / `ClipChannel` (the `detectClipSource` parameter types).
@@ -688,6 +806,9 @@ The `/utils` demo route exercises the v0.1 toolkit live; the `/styles` route dri
688
806
  | Editor while scrolled away | The visible editor disappears once the edited cell's top-left corner leaves the viewport (session and value survive; it re-shows on scroll-back) | The editor scrolls off with its cell, partially clipped | The sink is viewport-clamped for focus/IME survival — showing it clamped would paint an editor over the wrong cells |
689
807
  | Zoom shortcuts | Alt+Wheel / Ctrl+Alt+`=` `+` `-` `0` (produced-character match, Shift-agnostic) | Ctrl+Wheel | The browser owns Ctrl+Wheel in a web page — a conflicting binding would fight page zoom |
690
808
  | Multi-range copy | Tail range only + `onCopyMultiRange` notice | Aligned ranges concatenate | Ambiguous general case; explicit callback instead of silent difference |
809
+ | Multi-range paste | Pastes into the **tail** range of a multi-range selection only (no per-range fan-out; a dedicated `onPasteMultiRange` notice is deferred until demanded) | Repeats the clip into every selected range when shapes align | The tail range is the anchor the tiling geometry already targets; fanning out multiplies every gate (limit / bounds / read-only) per range for a gesture whose Excel semantics are themselves shape-conditional |
810
+ | Paste numeric normalization | Flavour-specific by contract: the HTML path canonicalises displayed numbers back to raw values only for cells whose incoming payload declares a numeric format (`numberDisplayToCanonical` — format-fidelity recovery); General-format and TSV text passes through verbatim | Excel re-parses displayed strings by locale heuristics on every paste | Re-parsing unformatted text guesses; the declared-format path is the only one where the display→canonical inversion is well-defined |
811
+ | Paste vs data validation | Paste bypasses `getCellValidation` rules BY DESIGN (strict rules gate commits, not pastes; an opt-in prop is deferred until demanded) | Same — Excel pastes over validated cells without running validation | Excel parity; validation is a commit-time gate (see "Editing, Undo, and rejection") |
691
812
  | Cut paste clipping | Rejected entirely (`"bounds"`) | Rejects likewise | Clipping a move would silently destroy the clipped cells |
692
813
  | Ctrl+Arrow without `findDataEdge` | Grid edge | Data edge | Data-end scan is a store capability — declare `findDataEdge` for Excel behavior |
693
814
  | Ctrl+Arrow landing on a hidden cell | Rescans forward first, then backward, else stays | Can land on the hidden cell | Forward keeps data beyond a hidden edge reachable; the backward snap only fires when nothing is visible ahead |
@@ -708,7 +829,7 @@ The `/utils` demo route exercises the v0.1 toolkit live; the `/styles` route dri
708
829
  | Fill-handle double-click | No-op | Fills down to adjacent data | Needs composition with `findDataEdge` (roadmap) |
709
830
  | Copy / TSV of formatted cells | Carries the **raw** canonical `getCellText` | Copies the **display** string into `text/plain` | Raw preserves the numeric round-trip (paste back, spreadsheets, importers); the display string is a lossy projection |
710
831
  | Too-narrow formatted cells | Content clips (right-aligned cells clip on the **left**, Sheets-style); autofit now measures the formatted display, preventing the common case | Renders `####` for numeric cells that don't fit | A `####` substitute needs per-cell width probing on every paint; clipping keeps rendering O(visible) |
711
- | Autofit scope | Visible-window measurement (columns) / default-height restore (rows) | Scans all data / wrap-measures row heights | Scanning 1M rows would freeze; cells render single-line clipped |
832
+ | Autofit scope | Leading band ∪ visible window ∪ trailing band measurement (columns — v0.9 ADR-21; the band inclusion is a deliberate behaviour change for existing `frozenRows > 0` users) / default-height restore (rows) | Scans all data / wrap-measures row heights | Scanning 1M rows would freeze; band rows (totals) are typically the widest cells; cells render single-line clipped |
712
833
  | Autofit / dblclick caret with `renderCell` | Disabled (no-op / caret at end) | — | Custom cell DOM cannot be measured or offset-mapped |
713
834
  | Same-value resize release | No-op | Equalizes all selected columns to the dragged width | A no-move release should change nothing |
714
835
  | Hiding everything | The last visible track of an axis clamps to 1px | Allows hiding all columns/rows | No select-all + Unhide menu exists here — an all-hidden grid would be unrecoverable |
@@ -748,11 +869,21 @@ The `/utils` demo route exercises the v0.1 toolkit live; the `/styles` route dri
748
869
  | Validation list picker | Case-insensitive **prefix** filter; mounts at most 500 options per render — a larger match set appends one `listMore` row naming the hidden count | Sheets filters by substring and virtualises the list | A pasted foreign rule may carry an arbitrarily large values array; mounting it whole would freeze the tab, and the prefix filter reaches everything beyond the cap |
749
870
  | Self-calc | Writes canonical decimals (exponent-form inputs keep exponent form), flips `TRUE` / `FALSE` case-insensitively, leaves custom checkbox labels alone, treats an `=`-prefixed text as text unless `getCellFormula` is declared, and wraps compound formulas (`=(A1)/100+(B1)/100` × 100 ⇒ `=((A1)/100+(B1)/100)*100`) instead of peeling | The reference extension peels greedily | The greedy regex produces the broken `=A1)/100+(B1` |
750
871
  | Excel / Word HTML without a table | `mso-` paragraphs (Word / Outlook) paste as text, not as an unreadable Excel clip | — | No table reader can read them; ADR-14 forbids trying another reader |
872
+ | Freeze divider interactivity | The divider is inert (`pointer-events: none`): a drag on the bar starts a header selection; the resize strip sits immediately adjacent on the band side of the line | In Sheets this exact visual is the freeze drag handle | Excel behaviour (static thin line) in Sheets clothing — freeze-by-drag would be its own freeze-UI feature, out of scope |
873
+ | Freeze divider under zoom | Logical px — scales with the package zoom (z = 0.25 renders a 2px divider at 0.5 visual px) | Excel / Sheets dividers are UI chrome, unaffected by sheet zoom | One coordinate space for all chrome; a zoom-compensating width var is a recorded future path, not added for this alone |
874
+ | `--aqsp-divider-width: 0px` | Escape hatch: per-quadrant fill-fragment seams re-appear at every freeze boundary, and forced-colors paints nothing | — | The divider is what absorbs those seams; removing it re-exposes the pre-v0.9 artefact — intended |
875
+ | Occluded trailing cells (overlap) | When leading + trailing extent exceeds the viewport, occluded trailing cells cannot be scrolled into view and keyboard focus draws no visible frame; the count clamp gives leading the count space | Excel refuses to freeze past the viewport | Count-based trailing freeze is LIVE and viewport-independent; the degenerate state is registered, not guarded |
876
+ | Overlap divider collapse | With the middle band fully collapsed no divider renders, and the leading / trailing fill fragments butt directly (seam re-exposed) | — | Divider pixels live only on scroll-band pixels (structural placement rule); a collapsed middle band has none |
877
+ | Ctrl+Home / Ctrl+End with freezes | Native pass-through (unchanged) | Land on the first / last unfrozen cell | Freeze-aware Ctrl+Home/End is a NEW feature — recorded future work, not part of the trailing-freeze release |
878
+ | Trailing resize idiom | Near-edge one-sided strips; each strip controls its far-side track; the dragged edge is the moving edge (pinned-right); double-click autofits the same far-side track; T = 1 grow gestures pin the guide at the band edge; hidden trailing runs restore from band-side near-edge markers | Excel has no trailing freeze | Mirrors the shipped leading idiom under the pinned-right constraint |
879
+ | Trailing autofit boundary side | An internal trailing boundary autofits the track **after** it (far side); the visually identical leading-side boundary autofits the track **before** it | — | Consequence of pinned-right control — the strip and its controlled track sit on opposite sides of the two idioms |
880
+ | Trailing rows under consumer sorting | Trailing rows are positional (the last N of the same data) — a consumer implementing sorting must exempt its totals rows itself | — | The package has no sort/filter feature (adapter-owned pull model); the footer-permutation trap is a consumer contract |
881
+ | Frozen-band shadow | None — the divider is the only boundary emphasis | Sheets adds a drop shadow while scrolled | Derivability is kept (`getFrozenSize()` trailing fields); a shadow is a recorded future path |
751
882
 
752
883
  ## Demo (feature showcase, doubles as the E2E fixture)
753
884
 
754
885
  Run `pnpm run demo:dev` from a source checkout (the `demo/` app is not in the npm tarball) and open
755
- `http://localhost:5179`. Nine routes, each an in-header-nav page AND an E2E fixture (`tests/e2e/`):
886
+ `http://localhost:5179`. Ten routes, each an in-header-nav page AND an E2E fixture (`tests/e2e/`):
756
887
 
757
888
  | Route | What you experience |
758
889
  | --- | --- |
@@ -760,10 +891,11 @@ Run `pnpm run demo:dev` from a source checkout (the `demo/` app is not in the np
760
891
  | `/edit` | Editing, the reference inverse-patch Undo/Redo store, `findDataEdge` Ctrl+Arrow jumps, value-based auto-alignment via a bare `getCellFormat: () => undefined` declaration (typed numbers right-align like Excel), the imperative resize handle, and a Sheet A/B toggle (adapter-identity swap: selection resets to A1, the pending edit and an armed copy are discarded, an armed cut is kept as a foreign clip for a cross-sheet move) |
761
892
  | `/render` | `renderCell` formatting (currency / delta / meter / badge) over canonical strings, the selection readout (`onSelectionChange`), a custom context menu on both origins (right-click and Shift+F10), `onCopyMultiRange` |
762
893
  | `/format` | The built-in display formats (v0.7): a typed sheet whose columns declare `getCellFormat` (number with grouping / currency ¥ / percent / scientific / date / explicit-center text) with **no** `renderCell` — value-aware auto-alignment, raw-value editing and copy, formatted-display autofit, an **interactive format toolbar** (apply a value format and alignment to the selected cell/range independently, restore the column default — published through the documented `dataVersion` bump), plus the standard read-only wiring (`enableFillHandle={false}` and an `onEditRejected` readout whose wording is derived from the `EditRejection` context — a kept session says Escape cancels, a discarded one says the value is already gone — so Enter is never a silent dead key and a focus-out never gets bad advice) |
763
- | `/styles` | The v0.8 rich cells (`tests/e2e/styles.spec.ts`): a 300 × 26 sheet whose store declares all eight capabilities (a mini formula evaluator for `=A1+B1` / `SUM` / `AVERAGE` feeds `evaluateFormula` and the computed `getCellText`, `getCellFormula` returns the source), a patch-applying store with per-transaction inverse-patch Undo (a cross-sheet cut-move folds into one entry) and consumer-minted comment ids, a format toolbar driven by `handle.executeCommand` (every button keeps focus in the grid), colour pickers, border / number-format presets, link / note / comment / validation / conditional-format editors, a Paste Special menu opened by `onPasteSpecialRequest` (Excel accelerators — modes `A`ll / `F`ormulas / `V`alues / `T` formats / `C`omments and notes / `N` validation / `X` all except borders / `W`idths / `R` conditional formats, operations `O` none / `D` add / `S` subtract / `M` multiply / `I` divide, flags `B` skip blanks / `E` transpose / `J` inject formats → `handle.paste`), highlight-band settings, a shortcut rebinding table with reserved-chord warnings, a clip-info chip (`onClipChange`), a Sheet A / B toggle for cross-sheet paste and cut-move, and Excel / Sheets fixture buttons that write real interop HTML to the clipboard |
764
- | `/huge` | The v0.4 deep-reach proof: 1,000,000 × 10,000,000 on a pure functional adapter — Ctrl+→ (and End) reach the rightmost column 9,999,999 and Ctrl+↓ the last row 999,999 in one press (uniform-block Excel semantics, unified with the `/` route; ±1px cell placement), a "deep jump" button (`scrollToCell` to the interior column 5,000,000 ≈ 5 億 px, past the 2^25 wall), zoom ×4 holds ±1 logical px — plus runtime freeze toggles (0 ↔ 3 frozen rows above 0 ↔ 3 frozen columns), a context menu (right-click / Shift+F10) that opens at static frozen-cell coordinates even mid-deep-scroll, and the drag-freshness and performance-budget E2E gates |
894
+ | `/styles` | The v0.8 rich cells (`tests/e2e/styles.spec.ts`): a 300 × 26 sheet whose store declares all eight capabilities (a mini formula evaluator for `=A1+B1` / `SUM` / `AVERAGE` feeds `evaluateFormula` and the computed `getCellText`, `getCellFormula` returns the source), a patch-applying store with per-transaction inverse-patch Undo (a cross-sheet cut-move folds into one entry) and consumer-minted comment ids, a format toolbar driven by `handle.executeCommand` (every button keeps focus in the grid), colour pickers, border / number-format presets, link / note / comment / validation / conditional-format editors, a Paste Special menu opened by `onPasteSpecialRequest` (Excel accelerators — modes `A`ll / `F`ormulas / `V`alues / `T` formats / `C`omments and notes / `N` validation / `X` all except borders / `W`idths / `R` conditional formats, operations `O` none / `D` add / `S` subtract / `M` multiply / `I` divide, flags `B` skip blanks / `E` transpose / `J` inject formats → `handle.paste`), highlight-band settings, a shortcut rebinding table with reserved-chord warnings, a clip-info chip (`onClipChange`), a Sheet A / B toggle for cross-sheet paste and cut-move, and Excel / Sheets fixture buttons that write real interop HTML to the clipboard — and, since v0.9, the trailing freeze live by default: `frozenTrailingRows={1}` / `frozenTrailingColumns={1}` alongside the leading 1×1 (this nine-quadrant state is the ambient every `/styles` budget number is certified on) pin the sheet's grand-total row (the last row's `SUM` / `AVERAGE`, bold at the base font) to the bottom edge, with a `data-demo-trailing` checkbox to release it |
895
+ | `/huge` | The v0.4 deep-reach proof: 1,000,000 × 10,000,000 on a pure functional adapter — Ctrl+→ (and End) reach the rightmost column 9,999,999 and Ctrl+↓ the last row 999,999 in one press (uniform-block Excel semantics, unified with the `/` route; ±1px cell placement), a "deep jump" button (`scrollToCell` to the interior column 5,000,000 ≈ 5 億 px, past the 2^25 wall), zoom ×4 holds ±1 logical px — plus runtime freeze toggles (0 ↔ 3 frozen rows above 0 ↔ 3 frozen columns), a context menu (right-click / Shift+F10) that opens at static frozen-cell coordinates even mid-deep-scroll, and the drag-freshness and performance-budget E2E gates — plus the v0.9 trailing-freeze controls consumed by `tests/e2e/trailing-freeze.spec.ts`: trailing freeze toggles for 0 ↔ 2 last rows / columns (`data-demo-trailing-row-toggle` / `data-demo-trailing-col-toggle` — deliberately not the leading toggles' 0 ↔ 3), a row-axis deep jump to the last scroll row `rowCount − T − 1` (`data-demo-deep-jump-row`, reading the live toggle), and an overlap toggle (`data-demo-overlap-toggle` — 16 / 16 frozen rows and 8 / 8 frozen columns exceed the default E2E viewport on both axes) |
765
896
  | `/tune` | Sizing knobs (`defaultRowHeight` / `defaultColumnWidth` / banded per-column widths via the imperative `resizeColumn` mount effect), hide / unhide (drag a boundary to 0, marker drag / dblclick restore, single-column dblclick autofit), a deliberately low LIVE `maxOperationCells` staging every rejection class (limit, row fan-out ÷10, autofit ÷4, bounds via the multi-cell corner recipe), live `enableResizeUI` / `enableFillHandle` opt-outs, an empty-grid staging toggle (rowCount 0 adapter swap), a data-reset button, and the zoom card (v0.3) — the controlled-mode reference wiring (`zoom` + `onZoomChange`, 50–200% select plus every zoom gesture) and the fixture for the zoom E2E gates |
766
897
  | `/a11y` | Custom Japanese `formatCellAnnouncement` / `formatSelectionAnnouncement` mirrored into visible readouts, overridden accessible names (Escape advisement kept), the WCAG 2.1.2 escape route |
898
+ | `/readonly` | The v0.10 read-only cells (`tests/e2e/readonly.spec.ts`): a 200 × 10 sheet whose adapter declares `isCellReadOnly` (one locked COLUMN plus scattered locked cells for mixed selections) alongside note / comment / link / validation reads — the whole-operation refusals live (edit entries, Delete, paste, cut, fill incl. the diagonal legs, annotation chords on empty cells), the content-aware viewers (the locked column carries a comment thread, a note and a hyperlink; their chords open viewer-mode popovers with the commit affordance disabled), a host-side tint on `[data-aqsp-readonly]` (the package ships no CSS), a refusal toast worded per `ReadOnlyRejection` (the three `editSession` fates told apart, the two-producer cut wording), and an `onPasteResolved` two-line log that sits beside the refusal toast so a read-only-refused paste shows BOTH callbacks |
767
899
  | `/utils` | The exported pure functions live: TSV round-trip, paste-expansion rules, column-name converter (incl. its fail-fast `RangeError`), range algebra, and a clipboard inspector card (discloses the raw text/html payload, textContent-rendered) |
768
900
 
769
901
  Appending `?css=standalone` to any route swaps the source CSS pair for the built single-file
@@ -796,11 +928,26 @@ seam never meets: ≈ 18 ms cold median on the B5 host, 3.7 / 13.1 ms on a 12-co
796
928
  cells against a 10,000-item list paint their invalid marks with zero formula evaluations and one text read per cell, < 3 ms solo
797
929
  (≈ 1.4 ms measured; the in-suite gate is 3 ms × 5 slack, again in an `it` of its own); `getRangeExtrema` is called exactly once per
798
930
  value-less colour-scale range per render version (0 on a selection change); band repaints write only `transform` per scroll frame
799
- (Chromium: per-repaint p90 ≤ 4 ms with 16 ranges and both axes frozen — 1.30 ms measured solo over 320 band nodes); the
931
+ (Chromium: per-repaint p90 ≤ 4 ms with 16 ranges and both axes frozen — certified since v0.9 on the shipped nine-quadrant
932
+ `/styles` ambient (leading 1×1 plus trailing 1×1): the step-5c certification fired the pre-declared ADR-22 derate (trigger:
933
+ vertical p90 > 3.2 ms or horizontal per-event p90 > 2.0 ms; the horizontal leg exceeded 2.0 ms at the pre-derate 720 nodes —
934
+ the retired v0.8 four-quadrant registration was 1.30 ms solo), so `bandRangeCapFor` caps bands at 10 ranges beyond four
935
+ quadrants and the current gate column = 450 band nodes (10 × 5 × 9; at ≤ 4 quadrants the flat cap 16 is unchanged);
936
+ `styles.spec.ts` also adds the horizontal per-event wheel-bracket p90 ≤ 2 ms/event plus two zero-write invariants
937
+ (freeze-divider style writes = 0, fill non-transform writes = 0); since the 2026-09-16 review loop the plan §5.9 step-3
938
+ first-stage retreat is ADOPTED on top of the retained derate: `transform` itself is same-value cached on band segments and
939
+ fill slots, so a pure-horizontal wheel window writes only the moving x-kind-s third of the band clones (5,400 writes per
940
+ 60-event window, deterministic — mechanically pinned by the window bound "band transform writes ≤ events × armed bands / 2");
941
+ the current certification record — horizontal per-event bracket p90 1.00 / 1.30 / 1.20 ms and vertical in-callback rAF p90
942
+ 1.30 / 2.50 / 1.20 ms over three solo runs at 450 band nodes under session load 5.7-11.5 / 8 cores (the pre-retreat 1.80 ms
943
+ quietest-window figure had zero effective headroom under load), distribution {"1": 60}, host — is registered in spec 08 §9
944
+ and mirrored in spec 00 §6); the
800
945
  Chromium gates on the 300 × 26 `/styles` demo (7,800 cells — a 100,000-cell own range is dimensionally unreachable there, so
801
946
  the "100k" rows are certified whole-sheet, with the foreign table generated in-page) add an own copy of the whole sheet < 150 ms
802
- (30.4 ms measured solo, `html.length ≤ 160 × cells`), an own `Ctrl+V` of it < 120 ms (27.1 ms), a menu-path paste < 350 ms
803
- (54.7 ms), a foreign 999 × 100 = 99,900-cell Sheets table < 1.5 s (1,133.9 ms), the 25,000-distinct-string autofit < 300 ms
947
+ (26.5 ms measured solo on the derated nine-quadrant ambient — 2026-09-16 step-5e registry pass, minimum of two solo runs
948
+ on the shared 8-core certification host; the retired v0.8 four-quadrant figures were 30.4 / 27.1 / 54.7 / 1,133.9 ms;
949
+ `html.length ≤ 160 × cells`), an own `Ctrl+V` of it < 120 ms (29.2 ms), a menu-path paste < 350 ms
950
+ (61.2 ms), a foreign 999 × 100 = 99,900-cell Sheets table < 1.5 s (1,001.9 ms), the 25,000-distinct-string autofit < 300 ms
804
951
  (`tests/e2e/v2b.spec.ts`), and a `ctx.font` write-count gate on the same demo (one assignment per distinct painted font,
805
952
  conditional-format italics included). The solo inventory above and the operation-limits table's row-axis ceilings are
806
953
  themselves pinned by `src/hxCensus.spec.ts` against the spec files that read the variable and against `ROW_FANOUT_DIVISOR`.