kikuta@dev:~$cat ./notes/spex-keytips-interaction-design.md
·14 min·kikuta

Excel互換とSheets準拠のキーマップを共存させる

Canonical Catalog・Execution Capability・Runtime Bindingを分離し、Microsoft公式の外部inventoryでcoverageを監査する。

Google Sheetsをキーボード中心で操作するChrome拡張 Spex を作っています。

Spexには、Excelに近いキー操作を使う excel profileと、Google Sheetsの現在のメニュー構造に合わせる sheets profileがあります。

開発途中で、Excel側のショートカットカタログをかなり増やしました。

そのとき一見すると自然な設計をしていました。

Shortcut Catalog
    ↓
KeyTips
    ↓
Keyboard Controller
    ↓
Command

Catalogに登録されているなら、KeyTipsへ出す。

キーが完全一致したらCommandを実行する。

しかし、これが間違いでした。

ある時、KeyTipsにはキーが見えているのに、対応するSheets操作が存在しないという状態が出ました。

ユーザー視点ではかなり悪いバグです。

画面が「このキーを押せます」と案内しているのに、押した先に実行できるものがないからです。

Excelに存在することと、Google Sheetsで実行できることは別だった

SpexではExcel互換性を監査するため、canonicalなショートカット定義を持っています。

例えば概念的には、

Alt + F + H
Alt + H + O + I
Alt + N + S + Z + C
Alt + W + P

のようなExcel側のpathをcatalogへ残したい。

しかし、それら全部にGoogle Sheets上の同等機能があるとは限りません。

さらに、同等機能があっても、Spexが安全に実行・検証できるとは限りません。

つまり、次の3つは別物でした。

1. Excel canonical mappingとして存在する
2. Google Sheets上で対応するsemantic commandがある
3. Spexが現在安全に実行できる

最初の設計では、この3つをほぼ一つとして扱っていました。

Canonical Catalogを消すのも違う

では、実行できないショートカットをcatalogから消せばよいかというと、それも違います。

Excel互換性を考える上では、

Excelにはこのpathがあるが、Sheetsでは対応できない

という事実も重要だからです。

Catalogは仕様・監査のSource of Truthとして残したい。

ただし、そのままruntimeへ流してはいけない。

そこで、現在は概念を分離しています。

Canonical Catalog
        ↓
Execution Capability
        ↓
Runtime Binding
        ↓
KeyTips / Keyboard / Shortcut Reference

Canonical Catalog

Excel側のmappingを保持する。

「存在するかどうか」の正本。

Execution Capability

現在のSpexが、そのsemantic commandをGoogle Sheets上で安全に扱えるか。

例えば、

supported
unsupported

のような能力判定を持つ。

Runtime Binding

実際にユーザーへ提供するshortcut。

unsupportedなら、canonical catalogに残っていてもruntime bindingから外します。

「存在するけど使えない」をUIへ出さない

この変更後、Excel runtime shortcutsは概念的に次のように作られます。

const runtimeShortcuts = canonicalShortcuts.filter(
  shortcut => getExecutionCapability(shortcut.command) !== "unsupported"
);

これによって、canonical path自体は失いません。

一方で、

  • KeyTips
  • KeyboardController
  • Shortcut Reference

には実行可能なsubsetだけを渡します。

この分離は、機能対応表をきれいにするためではありません。

UIが嘘をつかないための設計です。

さらに、Excel準拠とSheets準拠で Alt+S が衝突した

もう一つ問題がありました。

Spexには二つのKeyMap profileがあります。

Excel profile

Excel-style shortcut engineがAlt直後を所有します。

Sheets固有のlive registryへ入るgatewayとして Alt+S を使います。

Alt
  ↓
Excel shortcut engine
  ↓
Alt + S
  ↓
Sheets Live Registry

Sheets profile

Google Sheetsの現在のUI構造を直接Alt配下へ出します。

この場合、Alt+S 自体がSheets側のrootになり得ます。

Alt
  ↓
Sheets Live Registry
  ↓
S = Sheet root

つまり、同じphysical sequenceである Alt+S が、profileによって別の意味を持ちます。

最初はこれを「duplicate shortcut」として扱いかけました。

しかし、考えてみれば衝突ではありません。

同時に有効にならないからです。

重複判定はglobalではなくprofile内で行う

ここで必要だったのは、各profileにAlt root ownerを一つだけ持たせることでした。

excel profile
  Alt owner = excel-shortcuts

sheets profile
  Alt owner = sheets-navigator

同じkeydownを両方のengineへ配送しません。

したがって、

excel:  Alt+S = Sheets Registry gateway
sheets: Alt+S = Sheet root

は合法です。

重複チェックもglobal catalog全体ではなく、同時にactiveになるbinding集合の中で行います。

この考え方を入れたことで、Excel準拠とSheets準拠を無理に一つの巨大shortcut treeへ混ぜなくて済みました。

直した後、別の場所からKeyTipsがまた嘘をついた

ここで終わらなかったのが面白いところです。

runtime bindingを分離した後も、一部のKeyTips生成経路がcanonical catalogを直接参照していました。

そのため、KeyboardController側では実行不能shortcutを除外していても、KeyTipsだけ古い集合を表示できる状態が残りました。

Git履歴でも、

fix: separate keymap profiles from command capability
    ↓
fix: scope keytips to runtime catalog
    ↓
test: exhaust runtime keytip prefixes

という順で修正しています。

ここで得た教訓はかなり単純です。

Filterした集合を作っただけでは不十分。すべてのuser-facing consumerが同じruntime集合を見ていることまで検証する。

Invariant Testを追加した

この種のバグは、個別exampleだけだと再発します。

そのため現在は、runtime binding全体を走査するテストを持っています。

最低限、次を確認します。

1. Active shortcutには必ずexecution capabilityがある

for every runtime shortcut:
    capability != unsupported

2. unavailable canonical pathはKeyTipsへ出ない

canonicalにはある
runtimeにはない
KeyTipsにもない

3. profile内ではsequenceが一意

excel active bindings -> unique
sheets active bindings -> unique

4. cross-profile overlapは許可

同時activeではないので、同じsequenceが別profileに存在してもよい。

5. prefixを網羅する

leafだけではなく、途中prefixが次のキー集合を正しく返すかを総当たりします。

これはKeyTipsのために重要です。

最終commandだけ正しくても、途中で存在しないキーを案内したらUXは壊れるからです。

Shortcut DefinitionはUIではない

今回の設計変更で一番大きかった認識はこれでした。

最初は、

shortcut catalog = ユーザーが使えるショートカット一覧

という感覚がありました。

今は、

Catalog     = 知識
Capability  = 現在できること
Binding     = 今ユーザーへ約束すること

と分けています。

この3つは似ていますが、責務が違います。

特に、外部Webアプリの上で動く拡張機能では、host側の変更によってcapabilityだけが突然落ちることがあります。

そのときcanonical knowledgeまで消す必要はありません。

一方、capabilityが落ちたのにUIが古い約束を続けるのは危険です。

Coverageの分母をSpex自身にしない

ここまでの分離で、runtimeに存在するshortcutが実行可能であることは検証できるようになりました。

ただし、もう一つ別の問題があります。

Spexが知っているshortcutを100%分類できても、Excel全体のcoverageが100%とは限らない。

内部catalogだけを分母にすると、まだ登録していないExcel commandは最初から集計対象に入りません。

そこで現在は、内部canonical catalogとは別に、Microsoft公式のWindows desktop向け資料から外部inventoryを作っています。

2026-09-19時点の監査済みinventoryは46 sequenceです。

external Excel inventory: 46

active           28
unsupported      10
missing-binding   0
missing-command   8

一方、Spex内部のmodern canonical catalogは47 sequenceあります。この二つは同じ集合ではありません。

Spex canonical catalog
    = Spexが現在知っているExcel mapping

External Excel inventory
    = Spex実装とは独立して監査したExcel側の分母

外部inventoryではcoverage状態を active / unsupported / missing-binding / missing-command の4つに分けています。missing-binding はSpex Command自体は存在するがmodern Excel bindingがない状態、missing-command は対応するSpex Command自体がまだない状態です。

unsupportedmissing をinventoryから削除しないことが重要です。実装済み項目だけで分母を作ると、機能を追加するたびにcoverageが常に100%に近づく自己参照的な指標になります。外部分母を持つことで、coverage reportをどこが未実装なのかを検出する監査データとして使えます。

Ctrl系shortcutはさらに別inventoryにする

Ribbon KeyTipと、Ctrl+CCtrl+B のようなdirect shortcutも同じcoverageへ混ぜません。Google Sheets自身が同じshortcutを持っている場合、Spexが横取りして再実装する必要がないからです。

現在のdirect shortcut seed 44件は次のように分類しています。

native-same        24
native-equivalent   8
different-key       4
conflict            8

native-same はSpexの実装実績としてKeyTip coverageへ加算しません。Sheets nativeへそのまま渡している操作と、Spexが実装したExcel互換commandを同じ数字にすると、何を実装したcoverageなのか分からなくなるためです。

Release gateも単一percentageにしない

最終的な実行結果も executed / invoked / unsupported / failed を区別しています。DOM eventを送れた、menu itemを押した、といった状態は invoked であって、Google Sheets上の結果まで確認できた executed とは別です。

Release AcceptanceではProduction Command inventory、Terminal outcome contract、Native-handoff continuity、Recovery / non-interferenceを独立して検証します。

shortcut coverageを一つのpercentageへ集約しないのは、入力経路、実行能力、hostへの引き継ぎ、観測可能な結果がそれぞれ別のfailure modeを持つからです。

一般化すると

これはショートカットだけの問題ではないと思います。

例えば、

  • feature catalog
  • plugin registry
  • command palette
  • API capability
  • role-based action menu

でも、

定義されている

現在実行できる

ユーザーへ表示する

を同じ集合として扱うと壊れやすいです。

SpexではKeyTipsが目に見えて嘘をついたことで、この境界をはっきり分けることになりました。

Spex

この設計はGoogle Sheets向けChrome拡張Spexで使っています。