docs/plans/2026-05-03-slate-v2-hotkey-runtime-dependency-ralplan.md
Date: 2026-05-03
Status: ready for implementation
Skill: slate-ralplan
Source repo: /Users/zbeyens/git/slate-v2
Replace is-hotkey. Do not keep it, do not fork it as a package, and do not import ProseMirror/Tiptap keymap code.
The right shape is a small Slate-owned hotkey matcher in slate-dom, with parity tests around today's Slate Hotkeys behavior plus two explicit upgrades:
mod+, platform, optional-modifier, arrow/delete/enter grammar used by Slate today;event.key first, then fall back to event.code only for single-letter shortcuts when event.key is non-ASCII.This keeps the public Hotkeys API stable while removing a stale hot-path dependency.
Intent: remove a no-longer-maintained keyboard dependency from Slate v2 without creating new shortcut regressions.
Desired outcome: Slate owns the tiny hotkey matching substrate it already treats as editor runtime law. Package consumers keep importing Hotkeys from slate-dom; examples stop importing is-hotkey directly.
In scope:
slate-dom hotkey matcher internals.slate-react keyboard command behavior that consumes Hotkeys.is-hotkey.Non-goals:
Hotkeys.Decision boundaries:
isHotkey helper if examples genuinely need a generic matcher after direct is-hotkey imports are removed.Unresolved user-decision points: none.
Live Slate v2 currently imports is-hotkey directly in the hotkey runtime: /Users/zbeyens/git/slate-v2/packages/slate-dom/src/utils/hotkeys.ts:1.
The current grammar is small and concrete:
mod+b, mod+i, mod+z, shift+enter, enter, arrows, and shift?+backspace/delete: /Users/zbeyens/git/slate-v2/packages/slate-dom/src/utils/hotkeys.ts:8-23.opt+left/right/up/down, cmd+shift?+backspace/delete, ctrl+h, ctrl+d, ctrl+k, and ctrl+t: /Users/zbeyens/git/slate-v2/packages/slate-dom/src/utils/hotkeys.ts:25-40./Users/zbeyens/git/slate-v2/packages/slate-dom/src/utils/hotkeys.ts:42-46.create() compiles the generic/apple/windows checkers once and reuses them at event time: /Users/zbeyens/git/slate-v2/packages/slate-dom/src/utils/hotkeys.ts:52-65.slate-dom exports the Hotkeys object: /Users/zbeyens/git/slate-v2/packages/slate-dom/src/index.ts:61.The keyboard command path depends on Hotkeys for history, break insertion, delete units, and movement: /Users/zbeyens/git/slate-v2/packages/slate-react/src/editable/editing-kernel.ts:916-1003.
The package dependency is real, not docs-only:
slate-dom depends on is-hotkey: /Users/zbeyens/git/slate-v2/packages/slate-dom/package.json:23-25.slate-dom carries @types/is-hotkey: /Users/zbeyens/git/slate-v2/packages/slate-dom/package.json:31-33.@types/is-hotkey and is-hotkey: /Users/zbeyens/git/slate-v2/package.json:85 and /Users/zbeyens/git/slate-v2/package.json:96./Users/zbeyens/git/slate-v2/bun.lock.Examples currently leak the dependency to users:
is-hotkey: /Users/zbeyens/git/slate-v2/site/examples/ts/richtext.tsx:1.is-hotkey as a library: /Users/zbeyens/git/slate-v2/docs/general/resources.md:9-10.site/examples/ts/images.tsx, code-highlighting.tsx, iframe.tsx, and inlines.tsx.Prior local evidence says this dependency has already caused non-versioned install breakage:
/Users/zbeyens/git/plate-2/docs/plans/2026-03-30-local-is-hotkey-parse-failure.md records a corrupted Bun mirror under node_modules/.bun/[email protected]/.../lib/index.js and a full local env wipe to recover.NPM freshness:
npm view is-hotkey version time repository license types dist.unpackedSize --json on 2026-05-03 returned version 0.2.0, last version publish 2020-11-24T14:43:01.562Z, MIT, and unpacked size about 20 KB.npm view w3c-keyname ... returned version 2.2.8, last publish 2023-06-07, MIT, and about 8 KB unpacked. It is usable evidence through ProseMirror, but not enough reason to add a new dependency for Slate's current tiny grammar.Lexical:
isExactShortcutMatch checks modifier masks, compares event.key case-insensitively, rejects code fallback for ASCII remapped layouts, and falls back to event.code for non-English single-letter layouts: /Users/zbeyens/git/lexical/packages/lexical/src/LexicalUtils.ts:995-1023.event.code, and special keys: /Users/zbeyens/git/lexical/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts:187-284.ProseMirror:
prosemirror-keymap normalizes Mod-, Cmd-, Ctrl-, Alt-, and Shift- names: /Users/zbeyens/git/prosemirror-keymap/src/keymap.ts:8-26./Users/zbeyens/git/prosemirror-keymap/src/keymap.ts:28-45.KeyboardEvent.key naming via w3c-keyname, then has a keyCode fallback for modified character keys while avoiding Ctrl-Alt on Windows because of AltGr: /Users/zbeyens/git/prosemirror-keymap/src/keymap.ts:83-108.Mod- grammar discipline and precompiled map idea. Do not import prosemirror-keymap; that brings ProseMirror's Plugin, EditorView, and command protocol into a Slate runtime utility.Tiptap:
/Users/zbeyens/git/tiptap/packages/core/src/extensions/keymap.ts:68-99.keyboardShortcut command normalizes names and synthesizes a KeyboardEvent that flows through ProseMirror handleKeyDown: /Users/zbeyens/git/tiptap/packages/core/src/commands/keyboardShortcut.ts:5-89.Mod by inspecting platform and modifier booleans directly: /Users/zbeyens/git/tiptap/packages/extension-node-range/src/node-range.ts:119-135.Mod is first-class DX. Do not steal Tiptap's extension-level keymap design.Current is-hotkey:
is-hotkey uses aliases and key codes, parses shift?, and can return a compiled checker: /Users/zbeyens/git/slate-v2/node_modules/.bun/[email protected]/node_modules/is-hotkey/src/index.js:18-40 and /Users/zbeyens/git/slate-v2/node_modules/.bun/[email protected]/node_modules/is-hotkey/src/index.js:87-100.event.which unless byKey is used: /Users/zbeyens/git/slate-v2/node_modules/.bun/[email protected]/node_modules/is-hotkey/src/index.js:143-148 and /Users/zbeyens/git/slate-v2/node_modules/.bun/[email protected]/node_modules/is-hotkey/src/index.js:172-178.event.key first, with careful event.code fallback.Principles:
Top drivers:
is-hotkey has no release since 2020 and already caused local dependency corruption pain.Viable options:
Keep is-hotkey.
which-centric default path, direct example leakage, recurring install/build friction.Fork is-hotkey as a package.
Replace with prosemirror-keymap or Tiptap keymap.
Add w3c-keyname and write a Slate wrapper.
Vendor a Slate-owned matcher inspired by current is-hotkey, Lexical, and ProseMirror.
Consequences:
is-hotkey and @types/is-hotkey disappear from package manifests and lockfile.Hotkeys helpers or a Slate-provided generic matcher.Stable:
import { Hotkeys } from "slate-dom";
Keep the current Hotkeys.isBold, isItalic, isUndo, isRedo, movement, delete, split, and compose helpers.
Possible public helper only if examples need it:
import { isHotkey } from "slate-dom";
if (isHotkey("mod+s", event)) {
// Save.
}
Reject createHotkeyMatcher as public API. It exposes implementation mechanics and makes the common example read worse:
const isTabHotkey = createHotkeyMatcher("tab");
The final public helper should match the useful part of the old is-hotkey DX:
isHotkey("tab", event);
Reject public curried usage:
const isTabHotkey = isHotkey("tab");
if (isTabHotkey(event)) {
// Tab.
}
That shape is overbuilt for examples and makes a boolean-check helper look like a matcher factory. Slate can keep compiled matchers private for semantic Hotkeys and cache direct isHotkey(...) calls internally.
Lexical comparison: Lexical favors semantic event helpers and key commands (isTab(event), isBold(event), KEY_TAB_COMMAND) rather than public curried matcher APIs. Slate should keep its semantic Hotkeys object and add direct isHotkey(spec, event) only for unopinionated custom shortcut checks; it should not copy Lexical's command dispatch surface in this dependency-removal slice.
Do not expose a ProseMirror/Tiptap keymap object in this slice.
Add a private matcher, likely:
type HotkeySpec = string | readonly string[];
type HotkeyMatchOptions = {
platform?: "apple" | "windows" | "other";
};
type KeyboardEventLike = {
key: string;
code?: string;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
getModifierState?: (key: string) => boolean;
};
function isHotkey(spec: HotkeySpec, event: KeyboardEventLike): boolean;
function isHotkey(
spec: HotkeySpec,
options?: HotkeyMatchOptions,
event?: KeyboardEventLike,
): boolean {
const matcher = getCachedHotkeyMatcher(spec, options);
return matcher(event);
}
Matcher rules:
mod, cmd, command, meta, ctrl, control, alt, opt, option, shift;shift?, and generic optional modifier support only if tests prove current behavior needs more than shift;left/right/up/down, backspace, delete, enter, return, tab, escape, space, punctuation currently used by examples such as `;event.key case-insensitive next, event.code fallback only for single-letter expected keys when event.key is non-ASCII;hotkeys.ts or uses an injected platform option for unit tests;No React API changes. No hooks. No effects.
Examples should become simpler, not more clever:
is-hotkey;is-hotkey from the current resources list and point to Slate-owned helpers where applicable.Plate benefits if raw Slate owns the hotkey matcher because Plate plugins can depend on stable Slate key semantics instead of shipping or re-exporting is-hotkey.
Do not make Plate-specific shortcuts part of Slate. The migration backbone is a tested primitive, not Plate's product keymap.
No direct collaboration data-model impact.
Indirect impact: deterministic keyboard command detection matters before operations are generated. Removing is-hotkey must preserve undo/redo/delete/move command classification so collaborative operation streams do not change accidentally.
Unit tests in slate-dom:
mod+b maps to meta on Apple and ctrl off Apple.cmd+shift+z, ctrl+y, and ctrl+shift+z redo rows match current platform behavior.shift?+backspace and shift?+delete match with and without shift.mod+` works if a public generic matcher is exported for examples.mod+z matches an event with code: "KeyZ" and non-ASCII key, but does not treat Dvorak-style ASCII remapped event.key as the physical key.Integration tests in slate-react:
getEditableCommandFromKeyDown still returns history, insert-break, delete, and movement commands for the same rows.Example/browser proof:
Dependency proof:
rg "is-hotkey|@types/is-hotkey" /Users/zbeyens/git/slate-v2 only finds historical changelog notes or intentionally archived docs, if any.is-hotkey external warning.Keyboard shortcut behavior is not a 50k-block performance problem, but it is in the event hot path.
Proof rows:
bun check fast gate after implementation.| Lens | Applicability | Finding | Plan delta |
|---|---|---|---|
slate-ralplan | applied | This changes a browser-runtime dependency used by Slate's editing kernel. | Decision brief, proof matrix, objection ledger, and scorecard recorded. |
performance | applied | Repeated keydown matching is hot-path work. GitHub-scale lesson applies: make repeated units cheap before adding architecture. | Precompile specs; no per-event parsing; add micro-benchmark row. |
performance-oracle | applied | Complexity must be O(number of shortcut variants for that helper), with no document-size coupling. | Keep tiny compiled matcher and no React/render state. |
tdd | applied | This is behavior replacement under the same API. | Write parity tests before implementation. |
vercel-react-best-practices | applied | Bundle/runtime shape is in scope, but no React render behavior changes. | Remove stale dependency; no hooks/effects; no extra listeners. |
react-useeffect | skipped | No effect, subscription, or derived-state work. | No change. |
build-web-apps:shadcn | skipped | No UI component surface. | No change. |
Triggered because this touches keyboard, selection-adjacent browser runtime behavior.
Pre-mortem:
event.key.Proof response:
event.key first, event.code fallback only for non-ASCII single-letter layouts;slate-react command classification through the same Hotkeys helpers.Blast radius:
packages/slate-dompackages/slate-reactsite/examples/ts/*docs/general/resources.mdbun.lockRollback answer:
Hotkeys API and swap internals back while tests stay; no public user migration required.| Change | Objection | Steelman antithesis | Answer | Verdict |
|---|---|---|---|---|
Remove is-hotkey dependency | "Why touch old working keyboard code?" | Keyboard code is fragile and old deps can be okay if tiny. | This dep has no release since 2020, already caused local parse/install pain, leaks into examples, and uses which by default. Slate can own this tiny runtime with tests. | keep |
Do not fork is-hotkey package | "A fork is safer than rewriting." | A fork preserves behavior with less code churn. | Forking preserves dated semantics and creates package maintenance. The current grammar is tiny enough to reimplement with parity tests and Lexical's stronger layout behavior. | keep |
| Do not use ProseMirror/Tiptap keymap | "They already solved keymaps." | PM keymap is mature and maintained. | PM solves keymap plugins for PM views. Slate needs a matcher for its editing kernel, not a PM Plugin/EditorView protocol. | keep |
Expose direct-only isHotkey instead of createHotkeyMatcher | "This expands public API." | Examples need a replacement for direct is-hotkey usage. | The helper is justified, but public factory/curried shapes are not. isHotkey('tab', event) matches the useful old-library DX; private compiled matchers and an internal cache preserve performance. Lexical supports the naming direction by exposing semantic is* helpers, not public matcher factories. | keep |
| Add non-English fallback | "This may change behavior." | Any behavioral expansion can surprise someone. | Lexical has exact tests for this class. Current Slate changelog already says the hotkeys util was updated for non-Latin keyboards; this plan makes that contract real. | keep |
is-hotkey runtime dependency after implementation.@types/is-hotkey.keymap import.Add slate-dom unit tests that freeze the current Hotkeys behavior and the desired non-English/Dvorak/AltGr behavior.
Gate:
Hotkeys where possible and private helper only if necessary.Add packages/slate-dom/src/utils/hotkey-match.ts or equivalent.
Gate:
hotkeys.ts no longer imports is-hotkey;Remove direct is-hotkey imports from examples and remove the docs recommendation.
Gate:
Hotkeys, a Slate-owned generic matcher, or local explicit logic when clearer.Remove is-hotkey and @types/is-hotkey from root and package manifests; update lockfile.
Gate:
rg "is-hotkey|@types/is-hotkey" /Users/zbeyens/git/slate-v2 shows no active dependency/import/doc recommendation.Run:
bun test packages/slate-dom
bun test packages/slate-react
bun --filter slate-dom typecheck
bun --filter slate-react typecheck
bun --filter slate-dom build
bun --filter slate-react build
bun check
Adjust commands to the actual repo scripts when implementing; source-first package checks win over cargo-cult root builds.
Browser proof:
/examples/richtext: mark hotkeys work./examples/iframe: iframe hotkey behavior still works.Hotkeys public API unchanged; isHotkey is the only generic helper.is-hotkey dependency remains.is-hotkey disappears.| Dimension | Score | Evidence |
|---|---|---|
| React 19.2 runtime performance | 0.93 | No React work; hot path stays compiled and event-only. Evidence: current create() compiles once at /Users/zbeyens/git/slate-v2/packages/slate-dom/src/utils/hotkeys.ts:52-65; plan keeps that property. |
| Slate-close unopinionated DX | 0.94 | Keeps Hotkeys; no PM/Tiptap keymap API. Evidence: Hotkeys object at /Users/zbeyens/git/slate-v2/packages/slate-dom/src/utils/hotkeys.ts:72-97. |
| Plate and slate-yjs migration-backbone shape | 0.90 | No product keymap; deterministic command classification preserved. Evidence: editing kernel command mapping at /Users/zbeyens/git/slate-v2/packages/slate-react/src/editable/editing-kernel.ts:916-1003. |
| Regression-proof testing strategy | 0.94 | Explicit red tests for current behavior, non-English fallback, Dvorak, AltGr, examples, dependency sweep. |
| Research evidence completeness | 0.94 | Cites live Slate v2, npm metadata, prior local failure, Lexical, ProseMirror keymap, Tiptap, and is-hotkey source. |
| shadcn-style composability and minimalism | 0.92 | No UI/component expansion; possible generic matcher only if examples need it. |
Weighted total: 0.932.
Ready threshold is met for a narrow dependency decision. The implementation-time helper decision is finalized: expose isHotkey, reject public createHotkeyMatcher.
is-hotkey parse failure as evidence, not just package staleness.w3c-keyname as a fallback option, not the first choice.w3c-keyname handles browser key naming better with less code and no regressions, use it as a tiny dependency under the Slate-owned matcher.isHotkey; keep the compiled matcher private.event.key, non-ASCII letter layouts may fall back to event.code.| Pass | Status | Evidence added | Plan delta | Open issues | Next owner |
|---|---|---|---|---|---|
| Current-state and evidence | complete | Live Slate v2 usage, package deps, npm freshness, prior local failure, Lexical/ProseMirror/Tiptap/is-hotkey source. | Chose Slate-owned matcher. | Public helper finalized as isHotkey. | Implementation |
| Intent and decision brief | complete | Intent/boundary record and options matrix. | Reject keep/fork/PM/Tiptap. | none | Implementation |
| Risk and proof pass | complete | High-risk keyboard pre-mortem and regression matrix. | Added Dvorak/non-English/AltGr rows. | none | Implementation |
| Closure score | complete | Scorecard total 0.915 with no blocker for narrow dependency decision. | Ready for implementation. | none | Implementation |
Accepted decisions:
is-hotkey.Hotkeys stable.Mod grammar discipline.is-hotkey.Implementation starts with tests, not a dependency deletion.
active goal state may be set to done for this planning lane because no further autonomous plan review is needed; the next runnable work is implementation, which requires a go/execution turn.Status: in progress
Driver skill: ralph
Supporting skills: hard-cut, tdd
Actions:
active goal state as pending.active goal state for the implementation lane.Next action:
.tmp/slate-v2, then replace the matcher and remove the dependency.Status: complete
Driver skill: ralph
Supporting skills: hard-cut, tdd, ce-compound
Actions:
.tmp/slate-v2/packages/slate-dom/test/hotkeys.ts..tmp/slate-v2/packages/slate-dom/src/utils/hotkey-match.ts.Hotkeys to the Slate-owned matcher while preserving the public Hotkeys surface.is-hotkey to Slate-owned hotkey helpers.is-hotkey and @types/is-hotkey from active manifests and bun.lock.docs/solutions/developer-experience/2026-05-03-slate-hotkey-dependency-hard-cuts-need-owned-matchers-and-layout-contracts.md.Verification:
bun test ./packages/slate-dom/test/hotkeys.ts passed.bun test ./packages/slate-dom/test/hotkeys.ts ./packages/slate-react/test/editing-kernel-contract.ts passed.bun test ./packages/slate-dom/test/hotkeys.ts ./packages/slate-dom/test/bridge.ts ./packages/slate-dom/test/dom-coverage.ts ./packages/slate-dom/test/clipboard-boundary.ts passed.bun --filter slate-dom typecheck passed.bun --filter slate-react typecheck passed.bun --filter slate-dom build passed.bun --filter slate-react build passed.bun typecheck:site passed.bun lint:fix passed.bun check passed./examples/richtext and /examples/iframe shortcut handling.packages/slate-react/CHANGELOG.md mention remains.Completion decision:
active goal state may be set to done.Status: complete
Actions:
../is-hotkey/test/index.js after the dependency clone was available..tmp/slate-v2/packages/slate-dom/test/hotkeys.ts:
cmd, space, and +;mod;.tmp/slate-v2/packages/slate-dom/src/utils/hotkey-match.ts to support modifier-only keydown and clearer invalid modifier errors.Verification:
bun test ./packages/slate-dom/test/hotkeys.ts passed.bun test ./packages/slate-dom/test/hotkeys.ts ./packages/slate-react/test/editing-kernel-contract.ts passed.bun --filter slate-dom typecheck passed.bun lint:fix passed.Status: complete
Decision:
isHotkey, not createHotkeyMatcher.isHotkey('mod+s', event) is supported for direct checks.const isSaveHotkey = isHotkey('mod+s') is overkill.isHotkey('mod+s', { platform: 'apple' }, event).Hotkeys, and direct isHotkey(...) calls use an internal cache.Why:
is-hotkey library's best DX was the direct isHotkey(spec, event) call shape.is* utilities and key commands, not public curried matchers.Hotkeys for editor semantics and isHotkey for unopinionated custom shortcuts.Actions:
createHotkeyMatcher exports with isHotkey.isHotkey.isHotkey('tab', event) and platform-specific direct checks.Status: complete
Decision:
isHotkey is direct-only public API.Hotkeys.Actions:
isHotkey(spec) overload.isHotkey(spec, event) only.Status: complete
Decision:
isHotkey accepts a structural KeyboardEventLike, not only the DOM KeyboardEvent.event.nativeEvent.../is-hotkey behavior was reviewed again. Keep useful alias and optional-modifier behavior; continue rejecting curried checks, which/keyCode mode, byKey, parser exports, isCodeHotkey, and isKeyHotkey.Actions:
KeyboardEventLike from slate-dom.nativeEvent from active isHotkey example call sites.cmd+=, return, esc, spacebar, function keys, pageup, and optional mod?.