Context
Leaf of #18762, split out of #18861 with its author's agreement (@neo-opus-grace, 2026-09-18): #18861 / PR #18878 deliver Tab and Shift+Tab for a MOUNTED editor, in the App Worker only. This is D#17832's second row of the same terminal table, and it needs a main-thread contract the mounted path does not.
unowned-rationale: split for scope, not claimed — I am finishing #18861 and #18871. Claimable.
The Problem
An edit is suspended when its cell leaves the row pool or the mounted column window: the session and its draft live on in the App Worker, no editor is in the DOM, and focus is on grid.View (measured for #18843: after a sort moved the record out of the pool, document.activeElement is div.neo-grid-view).
main.DomEvents#onKeyDown cancels Tab's default only for an INPUT whose event path contains neo-grid-editor. While suspended there is no such input, so Tab is the browser's own: focus leaves the grid, and the session is neither committed nor ended.
D#17832's terminal table (OQ5) asks for: "before suspension the plugin pre-registers synchronous Main-thread suppression for the focused grid.View; the prevented event commits if valid, advances generation and target, and routes through grid.View; an invalid draft reprojects and blocks the move."
The Architectural Reality
src/main/DomEvents.mjs onKeyDown: the editor branch is isInput && event.key === 'Tab' && testPathInclusion(event, ['neo-grid-editor', 'neo-table-editor'], true). The declarative registry next to it, disabledInputKeys (registerDisabledInputChars / unregister…), is gated on isInput too — D#17832 constraint 11 names both options: generalise its registered-id branch to a non-input target, or add a sibling registry.
- An App Worker key handler cannot call
preventDefault() after dispatch: the suppression has to be registered BEFORE the key arrives, which is why the plugin registers it when the embodiment goes and removes it when the session ends or is embodied again.
grid.plugin.CellEditing#onTabKey (PR #18878) is the terminal; today it returns early unless isEditorEvent(data). The suspended path reaches it with the View as target.
isEmbodied(session) already answers "suspended" from the App Worker's own projection state.
The Fix
A main-thread registration that cancels Tab's default for a registered non-input node id. The plugin registers grid.View for as long as a SESSION is open, and unregisters it when the session ends — commit, cancel, disable, destroy. onTabKey accepts the View as the key's source and runs the same terminal as the mounted path: commit if valid, then edit the adjacent editable cell, born suspended when its Row is not rendered; an invalid draft scrolls its cell back into sight instead.
Revised while building it (2026-09-18): the first draft registered only while suspended. There is no event for "the embodiment went", and the narrower window buys nothing: a session that is embodied but not focused — the state every reprojection leaves, measured for #18843 — has the same problem, a Tab on the View would walk away from an open draft. One rule covers both: while a session is open, the View's Tab belongs to the edit.
Acceptance Criteria
Out of Scope
The mounted path (#18861) · the IME probe and S3 opt-out (#18865) · reprojection focus (#18863).
Avoided Traps
- Cancelling Tab for every
grid.View: a keyboard trap — a grid nobody edits must let Tab through.
- An App Worker handler "preventing" the default after the fact: the key has already acted by the time the worker sees it.
Related
#18762 · #18861 / PR #18878 · D#17832 (terminal table, OQ5, constraint 11) · #18843 (where the suspended focus was measured).
Live latest-open sweep: latest 12 open issues at 2026-09-18T12:57Z — no equivalent; #18861 is the ticket this splits from. A2A sweep at 12:54Z: the split was agreed by its author (message e04e5ec2).
Origin Session ID: 323cb989-06a2-4b3f-8d70-912d67796c11
Retrieval Hint: "grid cell editing suspended session Tab leaves grid main thread suppression disabledInputKeys non-input registry"
Context
Leaf of #18762, split out of #18861 with its author's agreement (@neo-opus-grace, 2026-09-18): #18861 / PR #18878 deliver Tab and Shift+Tab for a MOUNTED editor, in the App Worker only. This is D#17832's second row of the same terminal table, and it needs a main-thread contract the mounted path does not.
unowned-rationale: split for scope, not claimed — I am finishing #18861 and #18871. Claimable.
The Problem
An edit is suspended when its cell leaves the row pool or the mounted column window: the session and its draft live on in the App Worker, no editor is in the DOM, and focus is on
grid.View(measured for #18843: after a sort moved the record out of the pool,document.activeElementisdiv.neo-grid-view).main.DomEvents#onKeyDowncancels Tab's default only for an INPUT whose event path containsneo-grid-editor. While suspended there is no such input, so Tab is the browser's own: focus leaves the grid, and the session is neither committed nor ended.D#17832's terminal table (OQ5) asks for: "before suspension the plugin pre-registers synchronous Main-thread suppression for the focused
grid.View; the prevented event commits if valid, advances generation and target, and routes throughgrid.View; an invalid draft reprojects and blocks the move."The Architectural Reality
src/main/DomEvents.mjsonKeyDown: the editor branch isisInput && event.key === 'Tab' && testPathInclusion(event, ['neo-grid-editor', 'neo-table-editor'], true). The declarative registry next to it,disabledInputKeys(registerDisabledInputChars/unregister…), is gated onisInputtoo — D#17832 constraint 11 names both options: generalise its registered-id branch to a non-input target, or add a sibling registry.preventDefault()after dispatch: the suppression has to be registered BEFORE the key arrives, which is why the plugin registers it when the embodiment goes and removes it when the session ends or is embodied again.grid.plugin.CellEditing#onTabKey(PR #18878) is the terminal; today it returns early unlessisEditorEvent(data). The suspended path reaches it with the View as target.isEmbodied(session)already answers "suspended" from the App Worker's own projection state.The Fix
A main-thread registration that cancels Tab's default for a registered non-input node id. The plugin registers
grid.Viewfor as long as a SESSION is open, and unregisters it when the session ends — commit, cancel, disable, destroy.onTabKeyaccepts the View as the key's source and runs the same terminal as the mounted path: commit if valid, then edit the adjacent editable cell, born suspended when its Row is not rendered; an invalid draft scrolls its cell back into sight instead.Revised while building it (2026-09-18): the first draft registered only while suspended. There is no event for "the embodiment went", and the narrower window buys nothing: a session that is embodied but not focused — the state every reprojection leaves, measured for #18843 — has the same problem, a Tab on the View would walk away from an open draft. One rule covers both: while a session is open, the View's Tab belongs to the edit.
Acceptance Criteria
devfirst.grid.Viewstays the browser's own — before any edit, and after one ended: the registration never outlives its session (arm: focus moves off the View).Out of Scope
The mounted path (#18861) · the IME probe and S3 opt-out (#18865) · reprojection focus (#18863).
Avoided Traps
grid.View: a keyboard trap — a grid nobody edits must let Tab through.Related
#18762 · #18861 / PR #18878 · D#17832 (terminal table, OQ5, constraint 11) · #18843 (where the suspended focus was measured).
Live latest-open sweep: latest 12 open issues at 2026-09-18T12:57Z — no equivalent; #18861 is the ticket this splits from. A2A sweep at 12:54Z: the split was agreed by its author (message e04e5ec2).
Origin Session ID: 323cb989-06a2-4b3f-8d70-912d67796c11
Retrieval Hint: "grid cell editing suspended session Tab leaves grid main thread suppression disabledInputKeys non-input registry"