@aiquants/spreadsheet 0.15.0 → 0.16.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,81 @@
2
2
 
3
3
  All notable changes to `@aiquants/spreadsheet` are documented here.
4
4
 
5
+ ## 0.16.0 (unreleased)
6
+
7
+ TSV の往復を文字列のどの矩形行列でも厳密にし、拡大率の操作を等比の段に揃え、`@aiquants/spreadsheet/view` にホストが最初の画面を
8
+ グリッドと同じ数で描くための既定値を加える。
9
+
10
+ - **破壊的変更 — TSV の符号化 (§6.5.1)**: `serializeTsv` は、タブ・CR・LF を含む値に加えて `"` で始まる値も引用符で囲む (中の
11
+ `"` は二重化)。`parseTsv` / `measureTsv` (同じ 1 つの走査) が引用の読みを採るのは、引用が正しく閉じ (閉じの引用符の直後が
12
+ タブ・改行・終端)、かつ中身がタブ・CR・LF・`"` のどれかを含むときだけ。これで `parseTsv(serializeTsv(m))` は、少なくとも 1 × 1
13
+ の文字列のどの矩形行列 `m` でも `m` に等しい — 直したのはパッケージ自身の往復で、同じ記号 `"`・`"`・`Blue` の行が
14
+ 1 つのセル `\t` と `Blue` に潰れ、A1 で開いた引用が A2 で閉じることがあった。Excel が書く素の `"<TAB>"<TAB>Blue` は本質的に
15
+ あいまいなので、従来どおり 2 つのセルに読む。Excel の素の出力は従来どおりに読む (引用だけで包まれた `"hi"` は文字どおり)。
16
+ 読みが変わる素の値は、二重化した引用符を持つ引用のフィールドとそっくりな値だけ (文字どおりの `"a""b"` は `a"b` と読む)。
17
+ - **書き手は 2 つ**: クリップボードの文字は Excel の形、ファイルと保存する本文は厳密に往復する `serializeTsv`。新しい公開の
18
+ `serializeClipboardTsv` (barrel と `/tsv`) がクリップボードの書き手で、グリッドのコピーの `text/plain` も **Excel の形**
19
+ (タブ・CR・LF を含む値だけを囲み、中の `"` を二重化) で書くので、平文の貼り先には `"Important" note` がそのまま届く。この形は
20
+ `"` で始まるセルの往復が厳密でないため、グリッド自身の貼り付けは JSON の payload (`application/x-aqsp-clip`) を先に読み、
21
+ `text/plain` はほかの出どころのときだけ読む。
22
+ - **新機能 — `iterateTsvRows`**: `parseTsv` とちょうど同じ文法で行を 1 つずつ、幅を埋めずに出すジェネレータを `/tsv` と
23
+ barrel から公開する。途中で止めた受け手は取った行より先を実体化しないので、大きなファイルをセルの予算の下で読める。
24
+ `parseTsv` はこの行を埋めたもの、`measureTsv` も同じ 1 つの文法の走査を数える (文法の定義は 1 つ)。
25
+ - **新機能 — `measureTsv` の公開**: `parseTsv` が組む行 × 列を、組まずに返す寸法の走査を `@aiquants/spreadsheet/tsv` と barrel
26
+ から公開する。
27
+ - **破壊的変更 — 拡大率の段**: `stepSpreadsheetZoom(value, direction)` は 0.1 刻みでなく、新しい `SPREADSHEET_ZOOM_STEPS`
28
+ (−8 から 8 までの `2^(k/4)` を 100 分の 1 に丸めた 17 段: 0.25・0.3・0.35・0.42・0.5・0.59・0.71・0.84・1・1.19・1.41・
29
+ 1.68・2・2.38・2.83・3.36・4) の、進む向きの次の段を返す。段の外の値 (ホストの 1.25) からも求め、結果は
30
+ 入力を先に `clampSpreadsheetZoom` と同じく範囲へ切り、そこから一歩進む (9 からの縮小は 3.36、0.01 からの拡大は 0.3、端では
31
+ 端に留まる)。グリッドの操作 (Alt+ホイールと Ctrl+Alt+= / -) も同じ段を進む。
32
+ - **新機能 — `@aiquants/spreadsheet/view` の既定値**: 既定の列幅 `SPREADSHEET_DEFAULT_COLUMN_WIDTH_PX` (100)、
33
+ `maxOperationCells` の既定 `SPREADSHEET_MAX_OPERATION_CELLS` (100,000)、列名の `columnIndexToName` (barrel と同じ関数) を
34
+ 公開する。グリッドもここから読む (定義は 1 つ)。
35
+ - **修正 — グリッドの構造 (WCAG 1.3.1 / 4.1.2)**: `role="grid"` は本体の包み (`[data-aqsp-body]`) が持ち、先頭の凍結・
36
+ スクロール・末尾の凍結のペインを全て含む (スクロールのペインの中身は `role="rowgroup"`)。セルが `aria-selected` を持たないので
37
+ `aria-multiselectable` は付けない (選択は live region が読み上げる)。凍結した先頭の行がグリッドの外の
38
+ 孤立した `row` になり、`aria-rowcount` がグリッドの外の行まで数えていた欠陥を直す。スクロールバーはポインタ専用
39
+ (`aria-hidden`・Tab の止まり先なし) に宣言し、グリッドの中に許されない子 (`scrollbar`・`button`) を作らない — キーボードの
40
+ スクロールはグリッド自身が持つ。
41
+ - **新機能 — `headerRows` / `labelledBy` / `readOnly`**: `headerRows` (既定 0、`frozenRows` 以下) は先頭の行のセルを
42
+ `role="columnheader"` と `data-aqsp-header-row` にする。`labelledBy` はグリッドとルートの `aria-labelledby` になり、組み込みの
43
+ 名前 (`labels.rootAriaLabel`) の代わりになる (省略するとルートとグリッドの両方が組み込みの名前を持つ)。`readOnly` はグリッドと隠しの入力に `aria-readonly="true"` を付け、アダプタを全セルが読み取り専用のビュー
44
+ (`readOnlyAdapterView`) に包む — 書き込みは全て `onReadOnlyRejected` で拒まれ、セルの読み上げの `readOnly` は true。
45
+ 切替はシート切替ではない (選択を保つ)。不正な `headerRows` / `labelledBy` は描画中の `RangeError`。
46
+ - **新機能 — 寸法のキーボード操作 (WCAG 2.1.1 / 2.1.3)**: 寸法は帯の 6 px の境界のドラッグでしか変えられなかった。
47
+ 再束縛できる 5 つのコマンド `increaseColumnWidth` / `decreaseColumnWidth` (既定 Alt+Shift+→ / ←)・`increaseRowHeight` /
48
+ `decreaseRowHeight` (Alt+Shift+↓ / ↑)・`autoFitColumnWidth` (Alt+Shift+=) を加える。1 段は × / ÷ 2^(1/4) (整数 px、
49
+ 少なくとも 1 px 動き 1 px を下回らない)、全スパン選択では帯のドラッグと同じく選んだ全てのトラックへ当てる。`readOnly` でも
50
+ 使え、`enableResizeUI={false}` では使えない。新しい寸法は `formatResizeAnnouncement` (新しい prop、省略で無音) で読み上げ、
51
+ 隠しの入力は実効のチョードを `aria-keyshortcuts` で示す。既定で束縛されるコマンドは 39 (41 チョード)。
52
+ - **新機能 — 色はすべて CSS 変数から**: グリッドが塗る全ての色 (セルの面・文字・格子の線・見出しの帯と文字・選択した見出し・
53
+ 選択・リンク・フォーカス・分割線・指標・ポップオーバー) を、文書にある公開の変数 `--aqsp-*` から読む。明るい既定は `.aqsp-root`
54
+ の別名 `--_aqsp-*` の中で 1 度だけ書き、ホストは公開の変数を祖先 (`:root`・ダークのクラス) にもルートにも置ける。スタイル
55
+ シートは公開の名前を 1 つも宣言しない — 帯の寸法 (`--aqsp-row-header-width` / `--aqsp-col-header-height`) と強調帯
56
+ (`--aqsp-band-*`) も別名から読み、強調帯の別名は `highlightColors` prop が公開の名前をインラインで書くオーバーレイの要素で解く
57
+ (prop がホストの値に勝つ)。一覧と既定値は README の「Theming」節。
58
+ - **修正 — 意味を持つ印のコントラスト 3:1 (WCAG 1.4.11)**: コメントの三角 `#f97316` → `#eb6406` (2.80 → 3.3)、
59
+ 凍結の分割線 `#9ca3af` → `#858e9d` (2.54 → 3.31)、参照とフィルの予告の緑 `#16a34a` → `#16a24a` (見出しの行の面で
60
+ 2.99 → 3.03)、再表示の印は不透明に (見出しの帯で 2.86 → 4.7)、ポップオーバーの入力欄の境界は新しい
61
+ `--aqsp-input-border` (`#8b95a4`、3.03)。格子の線・帯の縁・ポップオーバーの枠・ノートの tip の枠は飾り (tip は面と影で
62
+ 見分ける)。見出しの行 (`headerRows`) の面・文字・太さ (既定 700) も `--aqsp-header-row-*` で塗る。
63
+ - **修正 — 文字のコントラスト 7:1 (WCAG 1.4.6)**: 7:1 未満だった対を明度だけで直す — 見出しの文字 `#4b5563` → `#4a5361` (6.87 →
64
+ 7.06)、選択した見出しの文字 `#1d4ed8` → `#1841b4` (5.49 → 7.01)、リンク `#1d4ed8` → `#163ba5` (6.70 → 9.53、選択の塗りの
65
+ 下でも 7.05 以上)、控えめな文字 `#6b7280` → `#4e535e` (4.83 → 7.71)、拒否の文字 `#b91c1c` → `#a41919` (6.47 → 7.69)、主ボタンの面
66
+ `#2563eb` → `#134dce` (白の文字 5.17 → 7.05。ホバー `#173faf`)。全ての対を `designTokens.spec.ts` が CSS の文字から計算して
67
+ 固定する。
68
+ - **変更 — 文字の大きさは rem の 1 つの基準から (U13)**: 全ての文字の大きさを `--aqsp-font-size` (既定 `0.8125rem` = 13px) から
69
+ 比で導く。セルの中のエディタは粗いポインタで `max(1rem, 16px)` (iOS がページを拡大しない)。
70
+ - **寸法の操作の的 (WCAG 2.5.5 / 2.5.7)**: 帯の境界は 6px のストリップ (ドラッグ・ダブルクリックで autofit) のまま。境界の
71
+ 44px の的は隣の見出しのラベルの押下を奪う (24px の行では次の行のラベルを丸ごと覆う) ので置かない。同等の操作は寸法の
72
+ キーボードのコマンドと `handle.executeCommand` で、AAA を要るホストはそれを動かす 44px の部品を置く (README の
73
+ 「Pointer targets」節。フィルハンドルと再表示の印も同じ)。
74
+ - **DOM 属性**: `data-aqsp-header-row` の 1 種を追加 — リテラル census は 81 種 (dataset 直書きの同値抑止署名キー 4 種を
75
+ 加えた総数は 85)。
76
+ - **公開面**: 公開面は `index.ts` の値 43 + 型 98 = 141 export (`measureTsv`・`serializeClipboardTsv`・`iterateTsvRows` を追加)。
77
+ - **検証 — 走行台帳 (census の錨は本未リリース節)**: 単体 / 統合 **97 ファイル 4,202 テスト** 全緑 —
78
+ 0.15.0 出荷時の 94 ファイル 4,097 に v0.16 進行分 +105 (新規 `keyboardResize` 15・`gridSemantics` 14・`designTokens` 12、寸法の 5 コマンドで `shortcuts` +30 / `commands` +10 / `useCommands` +5、`tsv` の往復の性質・クリップボードの書き手・`iterateTsvRows` ほか +13、`view` の段と既定値 +5、0.15.0 節の出荷時凍結ピンで `hxCensus` +1)。`tsc --noEmit` / biome 0。
79
+
5
80
  ## 0.15.0 (2026-10-26)
6
81
 
7
82
  グリッドがコピーと貼り付けに使う TSV の符号化を、グリッドを読み込まない副パス `@aiquants/spreadsheet/tsv` として公開する。
package/README.md CHANGED
@@ -36,7 +36,7 @@ deep-reach proof route.
36
36
  - 📎 **Excel / Sheets clipboard interop with a lossless own payload** (ADR-14): copy writes `text/plain` (raw TSV), `text/html` (an interop table Excel and Sheets read — typed `x:num` / `mso-number-format` / `data-sheets-*` attributes) carrying the package's lossless JSON in a `data-aqsp-clip`
37
37
  attribute, plus a custom MIME fast marker on the paste event; paste detects ONE source (own → Sheets → Excel → generic HTML → TSV), estimates before materialising, and Paste Special (values / formats / formulas / no borders / widths / validation / conditional formats / comments and notes, Excel
38
38
  arithmetic operations, skip blanks, transpose) runs on both the keyboard (`Ctrl+Shift+V`, `Ctrl+Alt+V`) and the menu path (`handle.paste(options)` over `navigator.clipboard.read`); cut-move matches by clip id on both channels, including cross-sheet moves
39
- - ⌨️ **Rebindable Sheets-style commands** (ADR-17): 70 `SpreadsheetCommand` literals (bold / italic / underline / strikethrough / alignment / number formats / borders / links / notes / comments / fill down / right / selection / date / time / hide / unhide / paste special, plus unbound extension
39
+ - ⌨️ **Rebindable Sheets-style commands** (ADR-17): 75 `SpreadsheetCommand` literals (bold / italic / underline / strikethrough / alignment / number formats / borders / links / notes / comments / fill down / right / selection / date / time / hide / unhide / paste special, plus unbound extension
40
40
  commands for colours, self-calc and highlight toggles) driven by ONE built-in table (`BUILTIN_ROWS`), a canonical chord grammar (`Ctrl+Alt+Shift+KEY`, letters by the produced character or the `code` letter, everything else by `event.code`), prop-time validation with `RangeError`s, and an
41
41
  imperative `executeCommand` / `applyFormat` / `setBorders` / `setNote` / `addComment` / `setHyperlink` / `setValidation` handle group
42
42
  - 💬 **Popover layer and highlight bands** (ADR-15): note editor + hover note tip, comment thread, hyperlink editor, validation list picker and validation message on two resident anchors positioned by the overlay (zero DOM for a v0.7 adapter with default props, `renderPopover` to replace the defaults, a `labels` bag folding the former `sinkAriaLabel` / `rootAriaLabel` props — see "Localization"), and Excel-style active row / column / intersection highlight bands (transform-only per scroll frame, `--aqsp-band-*` colours, controlled / uncontrolled `highlight` state)
@@ -446,17 +446,34 @@ away while the band is visible; with every trailing track hidden the scroll-side
446
446
 
447
447
  ## Accessibility model and trade-offs
448
448
 
449
+ **Structure.** One `role="grid"` element (the body wrapper) owns every row: the frozen leading pane, the scrolling pane
450
+ (its content element is the body `rowgroup`) and the frozen trailing pane all sit inside it, so `aria-rowcount` /
451
+ `aria-colcount` count every row and column and each `role="row"` has the grid as an ancestor. The grid does not declare
452
+ `aria-multiselectable`: no cell carries `aria-selected`, and the live region announces the selection instead
453
+ (`formatSelectionAnnouncement`). The scroll bars are pointer-only (`aria-hidden`, no Tab stop): the grid owns keyboard
454
+ scrolling, so they duplicate it, as native scroll bars do, and they never become children of the grid. The column-letter
455
+ and row-number bands are visual chrome outside the grid (`aria-hidden`). Three props complete the structure:
456
+
457
+ - `headerRows` (default 0, at most `frozenRows`): the leading rows whose cells are column headers — `role="columnheader"`,
458
+ painted by the stylesheet (`--aqsp-header-row-*`), so a screen reader names the column of every cell below them.
459
+ - `labelledBy`: the id of the host element that names the grid (a caption or heading) — `aria-labelledby` on the grid
460
+ and on the focusable root (the Escape landing point), instead of the built-in `labels.rootAriaLabel`. Without it, the
461
+ root and the grid both carry `labels.rootAriaLabel` as `aria-label`.
462
+ - `readOnly`: the whole grid is read-only — `aria-readonly="true"` on the grid and on the hidden input, every cell is
463
+ read-only whatever the adapter declares (every write goes to `onReadOnlyRejected` / `formatReadOnlyAnnouncement`, and the
464
+ cell announcement's `readOnly` is `true`), while selection, copy, scrolling, zoom and resizing stay available.
465
+
449
466
  The grid renders cells as a **non-focusable virtualized surface** with a hidden 1px textarea ("sink") owning focus and
450
467
  keyboard input — this is what makes IME composition, clipboard events, and 16M-cell virtualization coexist. The trade-offs:
451
468
 
452
- - Screen readers do not walk cells as a native table/grid. Instead, cell and selection changes can be **announced** through a
469
+ - Keyboard focus stays in the hidden input, so a screen reader in focus mode does not move through the cells itself (browse mode reads the `role="grid"` structure above). Instead, cell and selection changes can be **announced** through a
453
470
  debounced `aria-live="polite"` region — **strictly opt-in**: the package ships no built-in announcement strings (the `locale` catalogs cover UI chrome only), and the live region is
454
471
  rendered only when you provide `formatCellAnnouncement` and/or `formatSelectionAnnouncement`. Omit both and the grid is
455
472
  silent to assistive technology beyond its labels. The cell-announcement context carries both `text` (the raw canonical
456
473
  `getCellText`) and `displayText` (the formatted string a sighted user sees — identical to `text` on unformatted cells),
457
474
  so a formatter can announce `"87.0%"` rather than `"0.87"`.
458
475
  - `labels.sinkAriaLabel` labels the input surface (its catalog value explains the Escape hand-off — in English by default, in
459
- Japanese under `locale="ja"`); `labels.rootAriaLabel` labels the grid container (`"Spreadsheet"` / `"スプレッドシート"`). Both
476
+ Japanese under `locale="ja"`); `labels.rootAriaLabel` names the grid's root (the Escape landing point) and the grid, unless `labelledBy` names both (`"Spreadsheet"` / `"スプレッドシート"`). Both
460
477
  live in the `labels` prop (v0.8 — the former `sinkAriaLabel` / `rootAriaLabel` props were folded into it; together with the 19
461
478
  popover strings they are the 21 package-owned keys, and the engine keys (every `VIRTUAL_SCROLL_LABEL_KEYS` entry) name the
462
479
  grid scrollbars' chrome, such as the arrows and edge pills, and the empty state). The `locale` prop picks the catalog (`SPREADSHEET_LABEL_CATALOGS[locale]`), and an omitted or `undefined` key
@@ -470,8 +487,9 @@ keyboard input — this is what makes IME composition, clipboard events, and 16M
470
487
  - Keyboard reachability is complete for the keymap below: every listed operation works without a pointer, and `Escape` from
471
488
  the grid root returns focus to the page tab order (WCAG 2.1.2 — no keyboard trap). The grid never takes focus on mount
472
489
  (the hidden input is armed focus-neutrally — WCAG 3.2.1 / 2.4.3): keyboard entry is the page tab order (Tab / Shift+Tab)
473
- or a click, and re-entry always lands on the hidden input. The pointer-only affordances have non-pointer equivalents at
474
- other layers: resizing via the imperative `resizeRow` / `resizeColumn`, filling via your own `applyEdits`-based UI.
490
+ or a click, and re-entry always lands on the hidden input. Resizing has keyboard commands of its own (Alt+Shift+Arrow,
491
+ Alt+Shift+= — see "Resizing from the keyboard"), and filling has `fillDown` / `fillRight` / `fillSelection` (Ctrl+D / Ctrl+R /
492
+ Ctrl+Enter).
475
493
  - The hidden input's `inputmode` is a **virtual-keyboard invitation**, not a mirror of the editing session — and not an idle
476
494
  / editing predicate. It starts as `inputmode="none"`, and for keyboard and IME users it stays `"none"` forever (F2, a
477
495
  printable key, or a composition opens the editor without touching the attribute): a touch / pen tap does not summon a
@@ -506,6 +524,135 @@ keyboard input — this is what makes IME composition, clipboard events, and 16M
506
524
  WebKit re-evaluates `inputmode` on the focused element and can show it (iPad + trackpad gets the keyboard). Hardware
507
525
  keyboards and desktop IME are unaffected either way.
508
526
 
527
+ ## Theming: CSS variables, contrast and type
528
+
529
+ Every colour the grid paints is read from a public `--aqsp-*` variable. The package never declares the public names: the
530
+ stylesheet reads each one exactly once, in an alias on `.aqsp-root` that also holds its light default
531
+ (`--_aqsp-surface: var(--aqsp-surface, #ffffff)`), and every rule paints the alias. A host therefore binds a dark scheme by
532
+ setting the public variables anywhere above or on the grid — `:root`, a `.dark` ancestor, or the grid's own `className` —
533
+ and every surface, line, label and popover follows; no colour is written anywhere else in the stylesheet (forced-colors
534
+ mode uses system colours only). `src/styles/designTokens.spec.ts` pins all of this from the CSS text.
535
+
536
+ ```css
537
+ .dark {
538
+ --aqsp-surface: #111827;
539
+ --aqsp-text: #f3f4f6;
540
+ --aqsp-grid-line: #374151;
541
+ --aqsp-header-surface: #1f2937;
542
+ --aqsp-header-text: #e5e7eb;
543
+ /* … the rest of the table below … */
544
+ }
545
+ ```
546
+
547
+ | Variable | Light default | Paints |
548
+ | :--- | :--- | :--- |
549
+ | `--aqsp-row-header-width` | `64px` | Width of the row-number band (= `SPREADSHEET_ROW_HEADER_WIDTH_PX`; a host that changes it sizes its box with the new value) |
550
+ | `--aqsp-col-header-height` | `24px` | Height of the column-letter band (= `SPREADSHEET_COLUMN_HEADER_HEIGHT_PX`; same caveat) |
551
+ | `--aqsp-font-size` | `0.8125rem` | The one type-size token (13 px at the default 16 px root); every other size is a ratio of it (12/13, 11/13, 16/13), and the in-cell editor uses it except under `(pointer: coarse)`, where it is `max(1rem, 16px)` so iOS does not zoom |
552
+ | `--aqsp-surface` | `#ffffff` | Cell surface, root, editor, popover and input backgrounds; the light half of the clip marquee |
553
+ | `--aqsp-text` | `#1f2937` | Cell text, editor text and caret, popover and button text |
554
+ | `--aqsp-grid-line` | `#e5e7eb` | Cell grid lines and the header cells' separators (decorative) |
555
+ | `--aqsp-border` | `#d1d5db` | Header band edges, the corner, popover frames and buttons (decorative) |
556
+ | `--aqsp-input-border` | `#8b95a4` | Popover text inputs — the input's only boundary cue, so 3:1 (WCAG 1.4.11) |
557
+ | `--aqsp-accent` | `#2563eb` | Active-cell frame, drag rectangle, focus rings, resize guide, fill handle, unhide markers, marquee |
558
+ | `--aqsp-header-surface` | `#f3f4f6` | Column-letter and row-number bands and the corner |
559
+ | `--aqsp-header-text` | `#4a5361` | Column letters and row numbers |
560
+ | `--aqsp-header-selected-surface` | `#dbeafe` | A header inside the selection |
561
+ | `--aqsp-header-selected-text` | `#1841b4` | The label of a header inside the selection |
562
+ | `--aqsp-header-row-surface` | `#f3f4f6` | Cells of the `headerRows` rows |
563
+ | `--aqsp-header-row-text` | `#1f2937` | Text of the `headerRows` cells |
564
+ | `--aqsp-header-row-font-weight` | `700` | Weight of the `headerRows` cells (not a colour; autofit measures with it) |
565
+ | `--aqsp-link` | `#163ba5` | Hyperlink cells (underlined) |
566
+ | `--aqsp-note-marker` | `#4b5563` | The note triangle |
567
+ | `--aqsp-comment-marker` | `#eb6406` | The comment triangle |
568
+ | `--aqsp-danger` | `#dc2626` | Invalid-cell triangle, subtract drag border, rejected accent, invalid input borders |
569
+ | `--aqsp-danger-fill` | `rgb(220 38 38 / 0.08)` | Subtract drag fill |
570
+ | `--aqsp-danger-text` | `#a41919` | Rejected hint and rejected message title |
571
+ | `--aqsp-danger-surface` | `#fef2f2` | Rejected message background |
572
+ | `--aqsp-selection-fill` | `rgb(59 130 246 / 0.18)` | Selection fill (drawn with `mix-blend-mode: multiply`) |
573
+ | `--aqsp-selection-border` | `rgb(37 99 235 / 0.85)` | Selection outline |
574
+ | `--aqsp-drag-fill` | `rgb(59 130 246 / 0.1)` | Drag rectangle fill |
575
+ | `--aqsp-reference` | `#16a24a` | Formula-reference rectangle and the pointing caret |
576
+ | `--aqsp-reference-fill` | `rgb(22 163 74 / 0.08)` | Formula-reference rectangle fill |
577
+ | `--aqsp-fill-preview` | `#16a24a` | Fill-handle preview border |
578
+ | `--aqsp-divider-color` | `#858e9d` | Freeze dividers (with `--aqsp-divider-width`, default `2px`) |
579
+ | `--aqsp-popover-shadow` | `0 1px 2px rgb(0 0 0 / 0.08), 0 8px 24px rgb(0 0 0 / 0.14)` | Popover shadow (a whole `box-shadow` value) |
580
+ | `--aqsp-note-tip-surface` | `#fffbeb` | Note tip background |
581
+ | `--aqsp-note-tip-border` | `#f59e0b` | Note tip frame (decorative: the tip is identified by its surface and shadow) |
582
+ | `--aqsp-note-tip-shadow` | `0 2px 8px rgb(0 0 0 / 0.12)` | Note tip shadow (a whole `box-shadow` value) |
583
+ | `--aqsp-title-text` | `#374151` | Popover titles |
584
+ | `--aqsp-muted-text` | `#4e535e` | Popover hints, counts, labels, times, the empty list and the close glyph |
585
+ | `--aqsp-strong-text` | `#111827` | Comment authors |
586
+ | `--aqsp-separator` | `#f3f4f6` | Lines between comments |
587
+ | `--aqsp-button-surface` | `#f9fafb` | Popover buttons |
588
+ | `--aqsp-hover-surface` | `#f3f4f6` | Hovered buttons, options and the close glyph |
589
+ | `--aqsp-hover-border` | `#9ca3af` | Hovered button border |
590
+ | `--aqsp-primary-surface` | `#134dce` | Primary buttons (Save, Post) |
591
+ | `--aqsp-primary-hover-surface` | `#173faf` | Hovered primary button |
592
+ | `--aqsp-primary-text` | `#ffffff` | Primary button text |
593
+ | `--aqsp-option-selected-surface` | `#dbeafe` | Active list option |
594
+ | `--aqsp-option-selected-text` | `#1e3a8a` | Active list option text |
595
+ | `--aqsp-band-row` | `#0084ff` | Row highlight band (the `highlightColors` prop writes it inline on the overlay, which wins over a host's value) |
596
+ | `--aqsp-band-column` | `#42ff4f` | Column highlight band (same writer) |
597
+ | `--aqsp-band-intersection` | `#e1ff00` | Intersection highlight band (same writer) |
598
+ | `--aqsp-band-opacity` | `0.2` | Opacity of the highlight bands (same writer) |
599
+
600
+ The highlight bands resolve their aliases on the overlay element, where the `highlightColors` prop writes the public
601
+ `--aqsp-band-*` names inline (so the prop wins over a host's value, and a host's value wins over the default);
602
+ `--aqsp-divider-width` (default `2px`) sets the freeze dividers' thickness. The stylesheet declares no public `--aqsp-*`
603
+ name itself.
604
+
605
+ **Non-text contrast (WCAG 1.4.11, 3:1).** The marks that carry meaning reach 3:1 against the default surface and the
606
+ header-row surface: accent 5.17 / 4.7, the selection outline 3.97 (over the surface),
607
+ the note marker 7.56 / 6.87, the comment marker `#eb6406` 3.3 / 3.0 (was `#f97316`, 2.80),
608
+ danger 4.83 / 4.39, the reference and fill-preview green `#16a24a` 3.33 / 3.03 (was `#16a34a`, 3.30 / 2.99),
609
+ the freeze divider `#858e9d` 3.31 / 3.0 (was `#9ca3af`, 2.54), the unhide markers at full opacity 4.7 on the
610
+ header band (they were 70 % opaque, 2.86), and the popover input border `#8b95a4` 3.03 (new; inputs used the decorative
611
+ `#d1d5db`, 1.47). Grid lines, band edges, popover frames and the note tip's frame are decorative (the tip is identified by its
612
+ surface and shadow).
613
+
614
+ **Contrast (WCAG 1.4.6, 7:1).** Every text / background pair of the light defaults is at least 7:1, measured with the WCAG 2
615
+ formula; the three pairs below 7:1 before v0.16 were re-solved by changing lightness only (the hue stays):
616
+
617
+ | Text on surface | Ratio |
618
+ | :--- | :--- |
619
+ | `--aqsp-header-text` `#4a5361` on `--aqsp-header-surface` `#f3f4f6` (was `#4b5563`, 6.87) | 7.06 |
620
+ | `--aqsp-header-selected-text` `#1841b4` on `--aqsp-header-selected-surface` `#dbeafe` (was `#1d4ed8`, 5.49) | 7.01 |
621
+ | `--aqsp-link` `#163ba5` on `--aqsp-surface` `#ffffff` (was `#1d4ed8`, 6.70) | 9.53 |
622
+ | `--aqsp-link` on `--aqsp-header-row-surface` `#f3f4f6` / under the selection fill on the surface / on the header row | 8.66 / 7.74 / 7.05 |
623
+ | `--aqsp-text` `#1f2937` on `--aqsp-surface` / `--aqsp-header-row-surface` / under the selection fill on each | 14.68 / 13.34 / 11.92 / 10.86 |
624
+ | `--aqsp-muted-text` `#4e535e` on `--aqsp-surface` / `--aqsp-danger-surface` / `--aqsp-note-tip-surface` (was `#6b7280`, 4.83) | 7.71 / 7.05 / 7.44 |
625
+ | `--aqsp-danger-text` `#a41919` on `--aqsp-surface` / `--aqsp-danger-surface` (was `#b91c1c`, 6.47) | 7.69 / 7.03 |
626
+ | `--aqsp-primary-text` `#ffffff` on `--aqsp-primary-surface` `#134dce` / `--aqsp-primary-hover-surface` `#173faf` (was `#2563eb`, 5.17) | 7.05 / 8.87 |
627
+ | `--aqsp-title-text` `#374151` / `--aqsp-strong-text` `#111827` on `--aqsp-surface` | 10.31 / 17.74 |
628
+ | `--aqsp-option-selected-text` `#1e3a8a` on `--aqsp-option-selected-surface` `#dbeafe` | 8.49 |
629
+ | `--aqsp-text` on the button / hover / note-tip / danger surfaces | 14.05 / 13.34 / 14.16 / 13.42 |
630
+
631
+ A host that binds its own colours keeps the 7:1 obligation for its values.
632
+
633
+ **Type.** Every type size derives from the one `--aqsp-font-size` token (`0.8125rem`, 13 px at the default root), so the
634
+ grid follows the user's default font size; the popover sizes are fixed ratios of it. The in-cell editor uses the same size
635
+ except under `(pointer: coarse)`, where it is `max(1rem, 16px)` — iOS zooms the page when an input below 16 CSS px takes
636
+ focus.
637
+
638
+ ## Pointer targets (WCAG 2.5.5 / 2.5.7)
639
+
640
+ The grid's own resize affordances are small on purpose, and the grid does not try to make them 44 × 44 CSS px itself:
641
+
642
+ - **Strips.** Every column and row boundary carries a 6 px strip in the header bands (drag to resize, double-click to
643
+ autofit), and a boundary next to hidden tracks carries a 3 px unhide marker. A 44 px hit area on a boundary would cover
644
+ the neighbouring header labels (with 24 px rows, the whole label of the next row), so pressing a row or column label
645
+ next to the active boundary would resize instead of select; the strips stay inside their 6 px.
646
+ - **Fill handle.** The fill handle (opt-in through `enableFillHandle`) is a 6 px square at the selection's corner; every
647
+ pixel around it is a cell.
648
+ - **Equivalents.** Every one of these functions is reachable without a small target and without dragging: the keyboard
649
+ commands (`increaseColumnWidth` / `decreaseColumnWidth` / `increaseRowHeight` / `decreaseRowHeight` /
650
+ `autoFitColumnWidth`, `unhideRows` / `unhideColumns`, `fillDown` / `fillRight` / `fillSelection`) and
651
+ `handle.executeCommand(command)`. Target Size (2.5.5) and Dragging Movements (2.5.7) are met by the host: a page that
652
+ needs them provides 44 × 44 controls that run those commands (a toolbar, for instance).
653
+ - The column-letter band is 24 px tall (`SPREADSHEET_COLUMN_HEADER_HEIGHT_PX`) and the row-number band 64 px wide; cells and
654
+ header labels are as large as the host's rows and columns, and the zoom ladder enlarges every target with the grid.
655
+
509
656
  ## Context-menu constraint (right-click)
510
657
 
511
658
  Right-clicking a cell moves the selection (Excel-style) and calls `onCellContextMenu` with the cell and the native event —
@@ -620,6 +767,9 @@ track, and paging moves by exact viewport-height pixel geometry, so page landing
620
767
  | Ctrl+Enter | Fill every selected cell with the active cell's content | Run the strict validation gate on every selected cell's own rule (the first strict rejection keeps the session open, exactly like Enter), then write the session text to every selected cell in one transaction and close the session | Same | `fillSelection` |
621
768
  | Ctrl+Semicolon / Ctrl+Shift+Semicolon | Write today's date (`YYYY-MM-DD`) / the current time (`HH:mm:ss`, local, `now` prop) into the **active cell only** | Insert at the caret | Same | `insertDate` / `insertTime` |
622
769
  | Ctrl+Alt+Digit9 / Ctrl+Shift+Digit9 / Ctrl+Shift+Digit0 | Hide the selected rows / unhide rows / unhide columns (through the resize gates) | Native | Native | `hideRows` / `unhideRows` / `unhideColumns` |
770
+ | Alt+Shift+ArrowRight / Alt+Shift+ArrowLeft | Widen / narrow the active column by one step (× / ÷ 2^(1/4), whole px, at least 1 px of change, never below 1 px — a step never hides a column); with a full-span column selection every selected column takes the new size, as a strip drag does. Allowed under `readOnly` (a view change), unavailable with `enableResizeUI={false}` | Native | Native | `increaseColumnWidth` / `decreaseColumnWidth` |
771
+ | Alt+Shift+ArrowDown / Alt+Shift+ArrowUp | Make the active row taller / shorter by one step (same rule and fan-out) | Native | Native | `increaseRowHeight` / `decreaseRowHeight` |
772
+ | Alt+Shift+Equal (`=`) | Fit the active column to its content (the strip double-click's autofit; no-op while `renderCell` is set) | Native | Native | `autoFitColumnWidth` |
623
773
  | *(unbound by default)* | Hide the selected columns — bind it yourself, e.g. `shortcuts={{ hideColumns: ["Ctrl+Alt+Shift+Digit9"] }}` | — | — | `hideColumns` |
624
774
  | *(unbound by default)* | General (no value format) / all four borders | — | — | `formatGeneral` / `bordersAll` |
625
775
  | *(unbound by default)* | Paste Special modes through the menu path (`handle.paste`) | — | — | `pasteFormats` / `pasteFormulas` / `pasteAdd` / `pasteSubtract` / `pasteMultiply` / `pasteDivide` |
@@ -639,9 +789,21 @@ untouched and `executeCommand` returns `false`. The editing columns consult only
639
789
  (`bold` / `italic` / `underline` / `strikethrough` / `alignLeft` / `alignCenter` / `alignRight` / `formatNumber` / `formatTime` /
640
790
  `formatDate` / `formatCurrency` / `formatPercent` / `formatScientific` / `fillSelection` / `insertDate` / `insertTime`); any other
641
791
  command chord pressed while editing is native. The exported `DEFAULT_SHORTCUTS` (`{ [command]: readonly string[] }`, `[]` =
642
- unbound: 34 commands bound over 36 chords, 36 unbound) and `SPREADSHEET_RESERVED_SHORTCUTS` (the 63 exact chords of the
792
+ unbound: 39 commands bound over 41 chords, 36 unbound) and `SPREADSHEET_RESERVED_SHORTCUTS` (the 63 exact chords of the
643
793
  non-rebindable built-in rows consulted while idle) are derived from the same `BUILTIN_ROWS` table.
644
794
 
795
+ **Resizing from the keyboard (WCAG 2.1.1 / 2.1.3).** The five resize commands make the header strips' work keyboard-operable:
796
+ Alt+Shift+Arrow resizes the active column (Left / Right) or row (Up / Down) by one step and Alt+Shift+= fits the active column
797
+ to its content, through the strips' own fan-out and gates (`onOperationRejected("resize", …)` on a refusal). The chords were
798
+ chosen because nothing else claims them: the grid's own table has no Alt+Shift+Arrow row (Arrow, Shift+Arrow, Ctrl+Arrow and
799
+ Ctrl+Shift+Arrow navigate; Alt+ArrowDown opens the list picker; Alt+Shift+digits are the border presets), browsers bind Alt+Arrow
800
+ (history) but not Alt+Shift+Arrow, the screen readers' table commands use Ctrl+Alt+Arrow (NVDA, JAWS) or the VoiceOver keys
801
+ (Ctrl+Option), and Ctrl+Alt+Arrow also rotates the screen on some Windows drivers. On macOS, Option+Shift+Arrow extends a text
802
+ selection by word, which is why the commands are idle-only: in an editing session the keys keep their text meaning. Each
803
+ resize is announced through `formatResizeAnnouncement` (opt-in; it receives the command, the axis, the track index and the new
804
+ size in CSS px), and the hidden input lists the effective chords in `aria-keyshortcuts` (the ARIA spelling: `Control` for Ctrl,
805
+ `=` for Equal).
806
+
645
807
  - **Grammar** (`parseChord`, canonical spelling only): `[Ctrl+][Alt+][Shift+]KEY` in that fixed order (Cmd counts as Ctrl).
646
808
  `KEY` is a bare Latin letter `A`–`Z`, a digit by code (`Digit0`–`Digit9`, `Numpad0`–`Numpad9`), or a `KeyboardEvent.code` name
647
809
  (`Semicolon`, `Backslash`, `IntlYen`, `IntlRo`, `Minus`, `Equal`, `F1`–`F12`, `ArrowDown`, `Enter`, `Escape`, `Space`, …). Letters
@@ -729,6 +891,7 @@ The grid's own UI chrome ships in English (`"en"`, the default) and Japanese (`"
729
891
  | `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). |
730
892
  | `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. |
731
893
  | `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. |
894
+ | `headerRows` | `number` | v0.16 — the leading rows whose cells are column headers (default 0): `role="columnheader"` and the stylesheet's header-row paint (`--aqsp-header-row-*`). An integer from 0 to `frozenRows`; anything else is a `RangeError` during render |
732
895
  | `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. |
733
896
  | `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. |
734
897
  | `renderCell` | `(ctx: RenderCellContext) => ReactNode` — `RenderCellContext = { row; col; text; displayText }` (exported) | Custom cell rendering. **Must be identity-stable** (`useCallback`) — an inline arrow defeats the cell memo and re-renders every visible cell each pass. **Per-cell opt-out** (v0.12 — ADR-32): return `undefined` and that one cell falls back to the package default rendering, value-format engine included (only `undefined` opts out — `null`, `false` and any other ReactNode are custom output, i.e. an empty custom cell). `ctx.text` is the raw canonical string; `ctx.displayText` is the value-format display — the exact string the default rendering, announcements, the HTML clipboard and autofit share, so a custom cell that renders it can never drift from what gets copied. Autofit stays a whole-grid no-op while `renderCell` is set. |
@@ -744,6 +907,7 @@ The grid's own UI chrome ships in English (`"en"`, the default) and Japanese (`"
744
907
  | `onUndoRequest` / `onRedoRequest` | `() => void` | `Ctrl+Z` / `Ctrl+Y` / `Ctrl+Shift+Z` relays. |
745
908
  | `formatCellAnnouncement` / `formatSelectionAnnouncement` | formatter | Opt-in screen-reader announcements (see above). |
746
909
  | `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. |
910
+ | `formatResizeAnnouncement` | `(ctx: { command: SpreadsheetCommand; axis: "row" \| "column"; index: number; size: number }) => string` | v0.16 — opt-in live-region text after a keyboard resize (`increaseColumnWidth` / `decreaseColumnWidth` / `increaseRowHeight` / `decreaseRowHeight` / `autoFitColumnWidth`): the active track's axis, index and new size in CSS px. Omit for silence (no built-in strings); shares the announcement debounce |
747
911
  | `labels` | `SpreadsheetLabelOverrides` (any subset of the `SpreadsheetLabels` keys) | v0.8 — replaces the `sinkAriaLabel` / `rootAriaLabel` props (folded in) and names every UI chrome string: the engine keys shared with `@aiquants/virtualscroll` (every entry of the installed engine's `VIRTUAL_SCROLL_LABEL_KEYS`, such as `scrollUp`, `scrollDown`, `scrollToTop`, `noItems` — they reach the grid scrollbars and the empty state) and the 21 package keys (`sinkAriaLabel`, `rootAriaLabel`, `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`, `popoverClose`). Omitted / `undefined` keys keep the value of the `locale` catalog (`SPREADSHEET_LABEL_CATALOGS[locale]`); an unknown key (including the removed `validationRejected` / `indicatorNote` / `indicatorComment` / `indicatorInvalid`) or a blank / non-string value throws a `RangeError` during render; `sinkAriaLabel` must mention `Escape` (`RangeError` during render otherwise). |
748
912
  | `locale` | `SpreadsheetLocale` (`"en" \| "ja"`) | v0.13 — the UI chrome language, default `"en"`: selects the label catalog, the built-in validation messages (`onValidationRejected`'s `message` and the message popover) and the grid scrollbars' arrow names / empty state. Any other value (`"fr"`, `"ja-JP"`, `"EN"`, `""`, `null`) throws a `RangeError` during render, even on an empty grid — there is no language negotiation. Unrelated to `formats.locale`. See "Localization". |
749
913
  | `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. |
@@ -762,6 +926,8 @@ The grid's own UI chrome ships in English (`"en"`, the default) and Japanese (`"
762
926
  | `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. |
763
927
  | `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. |
764
928
  | `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). |
929
+ | `readOnly` | `boolean` | v0.16 — the whole grid is read-only (default false): `aria-readonly="true"` on the grid and the hidden input; every cell is read-only whatever the adapter's `isCellReadOnly` says, so writes are refused through `onReadOnlyRejected` and the cell announcement's `readOnly` is true. Selection, copy, scrolling, zoom, resizing, hiding and highlighting stay available, and toggling it keeps the selection |
930
+ | `labelledBy` | `string` | v0.16 — the id of the element that names the grid: `aria-labelledby` on the `role="grid"` element and on the root, instead of the built-in `labels.rootAriaLabel` (which names both when it is omitted). A non-empty id without whitespace; anything else is a `RangeError` during render |
765
931
  | `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. |
766
932
  | `overscanRows` / `overscanCols` | `number` | v0.11 — per-axis overscan pass-through onto VirtualGrid’s `overscanRows` / `overscanCols` (undefined ⇒ the engine default 3). This slack is what absorbs the engine’s one-frame rendering-window lag during fast scrolling — raise it when fast flicks show blank bands. Overscan counts rows / columns (hidden 0-size tracks consume slots too), so the px slack per edge is overscan × row height / column width only where tracks are uniform and visible. The engine does not validate overscan (a negative value silently shrinks the rendering window), so THIS surface fail-fasts: a non-integer, negative or non-finite value throws a RangeError at render. Cost multiplies across the two axes, and the engine truncates rather than bounds: a window beyond its per-axis row budget or its total cell cap keeps the leading side and drops the trailing side — the row-budget case has no callback (the engine only logs a warning), the cell-cap case reports dropped columns through `onRenderTruncated`. Keep overscan to the slack you need (hundreds per side approach the budget on tall viewports). LIVE props. |
767
933
  | `background` | `ReactNode` | v0.11 — background layer rendered behind the scroll-band rows inside the engine content element (the VirtualGrid `background` pass-through). VIEWPORT-PINNED: the engine translates only its row wrapper, so this layer does not follow the scroll offset — periodic row ruling placed here slides against the real rows. Suited to a static “content still arriving” texture for the overscan-exhausted band, not to row-aligned gridlines. It spans the full width of the scroll rows (the frozen-column region included — frozen columns live inside the scroll rows) but not the frozen row bands. **Identity-stable** like `renderCell`: an inline element pierces the GridBody memo boundary on every host re-render — hoist it or `useMemo` it. LIVE prop. |
@@ -819,43 +985,62 @@ aspects such as `columnWidths` — sizes may still have been applied).
819
985
 
820
986
  ## View geometry and zoom rules (`@aiquants/spreadsheet/view`)
821
987
 
822
- `@aiquants/spreadsheet/view` publishes the grid's fixed geometry and its zoom rules without loading the grid (no React, no
823
- `@aiquants/virtualscroll`): a host can size a box for the grid before the grid itself is loaded (a lazily imported grid, a
824
- server-rendered placeholder) and drive the zoom from its own controls with exactly the numbers the grid uses. The grid reads the
825
- same definitions.
988
+ `@aiquants/spreadsheet/view` publishes the grid's fixed geometry, its defaults and its zoom ladder without loading the grid
989
+ (no React, no `@aiquants/virtualscroll`; its only dependency is the column-name codec, which imports nothing): a host can size
990
+ a box for the grid before the grid itself is loaded (a lazily imported grid, a server-rendered placeholder), paint a static
991
+ stand-in of the first screen, and drive the zoom from its own controls with exactly the numbers the grid uses. The grid reads
992
+ the same definitions.
826
993
 
827
994
  | Export | Value / contract |
828
995
  | :--- | :--- |
829
996
  | `SPREADSHEET_COLUMN_HEADER_HEIGHT_PX` | `24` — the column-header band (`--aqsp-col-header-height`) |
830
997
  | `SPREADSHEET_ROW_HEADER_WIDTH_PX` | `64` — the row-header band (`--aqsp-row-header-width`) |
831
998
  | `SPREADSHEET_SCROLLBAR_SIZE_PX` | `12` — each scroll bar, reserved on both axes whether or not the grid scrolls |
999
+ | `SPREADSHEET_DEFAULT_COLUMN_WIDTH_PX` | `100` — the default of `defaultColumnWidth` |
1000
+ | `SPREADSHEET_MAX_OPERATION_CELLS` | `100_000` — the default of `maxOperationCells` |
1001
+ | `columnIndexToName(index)` | The column letters (`0` → `A`, `26` → `AA`); the same function as the barrel's |
832
1002
  | `SPREADSHEET_ZOOM_MIN` / `SPREADSHEET_ZOOM_MAX` | `0.25` / `4` — the bounds of `zoom` |
1003
+ | `SPREADSHEET_ZOOM_STEPS` | The zoom ladder: `2^(k/4)` for every integer `k` from −8 to 8, rounded to hundredths — `0.25, 0.3, 0.35, 0.42, 0.5, 0.59, 0.71, 0.84, 1, 1.19, 1.41, 1.68, 2, 2.38, 2.83, 3.36, 4` (four steps double the zoom) |
833
1004
  | `clampSpreadsheetZoom(value)` | Clamps into the bounds without quantizing; a non-finite value throws a `RangeError` |
834
- | `stepSpreadsheetZoom(value, direction)` | The gestures' 0.1 detent step (`1` in, `-1` out), unclamped: from an off-grid value it lands on the nearest detent in the travel direction (0.25 → 0.3 / 0.2) |
1005
+ | `stepSpreadsheetZoom(value, direction)` | The next ladder step in the travel direction (`1` in, `-1` out), from any value, including one off the ladder (1.25 → 1.41 / 1.19). The input is clamped first (as `clampSpreadsheetZoom` does) and the step is taken from there: 9 zooms out to 3.36, 0.01 zooms in to 0.3, and a bound stays at the bound. A non-finite value throws a `RangeError`. The gestures (Alt+Wheel, Ctrl+Alt+= / -) step the same ladder |
835
1006
 
836
1007
  At zoom 1 with uniform rows, a box that shows `n` rows exactly is
837
1008
  `SPREADSHEET_COLUMN_HEADER_HEIGHT_PX + n × defaultRowHeight + SPREADSHEET_SCROLLBAR_SIZE_PX` px tall. Zoom buttons of a host that
838
1009
  controls `zoom` step the same way the gestures do:
839
1010
 
840
1011
  ```tsx
841
- import { clampSpreadsheetZoom, stepSpreadsheetZoom } from "@aiquants/spreadsheet/view"
1012
+ import { stepSpreadsheetZoom } from "@aiquants/spreadsheet/view"
842
1013
 
843
- const zoomIn = () => setZoom((zoom) => clampSpreadsheetZoom(stepSpreadsheetZoom(zoom, 1)))
1014
+ const zoomIn = () => setZoom((zoom) => stepSpreadsheetZoom(zoom, 1))
844
1015
  // <Spreadsheet zoom={zoom} onZoomChange={setZoom} … />
845
1016
  ```
846
1017
 
847
1018
  ## TSV codec (`@aiquants/spreadsheet/tsv`)
848
1019
 
849
- `@aiquants/spreadsheet/tsv` publishes `parseTsv` and `serializeTsv` — the codec the grid copies and pastes with — without
850
- loading the grid (no React, no `@aiquants/virtualscroll`; about 1 KB). A host that lazily imports the grid can still read
851
- the grid's text during the first render, for example to server-render a placeholder table of the rows the grid will show
852
- (the same markup on the client, so hydration matches). The barrel exports the same two functions (one definition), so
853
- code that already loads the grid keeps importing them from `@aiquants/spreadsheet`.
1020
+ `@aiquants/spreadsheet/tsv` publishes `parseTsv`, `iterateTsvRows`, `serializeTsv`, `serializeClipboardTsv` and
1021
+ `measureTsv` — the codec the grid copies and pastes with — without loading the grid (no React, no `@aiquants/virtualscroll`; about 1 KB). A host that lazily imports the grid can
1022
+ still read the grid's text during the first render, for example to server-render a placeholder table of the rows the grid
1023
+ will show (the same markup on the client, so hydration matches). The barrel exports the same five functions (one
1024
+ definition), so code that already loads the grid keeps importing them from `@aiquants/spreadsheet`.
1025
+
1026
+ The round trip is exact: `parseTsv(serializeTsv(m))` deep-equals `m` for every rectangular matrix of strings of at least
1027
+ 1 × 1, whatever the cells hold (tabs, CR, LF, CRLF, quotes, a cell that starts with `"`). Store tables with `serializeTsv`.
1028
+
1029
+ Two writers, two jobs: **clipboard text uses the Excel-compatible form, files and stored bodies use the strict,
1030
+ round-tripping `serializeTsv`.** The grid's own copy writes `text/plain` with `serializeClipboardTsv` (published, so a
1031
+ host's own copy control writes the same text), in **Excel's form**: it quotes only fields that hold a tab, CR or LF (doubling the `"` inside them), so a plain-text
1032
+ target receives `"Important" note` exactly as typed rather than `"""Important"" note"`. That form is not an exact round
1033
+ trip for cells that start with `"` (a raw `"<TAB>"<TAB>Blue` reads back as two cells, as it does in Excel), which is why the
1034
+ grid's own paste reads its JSON payload (the `application/x-aqsp-clip` flavour) first and reads `text/plain` only for
1035
+ other sources.
854
1036
 
855
1037
  | Export | Contract |
856
1038
  | :--- | :--- |
857
- | `parseTsv(text)` | A rectangular `string[][]`: one trailing line terminator dropped, empty text read as one empty cell (`[[""]]`), ragged rows padded with `""`; a quoted field is unquoted only when its content holds a tab or a line break (a value merely wrapped in quotes is literal text, as Excel writes it) |
858
- | `serializeTsv(matrix)` | Rows joined with CRLF and a trailing CRLF; only a field with a tab or a line break is quoted (its quotes doubled) — every other field, quotes included, is written verbatim (Excel's behaviour) |
1039
+ | `parseTsv(text)` | A rectangular `string[][]`: one trailing line terminator dropped, empty text read as one empty cell (`[[""]]`), ragged rows padded with `""`; a field that starts with `"` is read as quoted only when the quote closes properly (the closing quote is followed by a tab, a line break or the end) and the unescaped content holds a tab, CR, LF or `"` — anything else is literal text, so Excel's raw output reads as Excel wrote it (`"hi"` stays `"hi"`). The one raw shape that reads unquoted is a value that looks exactly like a quoted field holding a doubled quote (`"a""b"` reads `a"b`) |
1040
+ | `serializeTsv(matrix)` | Rows joined with CRLF and a trailing CRLF; a field is quoted (its quotes doubled) when it holds a tab, CR or LF — the cells Excel quotes — or starts with `"`, the one other cell a reader could take for a quoted field; every other field, inner quotes included, is written verbatim |
1041
+ | `iterateTsvRows(text)` | A `Generator<string[]>` that yields the rows one at a time in exactly `parseTsv`'s grammar (same quoting rule, line terminators and final-terminator handling), **unpadded**; a consumer that stops early materialises nothing past the last row it took (preview a large file under a cell budget). `parseTsv` is these rows, right-padded |
1042
+ | `serializeClipboardTsv(matrix)` | The clipboard's `text/plain` (Excel's form): rows joined with CRLF and a trailing CRLF; a field is quoted (its quotes doubled) only when it holds a tab, CR or LF, so `"Important" note` is written as typed. Not an exact round trip for cells that start with `"`; the grid's own copy uses it, and a host's copy control should too |
1043
+ | `measureTsv(text)` | `{ rows, cols }` — the dimensions `parseTsv(text)` would build, from the same grammar walk, without building any row array (the paste limit check runs on it before anything is materialised) |
859
1044
 
860
1045
  ```tsx
861
1046
  import { parseTsv } from "@aiquants/spreadsheet/tsv"
@@ -872,7 +1057,7 @@ importers with the exact same Excel-compatible semantics the grid uses. `enumera
872
1057
  `onOperationRejected` op), and `EditRejection` (the `onEditRejected` context — see "Editing, Undo, and rejection") are exported for
873
1058
  consumer-side annotations.
874
1059
 
875
- v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/index.ts` exports 40 values and 98 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):
1060
+ v0.8 adds the rich-cell engines as public pure functions (all React-free; `src/index.ts` exports 43 values and 98 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):
876
1061
 
877
1062
  - **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`),
878
1063
  `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).