Skip to main content

Focus stewardship

The principle

Every search-driven screen in Helm has a single steward input — the input that should hold focus by default. After modals close, after print dialogs dismiss, after payments complete, after any guest interaction ends, focus returns to the steward, and any existing text in it is selected.

The steward is the safe sink for keyboard input — most importantly, barcode scans.

Why this matters

A barcode scanner is a USB wedge: it types the barcode at >>50ms/key and ends with Enter. If the focused element is anything other than the intended search box, the scan corrupts that field. A misrouted scan into the cart quantity field, into a customer name, into a notes textarea, into the till — any of these can produce silent record corruption that the cashier may not catch until reconciliation.

A misplaced scan is worse than a missed scan. The principle exists to make that class of error impossible by construction, not by cashier discipline.

Stewards on each screen

ScreenSteward inputNotes
Sales (Ring-Up)#sales-searchWired when the Sales slice lands
Service kanban(none)Not single-search-driven
Customers#customers-searchRegistered on screen activate
Inventory#inventory-searchRegistered on screen activate
Today / Reports / Settings(none)Different interaction model

The Service drop-off form has its own customer search inside a modal; that input is managed by the modal itself, not by the document-wide steward.

Events that trigger refocus

The focus steward listens for completion events dispatched by other subsystems. Coupling is one-way — focus-steward never reaches back.

EventSource
helm:modal-closedAny *-modal element going display: none (detected by MutationObserver in focus-steward.js)
helm:print-completedBrowser afterprint (wired once at module load)
helm:payment-completedPayment-flow subsystems (when Sales ships)
helm:transaction-completedTransaction-post subsystems (when Sales ships)
(idle timer)After idleMs (default 5000) of no keyboard input
(blur watcher)Steward loses focus and nothing else legitimately claims it within 250ms

Toasts deliberately do not trigger refocus. Toasts don't claim focus, so returning focus to the steward after a toast appears would be a no-op for any case where focus is already correct, and a focus-steal for any case where the user has deliberately put focus elsewhere.

Conditions that suppress refocus

shouldSkipRefocus() returns true — and the steward keeps its hands off — when any of these are true:

  • A modal is open (any [id$="-modal"] with display other than none)
  • An <input>, <textarea>, <select>, or contenteditable element other than the steward has focus
  • The steward's screen is no longer the active screen (data-screen-id mismatch)
  • The steward input is disabled or hidden (offsetParent === null)

The principle: if anything else has legitimately claimed focus, the user put it there and meant it. Don't steal it.

The scanner safety net

Even with the steward registered, focus can drift between scans. The document-wide scanner interceptor in focus-steward.js watches for fast-keystroke sequences ending in Enter (≥5 chars, ≤50ms between keys), and routes them to the steward regardless of where focus had drifted to.

To protect against false positives, the interceptor only fires when the heuristic isUserTypingManually() returns false — i.e., when the focused input's value does not end with the buffered keys (meaning the operator wasn't typing those keys into the focused input themselves).

The threshold (SCAN_THRESHOLD_MS = 50) is comfortably faster than human typing and comfortably slower than every USB wedge scanner we've measured.

The refocus operation

function refocus() {
if (shouldSkipRefocus()) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (shouldSkipRefocus()) return; // re-check after DOM settled
currentSteward.inputElement.focus();
if (currentSteward.selectOnFocus) currentSteward.inputElement.select();
});
});
}

The double requestAnimationFrame lets in-flight DOM updates settle before focus is moved — this matters because modal-close handlers often re-render significant chunks of the surrounding UI, and grabbing focus mid-render can race with the close animation.

Registration

Screens register and unregister their steward through the public API (attached to window):

window.helmRegisterFocusSteward({
inputElement: HTMLInputElement,
screenId: string, // matches data-screen-id on the screen
idleMs: 5000,
selectOnFocus: true,
scannerInterceptEnabled: true, // opt-out at the steward level if ever needed
});

window.helmRequestFocusReturn(reason); // explicit refocus
window.helmUnregisterFocusSteward(screenId);

The module maintains exactly one steward at a time — the steward for the currently-active screen. Routing on screen change is done by a small map in public/index.html (FOCUS_STEWARDS) that's consulted on every switchScreen() call. Screens that don't appear in the map (Today, Reports, Settings) have no steward, and the module silently does nothing.

What NOT to do

  • Do not implement focus stewardship as a per-screen ad-hoc behavior. The pattern is general; the code lives in one place; each screen registers itself by adding an entry to the routing map.
  • Do not auto-focus the steward when a modal is open. The skip-condition exists for a reason. Modals take precedence.
  • Do not steal focus from any input the user is actively typing into. The scanner interceptor's isUserTypingManually() heuristic protects this.
  • Do not use a focus-trap library or any third-party dependency. The behavior is simple and the implementation is under 200 lines of vanilla JS. Stay with vanilla per ADR-0004.
  • Do not extend focus stewardship to non-search-driven screens (Today, Reports, Settings sub-pages). Those screens don't have a single steward; their interaction model is different.
  • Do not add a UI toggle for the focus-stewardship behavior. It is always on. Cashiers cannot turn it off because cashiers will not remember to turn it back on, and one transaction with it off is one transaction at risk of corrupted records.

Code reference

  • Module: public/js/focus-steward.js
  • Screen routing: FOCUS_STEWARDS map inside public/index.html near switchScreen()
  • Tested manually against the 10-case suite documented in the Sales slice spec when the slice ships

See also