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
| Screen | Steward input | Notes |
|---|---|---|
| Sales (Ring-Up) | #sales-search | Wired when the Sales slice lands |
| Service kanban | (none) | Not single-search-driven |
| Customers | #customers-search | Registered on screen activate |
| Inventory | #inventory-search | Registered 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.
| Event | Source |
|---|---|
helm:modal-closed | Any *-modal element going display: none (detected by MutationObserver in focus-steward.js) |
helm:print-completed | Browser afterprint (wired once at module load) |
helm:payment-completed | Payment-flow subsystems (when Sales ships) |
helm:transaction-completed | Transaction-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"]withdisplayother thannone) - 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-idmismatch) - 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_STEWARDSmap insidepublic/index.htmlnearswitchScreen() - Tested manually against the 10-case suite documented in the Sales slice spec when the slice ships
See also
- In-situ editing — the other foundational UX principle
- Sales slice (slice 5) — where focus stewardship becomes an acceptance criterion
- Boring tech — and the vanilla-JS commitment that keeps this module dependency-free