Keyboard-First UIs: Config-Driven Navigation, Hotkeys, Accessibility by Construction
A full keyboard-driven Kanban board — arrow-key navigation across nested lists, hotkeys that postpone/close/assign, and screen-reader semantics that can't drift — built from one generic Stimulus controller configured by data-* attributes, with aria-selected as the cursor and the routing table holding the meaning of every key. The Rails lesson: the DOM attribute is the state, and config beats a fork-per-list.
You're building the Kanban board. Power users live in it all day, and the first thing they ask for is the thing every serious tool has and every weekend project skips: keyboard control. Arrow keys to move between cards. Arrow keys to move between columns. Hit [ to postpone the card you're on, ] to close it, m to assign it to yourself — without ever touching the mouse. And — this is the part that's easy to forget until an accessibility audit fails — a screen reader has to announce exactly the card a sighted user sees highlighted, never one out of sync.
That's a genuinely hard interaction. There's a cursor — "the currently-selected card" — that has to move with the arrow keys, survive a column change, survive a live multiplayer refresh morphing the board, and stay readable to assistive tech. Campfire has nothing like it; a chat timeline is one flat list you scroll. So for this whole tutorial Fizzy is the sole worked example, and the lesson isn't really about JavaScript at all. It's about where the state lives and who decides what each key means — and both answers turn out to be Rails-visible, sitting in your ERB and your routing table, not buried in a script.
Let's be honest about the version you'd build first.
The naive version: a cursor variable and a switch statement, per list
You don't know JS, so you reach for the shape you do know — and you'd build it the way you'd build any stateful widget you'd vibe-code: keep a "which one is selected" variable somewhere, and write a handler that bumps it on arrow keys. Mentally, in Rails terms, it looks like this — a little controller object per list, holding an index, with a big case on the key:
# The shape you'd reach for: one cursor-holder per list, a case-per-key,
# and a SEPARATE one for "the card list" vs "the column list" vs "the menu",
# because each needs slightly different rules.
class CardListCursor
def initialize(items)
@items = items
@selected = 0 # the cursor: an index into the array
end
def handle(key)
case key
when "ArrowDown" then @selected = [@selected + 1, @items.size - 1].min
when "ArrowUp" then @selected = [@selected - 1, 0].max
when "[" then postpone(@items[@selected]) # hard-coded: this list closes cards
when "]" then close(@items[@selected])
when "m" then assign(@items[@selected])
end
highlight(@items[@selected]) # ...and now manually re-paint the highlight
end
end
class ColumnListCursor # ...and a near-identical copy, but horizontal, and Enter
# ... descends into a card list instead of acting on an item
end
class MenuCursor # ...and another copy for the assignee dropdown, the tag picker...
Three things rot, and none of them throws an error. First, you fork per list. The cards navigate vertically; the columns navigate horizontally; the assignee dropdown is a third flavor. Each gets its own class because each has slightly different rules — and now "fix the arrow-key bug" means fixing it in three places, and the fourth list someone adds next month gets none of the fixes. Second, the meaning of each key is hard-coded into the cursor. [ means postpone, baked into the CardListCursor. The day you want the same hotkey on the "Not Now" tray but pointing at a different action, you fork again, or you grow a flag. Third — the one that fails the audit — your "highlight" is a CSS class you paint by hand, and the screen reader has no idea. A sighted user sees card #4 glowing; the screen reader is still announcing card #1, because "selected" lives in your @selected integer and a .highlighted class, and assistive tech reads neither. You now maintain two representations of "which card is selected" — the visual one and the semantic one — and they drift the first time you touch the code.
The naive shape is a fork-per-list, a switch-per-key with the verbs hard-coded in, and a selection state the screen reader can't see.
The mechanics, from first principles: the attribute is the state
Fizzy collapses all three problems with one move that sounds almost too small to matter: stop keeping "which item is selected" in a variable, and keep it in a DOM attribute instead — specifically the ARIA attribute that already means "selected."
There is exactly one navigation controller in the whole app — navigable_list_controller.js — and it is not subclassed for cards vs columns vs menus. It's configured, from the markup, by Stimulus values. The selection it manages isn't an index; it's an attribute it sets on the chosen element (navigable_list_controller.js:84-98):
async selectItem(item, skipFocus = false) {
await this.#selectCurrentElementInParent()
this.#clearSelection()
item.setAttribute(this.selectionAttributeValue, "true") // ← the cursor IS this attribute
this.currentItem = item
this.#refreshActiveDescendant()
await nextFrame()
if (this.autoScrollValue) { this.currentItem.scrollIntoView({ block: "nearest", inline: "nearest" }) }
if (this.hasNestedNavigationValue) { this.#activateNestedNavigableList() }
if (!skipFocus && this.focusOnSelectionValue) { this.currentItem.focus({ preventScroll: !this.autoScrollValue }) }
}
this.selectionAttributeValue defaults to the string "aria-selected" (navigable_list_controller.js:9). So "select this card" is "set aria-selected="true" on this card's element," and "deselect everything" is removing that attribute from every item (navigable_list_controller.js:133-137). There is no separate .highlighted class to keep in sync, and no @selected integer that can disagree with what's on screen — because the cursor is a real attribute on a real element, the styling and the screen-reader semantics read from the same fact.
The CSS draws the highlight off [aria-selected="true"]. The screen reader announces the element with aria-selected="true". They cannot drift, because there is no second copy — there's one attribute, and it's the cursor. That's the whole worldview of this tutorial in one line: the DOM attribute is the state. Selection isn't something the controller remembers and then reflects into the DOM; it's something the controller writes into the DOM, once, and everything else — the glow, the announcement, the scroll-into-view — derives from that single write.
"Why is putting it in an attribute better than a variable? A variable is simpler." Because a variable is a second source of truth that the screen and the screen reader both have to be kept in sync with — and "kept in sync by hand" is exactly the bug class that fails accessibility audits. The instant the cursor lives in
aria-selected, the visual highlight (CSS targeting that attribute) and the assistive-tech announcement (the attribute's literal job) come from the same place. You don't make them agree; they can't disagree. This is the same instinct as [[see P2: Derive, Don't Store]] — don't store what you can derive, and don't keep two copies of a fact — pointed at client state instead of a database column.
The 37signals way
One controller, configured by data — not a subclass per list
Look back at the cursor problem. The naive version forks: CardListCursor, ColumnListCursor, MenuCursor. Fizzy has one navigable-list controller, and it adapts to each list because each list declares its own rules as data attributes in the ERB. Here's the assignee dropdown configuring it (cards/assignments/new.html.erb:2-9):
<%= tag.div class: "max-width full-width", data: {
action: "turbo:before-cache@document->dialog#close dialog:show@document->navigable-list#reset keydown->navigable-list#navigate filter:changed->navigable-list#reset",
controller: "filter navigable-list assignment-limit",
dialog_target: "dialog",
navigable_list_focus_on_selection_value: false,
navigable_list_actionable_items_value: true,
assignment_limit_limit_value: Assignment::LIMIT,
assignment_limit_count_value: @card.assignments.count } do %>
And here's the board's columns container — the outer list — configured completely differently from the same one controller (boards/show/_columns.html.erb:1-26):
<%= tag.div class: "card-columns hide-scrollbar", data: {
controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list card-hotkeys",
navigable_list_supports_vertical_navigation_value: false,
navigable_list_has_nested_navigation_value: true,
navigable_list_prevent_handled_keys_value: true,
navigable_list_auto_select_value: false,
navigable_list_auto_scroll_value: false,
card_hotkeys_navigable_list_outlet: ".cards__transition-container",
action: "
keydown->navigable-list#navigate
keydown->card-hotkeys#handleKeydown
turbo:morph@document->card-hotkeys#handleMorphComplete
..." } do %>
Read the two side by side as configuration, not code. The columns container says supports_vertical_navigation: false — the columns are a horizontal row, so up/down does nothing here; left/right moves between columns. The assignee dropdown leaves vertical navigation on (it's a vertical menu) and turns focus_on_selection off (you don't want the page scrolling as you arrow through a dropdown). Same controller, opposite behavior, and the difference is entirely a few data-navigable-list-*-value attributes in the view. The controller exposes a whole vocabulary of these toggles — reverseOrder, actionableItems, supportsHorizontalNavigation, autoSelect, autoScroll, and more — each a Stimulus value with a default (navigable_list_controller.js:7-20).
This is config over forks, and it's the same instinct you've met in the model layer dressed in new clothes. A scope dispatched by a URL token, an enum generating a family of predicates — the pattern is always "express the variation as data the one generic mechanism reads," not "copy the mechanism and tweak it." Here the variation lives in data-* attributes in your ERB, which means a Rails developer configures a rich keyboard interaction without writing or reading a line of JavaScript — you set values in a view and the one controller obeys.
"If it's all one controller, how do the columns and the cards inside them both navigate? Those are two different lists." They're nested lists, and the controller handles that explicitly. The columns container is one
navigable-list; inside each column, the card list is anothernavigable-list(the inner one is built by thecolumn_taghelper — see below). When you select a column, the outer controller activates the nested card list's selection; and when a keystroke fires on the inner list, it relays back up to the parent. The nesting is declared in markup (has_nested_navigation_value: trueon the outer), and the controller walks the DOM to find the related list by reaching forgetControllerForElementAndIdentifier— for the parent (navigable_list_controller.js:114-120) and for the nested child by the same call (:155-161) — asking Stimulus "give me the navigable-list controller on this element." You don't wire the two lists together; you declare the outer one as nesting-capable and the framework connects them through the DOM tree.
Here's the nested inner list, and notice it's generated by a helper — the cards' vertical navigation is configured in Ruby, in columns_helper.rb, not hand-written per column (columns_helper.rb:33-45):
tag.section(id: id, class: classes, tabindex: "0", "aria-selected": selected, data: data, **properties) do
tag.div(class: "cards__transition-container", data: {
controller: "navigable-list css-variable-counter",
css_variable_counter_property_name_value: "--card-count",
navigable_list_supports_horizontal_navigation_value: "false", # cards go up/down, not left/right
navigable_list_prevent_handled_keys_value: "true",
navigable_list_auto_select_value: "false",
navigable_list_actionable_items_value: "true",
navigable_list_only_act_on_focused_items_value: "true",
card_hotkeys_disabled: hotkeys_disabled,
action: "keydown->navigable-list#navigate"
}, &block)
end
The outer container is horizontal-only; this inner one is vertical-only (supports_horizontal_navigation: "false"). Same controller, mirror-image config, and both configs are written in your view layer — one in ERB, one in a helper. The aria-selected on the outer <section> is the column's cursor; the cards inside get their own aria-selected from the inner list. The accessibility tree is correct by construction because selection is an ARIA attribute at every level of the nesting, not a class you remembered to add.
The contrast: the naive version forks a cursor class per list and hard-codes each list's axis and rules into its subclass, so four lists means four near-identical files that drift. Fizzy writes one navigable-list controller and varies it entirely through data-navigable-list-* values declared in the ERB and the column helper — vertical here, horizontal there, nesting-capable on the board, focus-quiet in a dropdown. The variation lives in the view, where a Rails developer can read and change it; the mechanism lives in one file nobody has to fork.
Hotkeys: the routing table decides what each key does
Now the hotkeys — [ to postpone, ] to close, m to assign-to-self. The naive version hard-coded [ means postpone into the cursor class. Fizzy does the opposite: the hotkeys controller knows nothing about what postponing or closing a card means. It is completely domain-agnostic. Here is its entire key table (card_hotkeys_controller.js:129-133):
#keyHandlers = {
"["(event) { this.#postponeCard(event) },
"]"(event) { this.#closeCard(event) },
m(event) { this.#assignToMe(event) }
}
And here's what #postponeCard actually does — watch where it gets the URL (card_hotkeys_controller.js:56-65):
async #postponeCard(event) {
const selection = this.#selectedCard
if (!selection) return
const url = selection.card.dataset.cardNotNowUrl // ← read the URL off the card itself
if (url) {
event.preventDefault()
await this.#performCardAction(url, selection)
}
}
The controller doesn't know the route for postponing a card. It reads data-card-not-now-url off the card element and POSTs to whatever's there. It's a dumb pipe: "the user pressed [ on this card; the card carries the URL for what [ means; POST to it." The meaning of [ isn't in the JavaScript — it's in the URL the server stamped onto the card. And that URL comes from your ERB (cards/display/_preview.html.erb:10-15):
<% if card.open? %>
<% card_data[:card_not_now_url] = card_not_now_path(card) %>
<% card_data[:card_closure_url] = card_closure_path(card) %>
<% card_data[:action] = "mouseenter->navigable-list#hoverSelect" %>
<% card_data[:card_assign_to_self_url] = card_self_assignment_path(card) %>
<% end %>
Three *_path helpers, stamped as data attributes onto the card. Press [, and the hotkey controller fires a POST at card_not_now_path(card). And what is that path? A REST resource — resource :not_now nested under the card (routes.rb:89). Postponing a card is creating a NotNow on it. Closing is creating a Closure (resource :closure, routes.rb:85). Assigning-to-self is creating a SelfAssignment (resource :self_assignment, only: :create, routes.rb:100). The hotkey doesn't call a "postpone" method; it creates a noun. This is the same url-carries-the-contract idea you met in [[see NEW-1: Drag-and-Drop the Rails Way]] — the routing table, not the client, decides what an action means — and it's exactly the same verb-as-noun discipline that owns its deep home in [[see P6: Model Every State Change as CRUD on a Noun]]. The keyboard is just a new way to trigger a create on a resource that already exists.
Notice what this buys: the same three keys would work, unchanged, on a completely different kind of card list — because the list doesn't carry the meaning, each card does. Stamp a different card-not-now-url onto a card and [ does something different, with zero change to the controller. The JS stays domain-agnostic precisely because the routing table holds the domain.
"How does pressing a key on the columns container know which card I mean? The keydown fires on the outer container, but the selected card is in a nested list." Through a Stimulus outlet — and this is the one piece of JS plumbing worth naming, because its configuration is, again, in your ERB. Look back at the columns container:
card_hotkeys_navigable_list_outlet: ".cards__transition-container". That line declares "the card-hotkeys controller can reach every navigable-list matching that CSS selector" — i.e. every column's inner card list. When a key fires, the hotkeys controller asks its outlets "which of you currently has focus?" and reads the selected card from that one (card_hotkeys_controller.js:44-54):
get #selectedCard() {
const focusedList = this.navigableListOutlets.find(list => list.hasFocus)
if (!focusedList) return null
const currentItem = focusedList.currentItem
if (currentItem?.classList.contains("card") && !this.#hotkeysDisabled(focusedList)) {
return { card: currentItem, controller: focusedList }
}
return null
}
The outlet is the wire from "a key was pressed at the board level" to "the one card the user is actually on." You don't write that wiring imperatively — you declare the outlet selector as a data attribute in the view, and the framework hands the hotkeys controller a live reference to the focused list.
There's a small accessibility-by-construction grace note hiding in that same method: !this.#hotkeysDisabled(focusedList). The "Done" and "Not Now" trays are card lists you can navigate but shouldn't be able to act on with [/] (you can't re-close a closed card). Rather than special-case those lists in the controller, the view declares it as data — card_hotkeys_disabled: true on the closed column (boards/show/_closed.html.erb:3) — and the controller reads the flag (card_hotkeys_controller.js:125-127). The capability is subtracted in the markup, where the person building the column can see it, not hidden in a conditional in the JS.
The contrast: the naive hotkey handler hard-codes [ → postpone(card) as a method call, so the keyboard layer knows the domain, and reusing those keys for a different action means forking the handler. Fizzy's hotkey controller knows only "read the URL off the card and POST to it"; the meaning of every key lives in a *_path helper in the ERB and a REST resource in routes.rb. The keyboard becomes one more client of the same routing table the mouse and the API already use — no /api/keyboard, no duplicated verbs, the contract is the URL.
Rehoming the cursor when the floor disappears
Here's the subtle one, and it's the natural consequence of everything above. You press [ on a card. The server creates a NotNow, the card leaves the open column, and — because the board uses live morph-refresh from [[see F4b: Turbo 8 — Morphing & Live Refresh]] — a morph arrives and removes the very row your cursor was sitting on. Where does the cursor go now? If you do nothing, selection points at a node that no longer exists; the next arrow key does nothing, and a keyboard user is stranded.
The naive instinct is to re-select "the first card" after any action — but that yanks the user back to the top of the list every time they postpone something, which is maddening when they're working through a column top to bottom. What you actually want is: land on the card that took the departed card's place — the next one down — or the previous one if you were on the last. That requires knowing the cursor's position before the morph and re-deriving a sane spot after it. And it requires waiting for the morph to actually finish, because you can't select the replacement row until the new DOM exists.
Here's the whole maneuver (card_hotkeys_controller.js:89-123):
async #performCardAction(url, selection) {
const { controller } = selection
const visibleItems = controller.visibleItems
const currentIndex = visibleItems.indexOf(selection.card)
const wasLastItem = currentIndex === visibleItems.length - 1
// Set up a promise that resolves when the board's morph completes
this.morphCompletePromise = new Promise(resolve => {
this.morphCompleteResolver = resolve
})
await post(url, { responseKind: "turbo-stream" })
// Wait for the morph, but never hang: 200ms fallback
await Promise.race([
this.morphCompletePromise,
new Promise(resolve => setTimeout(resolve, 200))
])
// Re-derive a sane cursor from the NEW list
const newVisibleItems = controller.visibleItems
if (newVisibleItems.length === 0) {
controller.clearSelection()
return
}
if (wasLastItem) {
controller.selectLast()
} else {
const nextIndex = Math.min(currentIndex, newVisibleItems.length - 1)
if (newVisibleItems[nextIndex]) {
await controller.selectItem(newVisibleItems[nextIndex])
}
}
}
Three judgments, all of them sane. It remembers the index before acting (currentIndex, wasLastItem) — that's the only state it carries across the round-trip, and it's read from the live list, not stored. It waits for the morph, but bounds the wait. The board, when a morph finishes, fires a turbo:morph event; the columns container wired that event to card-hotkeys#handleMorphComplete right in the markup you already saw (turbo:morph@document->card-hotkeys#handleMorphComplete, boards/show/_columns.html.erb:20), and the handler resolves the promise (card_hotkeys_controller.js:22-28). So the Promise.race normally wins on the real morph-complete signal — but if for any reason that signal never comes, the 200ms setTimeout resolves it anyway, so the cursor-rehoming can never hang the keyboard. Then it re-derives, off the fresh visibleItems: land on the same index (now occupied by the card that slid up), clamp to the last card if you were at the bottom, or clear selection entirely if the column is now empty.
Nothing here is stored. The cursor was always aria-selected in the DOM; the morph removed the old row; this code reads the new DOM and writes aria-selected onto the right replacement. It's the attribute-is-the-state worldview surviving a multiplayer refresh: the floor fell out, and the cursor calmly steps to where the floor now is.
"Why race a real event against a fixed timeout — isn't waiting for the actual
turbo:morphevent enough?" Because a UI that waits forever on an event that might not fire is worse than one that occasionally re-homes a few milliseconds early. The morph event is the right signal in the common case; the 200ms fallback is the seatbelt for the case where the action didn't trigger a morph, or the event was missed.Promise.racesays "whichever happens first" — the correct signal usually wins, and the timeout guarantees liveness. It's the same defensive instinct as a job thatretry_ona transient failure but won't loop forever: trust the happy path, bound the unhappy one.
The contrast: the naive version either re-selects the first card on every action (and infuriates anyone working down a list) or leaves the cursor pointing at a deleted node (and strands the keyboard entirely). Fizzy remembers only the index, waits for the morph with a bounded race, and re-derives the next sane cursor from the fresh DOM — so postponing a card lands you on the next one, closing the last card lands you on the new last, and emptying the column clears selection cleanly. The cursor is rehomed, never lost, and the only state that crossed the round-trip was one integer read from the live list.
The whole journey, on one screen
Trace one keystroke. A keyboard user has arrowed right through the columns (the outer horizontal navigable-list) into "In Progress," then down into its cards (the inner vertical navigable-list built by column_tag), landing on the third card — which now carries aria-selected="true", so the highlight glows and the screen reader announces it from the same attribute. They press [. The board-level card-hotkeys controller catches the keydown, asks its navigable-list outlets which one has focus, and reads the selected card from it. Off that card it reads data-card-not-now-url — a card_not_now_path(card) your ERB stamped there — and POSTs, which creates a NotNow resource (routes.rb:89). The card leaves the open column; the model broadcasts_refreshes; a morph arrives and removes the row. The columns container, wired in markup to relay turbo:morph to handleMorphComplete, resolves the waiting promise; the Promise.race returns; and the hotkeys controller re-derives the cursor onto the card that slid into the third slot — writing aria-selected="true" onto it. The user never left the keyboard, the screen reader never lost the thread, and not one line of JavaScript knew what "postpone" means.
Which patterns this serves
This tutorial is a frontend craft piece, but every load-bearing idea in it has its deep home in a principle you've already met:
- [[see P2: Derive, Don't Store]] — the whole "the DOM attribute IS the state" move: don't keep a
@selectedindex and a highlight class and an ARIA attribute; keep one, and let the visual and the semantic both derive from it. Cursor-rehoming is the same instinct surviving a morph — re-derive, don't restore. - [[see P6: Model Every State Change as CRUD on a Noun]] — why
[/]/mPOST tonot_now/closure/self_assignmentresources instead of calling "postpone"/"close" methods. The keyboard triggers acreateon a noun; the routing table holds the meaning. - [[see P4: Convention Is Leverage]] — config over forks: one navigable-list controller varied by
data-*values declared in the view, not a subclass per list. The variation is leverage precisely because it's declarative data a Rails developer reads in the ERB. - [[see NEW-1: Drag-and-Drop the Rails Way]] (the URL carries the contract) and [[see F4b: Turbo 8 — Morphing & Live Refresh]] (the morph that removes the focused row) — this tutorial is the keyboard's half of both: the same URLs the drag uses, reconciled by the same morph.
Key Takeaways — Patterns to Steal
When you build a selectable list, the reflex is a
@selectedindex plus a.highlightedCSS class you paint by hand — but that's two copies of "which item is selected," and the screen reader reads neither, so the visual highlight and the assistive-tech announcement drift the first time you touch the code. Make the cursor a single ARIA attribute instead. Fizzy's one navigable-list controller selects by settingaria-selected="true"on the chosen element and clearing it elsewhere (navigable_list_controller.js:88,133-137, withselectionAttributedefaulting to"aria-selected"at:9); the CSS highlight targets that attribute and the screen reader reads it, so they can't disagree — there's only one fact.The instinct for "cards navigate vertically, columns horizontally, the dropdown is different" is a cursor class per list, each with its rules hard-coded — and four lists become four drifting files. Write one generic controller and vary it with declared data. Fizzy configures the same navigable-list per list entirely through
data-navigable-list-*values in the view: vertical-off for the horizontal columns container (boards/show/_columns.html.erb:10), horizontal-off for the inner card list built in a helper (columns_helper.rb:37), focus-quiet for a dropdown (cards/assignments/new.html.erb:6). The variation lives in the ERB where a Rails developer reads it; the mechanism is one unforked file.Nested lists (cards inside columns) tempt you to wire the two together imperatively. Don't — declare the outer list as nesting-capable and let the controller walk the DOM to find its child or parent. Fizzy sets
has_nested_navigation_value: trueon the columns container and reaches the related list withgetControllerForElementAndIdentifier— the parent at (navigable_list_controller.js:114-120), the nested child by the same call at (:155-161) — activating the inner list when its column is selected and relaying inner keystrokes back up to the parent. You declare the nesting in markup; the framework connects the levels through the tree.A hotkey handler that calls
postpone(card)directly bakes the domain into the keyboard layer, so reusing those keys elsewhere means forking the handler. Keep the key table dumb and read the action's URL off the element. Fizzy's card-hotkeys controller maps[/]/mto "readdata-card-not-now-url(etc.) off the focused card and POST to it" (card_hotkeys_controller.js:60,129-133); the URLs arecard_not_now_path/card_closure_path/card_self_assignment_pathstamped in the ERB (cards/display/_preview.html.erb:11-14), each pointing at a REST resource (routes.rb:85,89,100). The keyboard becomes one more client of the routing table — the meaning is the URL, not the JS.Reaching the selected card from a board-level keydown tempts you to query the DOM by hand. Declare the relationship as a Stimulus outlet in the view instead. Fizzy's columns container declares
card_hotkeys_navigable_list_outlet: ".cards__transition-container"(boards/show/_columns.html.erb:15), and the hotkeys controller asks its outlets which onehasFocusand readscurrentItemfrom it (card_hotkeys_controller.js:44-54). The wire from "a key fired" to "the card you're on" is a declared selector, not imperative plumbing.To exempt a list from an action — you can navigate the "Done" tray but shouldn't re-close a closed card — the temptation is a special case inside the controller. Subtract the capability in the markup instead. Fizzy stamps
card_hotkeys_disabled: trueon the closed column (boards/show/_closed.html.erb:3) and the controller simply reads the flag (card_hotkeys_controller.js:125-127), so the exemption sits where the person building the column can see it, not buried in a JS conditional.When a live morph removes the focused row, the easy fixes are both bad: re-select the first card (and yank the user to the top on every action) or leave the cursor on a deleted node (and strand the keyboard). Remember only the index, wait for the morph with a bounded race, and re-derive the next sane cursor from the fresh DOM. Fizzy records
currentIndex/wasLastItembefore acting, races the realturbo:morphsignal (wired in markup atboards/show/_columns.html.erb:20, resolved atcard_hotkeys_controller.js:22-28) against a 200ms fallback so it never hangs, then lands on the same index, clamps to the last card, or clears selection if the column emptied (card_hotkeys_controller.js:89-123). The cursor is rehomed, never lost — and the only state that crossed the round-trip was one integer read from the live list.