@aiquants/spreadsheet 0.9.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.
@@ -10,14 +10,19 @@
10
10
  * without saving. DOM hooks: `data-aqsp-popover="link"`, `data-aqsp-popover-title`,
11
11
  * `data-aqsp-link-text`, `data-aqsp-link-url`, `data-aqsp-popover-save`, `data-aqsp-link-open`
12
12
  * (the Open button — distinct from the layer's anchor open-state `data-aqsp-popover-open`),
13
- * `data-aqsp-popover-close`, `data-aqsp-popover-rejected`.
13
+ * `data-aqsp-popover-close`, `data-aqsp-popover-rejected`. v0.10 viewer mode
14
+ * (`ctx.cellReadOnly === true`): Save is `disabled` + `aria-disabled` and Enter does nothing,
15
+ * while the URL stays readable and Open stays live (navigation is a read — the wall is G14a; key
16
+ * absent = v0.9-identical conduct).
14
17
  *
15
18
  * 既定のハイパーリンクエディタ (v0.8 §4.1.16): text + URL 欄、保存、Open (ホストが onHyperlinkOpen を
16
19
  * 宣言し (`ctx.canOpen`) セルにリンクがあるときだけ)、閉じる。URL は打鍵毎に normalizeHyperlink で
17
20
  * 検証し、正規化不能な非空値は不正表示 (`data-aqsp-link-invalid` / `aria-invalid`) で保存を拒む —
18
21
  * commit は常に正規化済み href か null (空欄 = リンク消去) だけを運ぶ。空の text 下書き + 有効 URL の
19
22
  * 保存は href を表示テキストとして commit する (Sheets / Excel 同等 — 空セルのリンクを見えないままに
20
- * しない)。キー: Enter 保存、Escape で閉じる。フォーカス離脱は保存せず閉じる。
23
+ * しない)。キー: Enter 保存、Escape で閉じる。フォーカス離脱は保存せず閉じる。v0.10 ビューアモード
24
+ * (`ctx.cellReadOnly === true`): 保存は `disabled` + `aria-disabled` で Enter も何もしないが、URL は
25
+ * 読めたまま Open も生きている (ナビゲーションは読み — 壁は G14a。キー不在 = v0.9 恒等)。
21
26
  */
22
27
 
23
28
  import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"
@@ -34,6 +39,9 @@ export type LinkPopoverProps = { ctx: LinkPopoverContext; labels: SpreadsheetLab
34
39
  export const LinkPopover = ({ ctx, labels }: LinkPopoverProps): ReactNode => {
35
40
  const [text, setText] = useState(ctx.current.text)
36
41
  const [url, setUrl] = useState(ctx.current.url === undefined ? "" : ctx.current.url)
42
+ // v0.10 (設計 ADR-30): 宣言ゲートキーが true のときだけ確定操作を disabled に (キー不在 = v0.9 恒等)。
43
+ // Open は読み (ナビゲーション) なので生きたまま。壁は G14a — ここは表示であって防壁ではない
44
+ const readOnly = ctx.cellReadOnly === true
37
45
  const titleId = useId()
38
46
  const settlingRef = useRef(false)
39
47
  // 空欄はリンク消去、それ以外は正規化 (null = 不正)
@@ -49,6 +57,10 @@ export const LinkPopover = ({ ctx, labels }: LinkPopoverProps): ReactNode => {
49
57
  }
50
58
  }
51
59
  const save = (): void => {
60
+ // 読み取り専用ビューア: 保存は不可 (disabled ボタンと同じ品行 — Enter も何もしない)
61
+ if (readOnly) {
62
+ return
63
+ }
52
64
  // 不正 URL は保存しない (表示で知らせる — 代替値は作らない)
53
65
  if (invalid) {
54
66
  return
@@ -91,7 +103,7 @@ export const LinkPopover = ({ ctx, labels }: LinkPopoverProps): ReactNode => {
91
103
  <input className="aqsp-popover-input" data-aqsp-link-url="" data-aqsp-popover-autofocus="" type="url" value={url} aria-invalid={invalid || ctx.rejected ? true : undefined} onChange={(event) => setUrl(event.currentTarget.value)} onKeyDown={onKeyDown} />
92
104
  </label>
93
105
  <div className="aqsp-popover-actions">
94
- <button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" disabled={invalid} onClick={save}>
106
+ <button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" disabled={invalid || readOnly} aria-disabled={readOnly ? "true" : undefined} onClick={save}>
95
107
  {resolveLabel(labels, "linkSave")}
96
108
  </button>
97
109
  {ctx.canOpen && ctx.current.url !== undefined ? (
@@ -5,17 +5,26 @@
5
5
  * focus saves-if-changed — an anchor scrolled under a frozen band is clipped, not un-rendered
6
6
  * (SelectionOverlay `placeAnchor`), so the textarea keeps focus and that is NOT a leave: the
7
7
  * editor hides and comes back with its draft. The textarea's `aria-describedby` points at the hint paragraph, which
8
- * swaps between `labels.noteEscapeHint` and `labels.noteRejectedHint` with the rejected state.
8
+ * swaps among `labels.noteEscapeHint` (normal), `labels.noteRejectedHint` (rejected) and
9
+ * `labels.noteViewerHint` (viewer mode — precedence over the rejected wording, since neither save
10
+ * nor retry is possible there and the hint must not promise either).
9
11
  * DOM hooks: `data-aqsp-popover="note"`, `data-aqsp-popover-title`, `data-aqsp-note-input`,
10
12
  * `data-aqsp-popover-save`, `data-aqsp-popover-close`, `data-aqsp-popover-hint`,
11
- * `data-aqsp-popover-rejected`.
13
+ * `data-aqsp-popover-rejected`. v0.10 viewer mode (`ctx.cellReadOnly === true`): the Save button
14
+ * is `disabled` + `aria-disabled` (native only — no new attribute, no package CSS, design ADR-31),
15
+ * Ctrl+Enter does nothing, Escape / focus-leave close WITHOUT saving (the wall is G14a; key
16
+ * absent = v0.9-identical conduct), and the hint swaps to `noteViewerHint`.
12
17
  *
13
18
  * 既定の note エディタ (v0.8 §4.1.16): textarea の下書き + 保存 / 閉じるボタン。キー: Escape は
14
19
  * 通常状態で「変更があれば保存して閉じる」、拒否状態では保存せず閉じる。Ctrl+Enter は保存 (拒否後は
15
20
  * 再試行)。フォーカスがポップオーバーを離れたら変更があれば保存 — 凍結帯の下へ潜ったアンカーは
16
21
  * clip されるだけで描画から外れない (SelectionOverlay の placeAnchor) ので textarea はフォーカスを保ち、
17
22
  * 離脱には当たらない: エディタは隠れて下書きごと戻る。textarea の aria-describedby は
18
- * ヒント段落を指し、拒否状態で `labels.noteEscapeHint` ↔ `labels.noteRejectedHint` が差し替わる。
23
+ * ヒント段落を指し、`labels.noteEscapeHint` (通常) ↔ `labels.noteRejectedHint` (拒否状態) ↔
24
+ * `labels.noteViewerHint` (ビューアモード — 保存も再試行も不能のため拒否文言より優先) が差し替わる。
25
+ * v0.10 ビューアモード (`ctx.cellReadOnly === true`): 保存ボタンは `disabled` + `aria-disabled`
26
+ * (ネイティブのみ — 新属性 / パッケージ CSS なし、設計 ADR-31)、Ctrl+Enter は何もせず、Escape /
27
+ * フォーカス離脱は保存せず閉じ、ヒントは `noteViewerHint` に差し替わる (壁は G14a。キー不在 = v0.9 恒等)。
19
28
  */
20
29
 
21
30
  import { type FocusEvent, type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"
@@ -31,6 +40,9 @@ export type NotePopoverProps = { ctx: NotePopoverContext; labels: SpreadsheetLab
31
40
  export const NotePopover = ({ ctx, labels }: NotePopoverProps): ReactNode => {
32
41
  const initial = ctx.current === undefined ? "" : ctx.current
33
42
  const [draft, setDraft] = useState(initial)
43
+ // v0.10 (設計 ADR-30): 宣言ゲートキーが true のときだけ確定操作を disabled に (キー不在 = v0.9 恒等)。
44
+ // 壁は G14a — ここは表示であって防壁ではない
45
+ const readOnly = ctx.cellReadOnly === true
34
46
  const titleId = useId()
35
47
  const hintId = useId()
36
48
  /** True while THIS component drives a commit / close (its synchronous focusout must not re-fire the save). / 自分が commit / close を駆動している間 true (同期 focusout の二重保存を防ぐ)。 */
@@ -46,11 +58,16 @@ export const NotePopover = ({ ctx, labels }: NotePopoverProps): ReactNode => {
46
58
  }
47
59
  }
48
60
  const save = (): void => {
61
+ // 読み取り専用ビューア: 確定操作は不可 (disabled ボタンと同じ品行 — Ctrl+Enter も何もしない)
62
+ if (readOnly) {
63
+ return
64
+ }
49
65
  // 空の下書きはノート消去
50
66
  settle(() => ctx.commit(draft === "" ? null : draft))
51
67
  }
52
68
  const saveIfChanged = (): void => {
53
- if (draft !== initial) {
69
+ // 読み取り専用ビューア: Escape / フォーカス離脱は保存せず閉じる (下書きは commit できない)
70
+ if (draft !== initial && !readOnly) {
54
71
  save()
55
72
  } else {
56
73
  settle(ctx.close)
@@ -91,10 +108,11 @@ export const NotePopover = ({ ctx, labels }: NotePopoverProps): ReactNode => {
91
108
  </div>
92
109
  <textarea className="aqsp-popover-textarea" data-aqsp-note-input="" data-aqsp-popover-autofocus="" value={draft} aria-describedby={hintId} aria-invalid={ctx.rejected ? true : undefined} onChange={(event) => setDraft(event.currentTarget.value)} onKeyDown={onKeyDown} />
93
110
  <p className="aqsp-popover-hint" id={hintId} data-aqsp-popover-hint="">
94
- {resolveLabel(labels, ctx.rejected ? "noteRejectedHint" : "noteEscapeHint")}
111
+ {/* ビューアは保存 (noteEscapeHint) も再試行 (noteRejectedHint) も約束できない — Escape は保存せず閉じるため noteViewerHint を最優先 */}
112
+ {resolveLabel(labels, readOnly ? "noteViewerHint" : ctx.rejected ? "noteRejectedHint" : "noteEscapeHint")}
95
113
  </p>
96
114
  <div className="aqsp-popover-actions">
97
- <button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" onClick={save}>
115
+ <button type="button" className="aqsp-popover-button" data-aqsp-popover-save="" disabled={readOnly} aria-disabled={readOnly ? "true" : undefined} onClick={save}>
98
116
  {resolveLabel(labels, "noteSave")}
99
117
  </button>
100
118
  <button type="button" className="aqsp-popover-button" data-aqsp-popover-close="" aria-label={resolveLabel(labels, "popoverClose")} onClick={() => settle(ctx.close)}>
@@ -21,6 +21,13 @@
21
21
  * cell binds —) and the
22
22
  * IME guard the layer applies to every popover keydown (`isCompositionKeydown`). No DOM is
23
23
  * rendered here — PopoverLayer.tsx renders from `context` / `tipContext` (lazy mount).
24
+ * v0.10 read-only (design ADR-30, refusals via the required `rejectReadOnly` funnel): S3 = ONE
25
+ * interactive-context builder site for the declaration-gated `cellReadOnly` key (note / comment /
26
+ * link builds and the G14a re-present all share it); G14a re-checks live at `commitOrReject`'s
27
+ * head (refusal re-presents with `cellReadOnly` recomputed, `rejected` kept false, draft
28
+ * preserved); G14b is the list commit's defense-in-depth; G14c refuses a list open PRE-open —
29
+ * after the RangeError wiring check, BEFORE the commit gate (doomed-open: no force-commit).
30
+ * Capability undeclared = contexts and conduct byte-identical to v0.9.
24
31
  *
25
32
  * ポップオーバー状態機械 (v0.8 §1.8 / §4.1.15 — ADR-15 対話層)。6 種 (note エディタ / ホバー
26
33
  * note tip / コメントスレッド / ハイパーリンクエディタ / 検証リスト / 検証メッセージ) を overlay が
@@ -39,7 +46,12 @@
39
46
  * 変化 (root の active 効果が呼ぶ) のたびに再評価し、離れれば外し、ホバー中セルへ乗れば結ぶ —)、
40
47
  * 層が全 popover keydown に掛ける
41
48
  * IME ガード (`isCompositionKeydown`)。DOM は描かない — PopoverLayer.tsx が `context` /
42
- * `tipContext` から遅延マウントする。
49
+ * `tipContext` から遅延マウントする。v0.10 読み取り専用 (設計 ADR-30、拒否は必須 `rejectReadOnly`
50
+ * 漏斗): S3 = 宣言ゲートキー `cellReadOnly` の単一対話 context ビルダ (note / comment / link の
51
+ * 構築と G14a 再提示が共有)、G14a = commitOrReject 頭の live 再検査 (拒否は cellReadOnly 再計算 +
52
+ * rejected false のまま + 下書き保持で再提示)、G14b = list commit の縦深防御、G14c = list の
53
+ * pre-open 拒否 (RangeError 結線検査の後・commit ゲートの前 — doomed-open で force-commit なし)。
54
+ * 能力未宣言は context も品行も v0.9 とバイト恒等。
43
55
  */
44
56
 
45
57
  import type { VirtualGridHandle } from "@aiquants/virtualscroll"
@@ -48,7 +60,7 @@ import { isCompositionKeydown } from "../keyboard/compositionGuard.ts"
48
60
  import type { AxisModel } from "../model/axisModel.ts"
49
61
  import { type CellComment, type CellCommentInput, normalizeAdapterHyperlink, normalizeHyperlink } from "../model/cellAnnotations.ts"
50
62
  import { type CellCoord, clampCoord, coordEquals } from "../model/cellCoord.ts"
51
- import type { CellEdit, EditRejection, SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
63
+ import { type CellEdit, type EditRejection, isReadOnlyCell, type ReadOnlyRejection, readOnlyDeclared, type SpreadsheetDataAdapter } from "../model/dataAdapter.ts"
52
64
  import { colToViewportX, rowToViewportY, viewportXToCol, viewportYToRow } from "../model/frozenPartition.ts"
53
65
  import { resolveLabel, type SpreadsheetLabels } from "../model/labels.ts"
54
66
  import { NOTE_TIP_LEAVE_GRACE_MS, type PopoverRect, shouldShowNoteTip } from "./placement.ts"
@@ -62,17 +74,28 @@ export const POPOVER_KINDS: readonly PopoverKind[] = ["note", "note-tip", "comme
62
74
  /** The `popovers` prop: `false` disables a kind (its commands pass through, `openPopover` throws). / `popovers` prop: `false` はその kind を無効化 (コマンドは素通し、`openPopover` は RangeError)。 */
63
75
  export type PopoverOverrides = { readonly [K in PopoverKind]?: boolean }
64
76
 
65
- /** Note editor context (v0.8 §1.8). `current` undefined = no note yet; `commit(null)` clears. / note エディタ文脈。current undefined = ノートなし、commit(null) は消去。 */
66
- export type NotePopoverContext = { kind: "note"; cell: CellCoord; anchorRect: PopoverRect; current: string | undefined; commit: (next: string | null) => void; close: () => void; rejected: boolean }
77
+ /** Note editor context (v0.8 §1.8). `current` undefined = no note yet; `commit(null)` clears. `cellReadOnly` (v0.10, declaration-gated): key ABSENT = the `isCellReadOnly` capability is undeclared (byte-identical context), else the live predicate answer — `true` disables the default component's commit affordance (G14a walls the commit regardless). / note エディタ文脈。current undefined = ノートなし、commit(null) は消去。cellReadOnly (v0.10、宣言ゲート): キー不在 = isCellReadOnly 能力未宣言 (context はバイト恒等)、あれば live 述語の答え — true は既定コンポーネントの確定操作を disabled にする (壁は G14a)。 */
78
+ export type NotePopoverContext = { kind: "note"; cell: CellCoord; anchorRect: PopoverRect; current: string | undefined; commit: (next: string | null) => void; close: () => void; rejected: boolean; cellReadOnly?: boolean }
67
79
 
68
80
  /** Hover note tip context (read-only). / ホバー note tip 文脈 (読み取り専用)。 */
69
81
  export type NoteTipContext = { kind: "note-tip"; cell: CellCoord; anchorRect: PopoverRect; current: string; close: () => void }
70
82
 
71
- /** Comment thread context: the reply UI commits `[...current (with ids), { text }]`; `null` clears the thread. / コメントスレッド文脈: 返信 UI は `[...current (id つき), { text }]` を commit、null はスレッド消去。 */
72
- export type CommentPopoverContext = { kind: "comment"; cell: CellCoord; anchorRect: PopoverRect; current: readonly CellComment[]; commit: (next: readonly CellCommentInput[] | null) => void; close: () => void; rejected: boolean }
83
+ /** Comment thread context: the reply UI commits `[...current (with ids), { text }]`; `null` clears the thread. `cellReadOnly` (v0.10): same declaration-gated key as the note context. / コメントスレッド文脈: 返信 UI は `[...current (id つき), { text }]` を commit、null はスレッド消去。cellReadOnly (v0.10) は note 文脈と同じ宣言ゲートキー。 */
84
+ export type CommentPopoverContext = { kind: "comment"; cell: CellCoord; anchorRect: PopoverRect; current: readonly CellComment[]; commit: (next: readonly CellCommentInput[] | null) => void; close: () => void; rejected: boolean; cellReadOnly?: boolean }
73
85
 
74
- /** Hyperlink editor context: `url` on commit is a normalised href or null (clear); `canOpen` = onHyperlinkOpen declared. / ハイパーリンクエディタ文脈: commit の url は正規化済み href か null (消去)、canOpen = onHyperlinkOpen 宣言。 */
75
- export type LinkPopoverContext = { kind: "link"; cell: CellCoord; anchorRect: PopoverRect; current: { text: string; url: string | undefined }; commit: (next: { text: string; url: string | null }) => void; close: () => void; canOpen: boolean; open: () => void; rejected: boolean }
86
+ /** Hyperlink editor context: `url` on commit is a normalised href or null (clear); `canOpen` = onHyperlinkOpen declared. `cellReadOnly` (v0.10): same declaration-gated key as the note context (the Open button stays live — navigation is a read). / ハイパーリンクエディタ文脈: commit の url は正規化済み href か null (消去)、canOpen = onHyperlinkOpen 宣言。cellReadOnly (v0.10) は note 文脈と同じ宣言ゲートキー (Open は読みなので生きたまま)。 */
87
+ export type LinkPopoverContext = {
88
+ kind: "link"
89
+ cell: CellCoord
90
+ anchorRect: PopoverRect
91
+ current: { text: string; url: string | undefined }
92
+ commit: (next: { text: string; url: string | null }) => void
93
+ close: () => void
94
+ canOpen: boolean
95
+ open: () => void
96
+ rejected: boolean
97
+ cellReadOnly?: boolean
98
+ }
76
99
 
77
100
  /** Validation list picker context: `current` = the list rule's values, `typed` = the prefix that opened it. / 検証リスト文脈: current = list 規則の値、typed = 開いたときの入力プレフィックス。 */
78
101
  export type ListPopoverContext = { kind: "list"; cell: CellCoord; anchorRect: PopoverRect; current: readonly string[]; typed: string; commit: (value: string) => void; close: () => void }
@@ -144,6 +167,15 @@ export type UsePopoversParams = {
144
167
  /** Composite drag probe incl. the reference drag — the tip is never shown mid-drag. / 参照ドラッグ含む合成ドラッグ判定 — ドラッグ中は tip を出さない。 */
145
168
  isDraggingAny: () => boolean
146
169
  commitEdits: (edits: readonly CellEdit[], rejection: EditRejection) => number | null
170
+ /**
171
+ * v0.10 read-only refusal funnel (required seam — the root always wires it; identity-stable,
172
+ * read through paramsRef): G14a (interactive commit), G14b (list commit) and G14c (the list
173
+ * pre-open refusal) fire it once per refused gesture. Capability absent ⇒ never called.
174
+ * v0.10 読み取り専用拒否漏斗 (必須 seam — root が常に渡す。identity 安定、paramsRef 読み):
175
+ * G14a (対話 commit)・G14b (list commit)・G14c (list の pre-open 拒否) が拒否ジェスチャ毎
176
+ * 1 回発火する。能力未宣言なら一度も呼ばれない。
177
+ */
178
+ rejectReadOnly: (rejection: ReadOnlyRejection) => void
147
179
  labels: SpreadsheetLabels
148
180
  onHyperlinkOpen?: (url: string, cell: CellCoord) => void
149
181
  /** Root-owned anchor state the overlay positions. / root 所有のアンカー状態 (overlay が配置)。 */
@@ -451,10 +483,34 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
451
483
  [writeAnchors],
452
484
  )
453
485
 
454
- /** Runs an annotate commit; a rejection re-presents the context with `rejected: true`. / annotate の commit を実行し、拒否なら rejected: true で再提示する。 */
486
+ /**
487
+ * S3 (v0.10): the SINGLE interactive-context builder site for the declaration-gated
488
+ * `cellReadOnly` key — the three build paths (note / comment / link) and the G14a re-present
489
+ * all spread this result, so the census stays at one `readOnlyDeclared` + one `isReadOnlyCell`
490
+ * call. Key absent = capability undeclared (context byte-identical), else the live answer.
491
+ * S3 (v0.10): 宣言ゲートキー `cellReadOnly` の**単一**対話 context ビルダサイト — 3 つの構築
492
+ * 経路 (note / comment / link) と G14a の再提示が全てこの戻り値を spread するため、census は
493
+ * readOnlyDeclared 1 + isReadOnlyCell 1 のまま。キー不在 = 能力未宣言 (context バイト恒等)、
494
+ * あれば live の答え。
495
+ */
496
+ const readOnlyKeyOf = useCallback((cell: CellCoord): { cellReadOnly?: boolean } => {
497
+ const p = paramsRef.current
498
+ // 宣言時だけキーを出す (undefined キーも出さない — v0.9 形ホストの context はバイト恒等)
499
+ return readOnlyDeclared(p.adapter) ? { cellReadOnly: isReadOnlyCell(p.adapter, cell.row, cell.col) } : {}
500
+ }, [])
501
+
502
+ /** Runs an annotate commit; a G14a read-only refusal re-presents with `cellReadOnly` recomputed (`rejected` stays false), an adapter rejection re-presents with `rejected: true`. / annotate の commit を実行。G14a の読み取り専用拒否は cellReadOnly を再計算して再提示 (rejected は false のまま)、アダプタ拒否は rejected: true で再提示。 */
455
503
  const commitOrReject = useCallback(
456
504
  (ctx: NotePopoverContext | CommentPopoverContext | LinkPopoverContext, edits: readonly CellEdit[]) => {
457
505
  const p = paramsRef.current
506
+ // G14a (v0.10 §6.10): commit 頭の live 再検査 — adapter 不到達。再提示は cellReadOnly 再計算
507
+ // (今は true — 既定コンポーネントが確定操作を disabled に)、rejected は false のまま
508
+ // (true は「adapter が投げた、再試行の余地あり」の意味 — ここでは嘘になる)、入力値は保持
509
+ if (isReadOnlyCell(p.adapter, ctx.cell.row, ctx.cell.col)) {
510
+ p.rejectReadOnly({ kind: "operation", op: "annotate", cellCount: 1 })
511
+ present({ ...ctx, ...readOnlyKeyOf(ctx.cell) })
512
+ return
513
+ }
458
514
  if (p.commitEdits(edits, { source: "annotate" }) === null) {
459
515
  // 拒否: 開いたまま rejected 状態へ (既定コンポーネントが Ctrl+Enter 再試行 / Escape 破棄を担う)
460
516
  present({ ...ctx, rejected: true })
@@ -462,7 +518,7 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
462
518
  }
463
519
  close()
464
520
  },
465
- [present, close],
521
+ [present, close, readOnlyKeyOf],
466
522
  )
467
523
 
468
524
  const open = useCallback(
@@ -472,6 +528,14 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
472
528
  throw new RangeError(`[spreadsheet] openPopover("${kind}") is unavailable: the kind is disabled by the popovers prop or its adapter capability is undeclared`)
473
529
  }
474
530
  const cell = p.getActive()
531
+ // G14c (v0.10 §6.10 — doomed-open 原則): list の pre-open 拒否。座席は RangeError 結線検査の
532
+ // 後 (結線バグはデータ状態に優先 — fail-fast 序列保存)・open 経路 commit ゲートの**前**
533
+ // (拒否される open が無関係セッションを force-commit しない)。picker の対話 = 書込そのもの
534
+ // なので popover は開かない。RangeError にはしない (読み取り専用はデータ状態で結線バグではない)
535
+ if (kind === "list" && isReadOnlyCell(p.adapter, cell.row, cell.col)) {
536
+ p.rejectReadOnly({ kind: "edit", cell, editSession: "none" })
537
+ return
538
+ }
475
539
  if (kind === "note-tip") {
476
540
  // 手動表示: active セルにノートがあるときだけ (ホバー述語とは独立)
477
541
  showTip(cell)
@@ -515,6 +579,14 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
515
579
  current: rule.values,
516
580
  typed,
517
581
  commit: (value: string) => {
582
+ // G14b (v0.10 §6.10): list commit の縦深防御 — 開いている間の読み取り専用化を
583
+ // commitEdits 前に live 再検査 (G14c が正門、ここは第二壁)。拒否は無書込で閉じる
584
+ // (picker に他の対話は無い — Escape 相当の閉鎖)
585
+ if (isReadOnlyCell(paramsRef.current.adapter, cell.row, cell.col)) {
586
+ paramsRef.current.rejectReadOnly({ kind: "edit", cell, editSession: "none" })
587
+ close()
588
+ return
589
+ }
518
590
  // リスト確定はセル編集 (source "edit"、セッションなし = discarded)
519
591
  paramsRef.current.commitEdits([{ row: cell.row, col: cell.col, text: value }], { source: "edit", editSession: "discarded" })
520
592
  close()
@@ -537,6 +609,8 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
537
609
  },
538
610
  close,
539
611
  rejected: false,
612
+ // S3: 宣言ゲートキー (単一ビルダ経由 — 未宣言はキー不在で context バイト恒等)
613
+ ...readOnlyKeyOf(cell),
540
614
  }
541
615
  present(ctx)
542
616
  return
@@ -554,6 +628,8 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
554
628
  },
555
629
  close,
556
630
  rejected: false,
631
+ // S3: 宣言ゲートキー (単一ビルダ経由)
632
+ ...readOnlyKeyOf(cell),
557
633
  }
558
634
  present(ctx)
559
635
  return
@@ -585,10 +661,12 @@ export const usePopovers = (params: UsePopoversParams): PopoverController => {
585
661
  }
586
662
  },
587
663
  rejected: false,
664
+ // S3: 宣言ゲートキー (単一ビルダ経由)
665
+ ...readOnlyKeyOf(cell),
588
666
  }
589
667
  present(ctx)
590
668
  },
591
- [anchorRectOf, close, commitOrReject, hideTipNow, present, showTip],
669
+ [anchorRectOf, close, commitOrReject, hideTipNow, present, showTip, readOnlyKeyOf],
592
670
  )
593
671
 
594
672
  const { enabled, bodyRef } = params