diff --git a/CHANGELOG.md b/CHANGELOG.md
index 730a228..eee90f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,13 @@ workflow copies it into the GitHub release notes.
mid-drag started a pan underneath the active drag, shifting the view out
from under it. One gesture at a time now, and releases always reach the
editor.
+- Clicking any button or checkbox left keyboard focus stuck on that control,
+ browser-style: after toggling a layer checkbox, Space toggled the checkbox
+ again instead of panning, arrow keys walked the panel, and editor
+ shortcuts (tool switching, entity editor) went dead until you clicked the
+ canvas. Clicked controls now hand the keyboard straight back to the
+ editor. Text fields, dropdowns, and sliders still keep focus, and tabbing
+ to a control deliberately still works.
## [1.4.0] - 2026-07-28
diff --git a/src/App.tsx b/src/App.tsx
index 8e7709b..37fb964 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -21,6 +21,7 @@ import { PrefabPlaceTool } from './tools/prefabPlaceTool';
import type { PrefabData } from './prefab/prefabTypes';
import { Camera } from './rendering/camera';
import { useToolLifecycle } from './hooks/useToolLifecycle';
+import { useChromeFocusGuard } from './hooks/useChromeFocusGuard';
import { useShipMeta } from './hooks/useShipMeta';
import { EditorCanvas } from './components/EditorCanvas';
import { Toolbar } from './components/Toolbar';
@@ -238,6 +239,10 @@ export const App: React.FC = () => {
// (drag anchors, ghost previews, pickers, uncommitted strokes).
useToolLifecycle(activeTool);
+ // Clicked buttons/checkboxes release the keyboard back to the editor
+ // instead of keeping browser focus and eating shortcuts.
+ useChromeFocusGuard();
+
// What the fork intends the open file to be (#3): vessel / gameMap / POI /
// salvage badges in the menu bar. Reading the ref here is safe because
// forkDir only ever changes together with forkProvider, which is state.
diff --git a/src/hooks/__tests__/useChromeFocusGuard.test.tsx b/src/hooks/__tests__/useChromeFocusGuard.test.tsx
new file mode 100644
index 0000000..f35ffc1
--- /dev/null
+++ b/src/hooks/__tests__/useChromeFocusGuard.test.tsx
@@ -0,0 +1,127 @@
+import { describe, it, expect } from 'vitest';
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+import { useChromeFocusGuard, isClickActivatedChrome } from '../useChromeFocusGuard';
+
+/**
+ * The browser leaves keyboard focus on a clicked control; a desktop app does
+ * not. The guard blurs click-activated chrome after the click so shortcuts
+ * keep working (the field report: toggle a layer checkbox, then Space toggles
+ * the checkbox instead of panning and tool keybinds go dead).
+ *
+ * jsdom does not move focus on click the way a browser does, so each case
+ * focuses the control explicitly and then clicks: exactly the post-click
+ * state a real browser is left in.
+ */
+
+function Harness() {
+ useChromeFocusGuard();
+ return (
+
+
+
+
+
+
+ );
+}
+
+describe('useChromeFocusGuard', () => {
+ it('releases focus from a clicked checkbox', () => {
+ const { getByTestId } = render();
+ const checkbox = getByTestId('checkbox');
+ checkbox.focus();
+ fireEvent.click(checkbox);
+ expect(document.activeElement).not.toBe(checkbox);
+ });
+
+ it('releases focus from a clicked button', () => {
+ const { getByTestId } = render();
+ const button = getByTestId('button');
+ button.focus();
+ fireEvent.click(button);
+ expect(document.activeElement).not.toBe(button);
+ });
+
+ it('leaves text inputs focused: typing there is the point', () => {
+ const { getByTestId } = render();
+ const text = getByTestId('text');
+ text.focus();
+ fireEvent.click(text);
+ expect(document.activeElement).toBe(text);
+ });
+
+ it('leaves selects focused: blurring one closes its dropdown', () => {
+ const { getByTestId } = render();
+ const select = getByTestId('select');
+ select.focus();
+ fireEvent.click(select);
+ expect(document.activeElement).toBe(select);
+ });
+
+ it('lets a click handler that focuses something else win', () => {
+ // The search bar's clear button refocuses the search input during its
+ // click handler; the guard must not blur the input it moved focus to.
+ function Refocus() {
+ useChromeFocusGuard();
+ const inputRef = React.useRef(null);
+ return (
+
+
+
+
+ );
+ }
+ const { getByTestId } = render();
+ const clear = getByTestId('clear');
+ clear.focus();
+ fireEvent.click(clear);
+ expect(document.activeElement).toBe(getByTestId('search'));
+ });
+
+ it('stops listening after unmount', () => {
+ const { unmount } = render();
+ unmount();
+ // With the guard gone, a clicked control keeps focus again.
+ const straggler = document.createElement('button');
+ document.body.appendChild(straggler);
+ straggler.focus();
+ fireEvent.click(straggler);
+ expect(document.activeElement).toBe(straggler);
+ straggler.remove();
+ });
+});
+
+describe('isClickActivatedChrome', () => {
+ it.each([
+ ['checkbox', true],
+ ['radio', true],
+ ['button', true],
+ ['submit', true],
+ ['file', true],
+ ['text', false],
+ ['number', false],
+ ['search', false],
+ ['range', false],
+ ['color', false],
+ ])('input[type=%s] -> %s', (type, expected) => {
+ const input = document.createElement('input');
+ input.type = type as string;
+ expect(isClickActivatedChrome(input)).toBe(expected);
+ });
+
+ it('classifies buttons and links as chrome, selects and textareas not', () => {
+ expect(isClickActivatedChrome(document.createElement('button'))).toBe(true);
+ expect(isClickActivatedChrome(document.createElement('a'))).toBe(true);
+ expect(isClickActivatedChrome(document.createElement('select'))).toBe(false);
+ expect(isClickActivatedChrome(document.createElement('textarea'))).toBe(false);
+ expect(isClickActivatedChrome(null)).toBe(false);
+ });
+});
diff --git a/src/hooks/useChromeFocusGuard.ts b/src/hooks/useChromeFocusGuard.ts
new file mode 100644
index 0000000..42a9cf8
--- /dev/null
+++ b/src/hooks/useChromeFocusGuard.ts
@@ -0,0 +1,45 @@
+import { useEffect } from 'react';
+
+/**
+ * Desktop apps don't leave keyboard focus on a control after a mouse click:
+ * clicking a toolbar button or a panel checkbox acts once, and the keyboard
+ * still belongs to the document. The browser instead moves focus to the
+ * clicked control and leaves it there, so after toggling a layer checkbox,
+ * Space re-toggles it (instead of panning), Tab walks the panel, and every
+ * editor shortcut dies on the useKeyboard input guard.
+ *
+ * This guard restores the desktop rule app-wide: after a click lands on
+ * click-activated chrome (buttons, links, checkboxes, radios), focus is
+ * released back to the document. Controls where post-click keyboard input is
+ * the point (text fields, selects, sliders, color wells) keep focus, and
+ * keyboard-driven focus (tabbing to a control deliberately) is untouched
+ * because the guard only reacts to clicks.
+ */
+
+/** Input types that act on click and have no post-click keyboard role. */
+const CLICK_ACTIVATED_INPUT_TYPES = new Set(['checkbox', 'radio', 'button', 'submit', 'reset', 'file']);
+
+/** Exported for testing. */
+export function isClickActivatedChrome(el: Element | null): el is HTMLElement {
+ if (!el) return false;
+ const tag = el.tagName;
+ if (tag === 'BUTTON' || tag === 'A') return true;
+ if (tag === 'INPUT') return CLICK_ACTIVATED_INPUT_TYPES.has((el as HTMLInputElement).type);
+ return false;
+}
+
+export function useChromeFocusGuard(): void {
+ useEffect(() => {
+ // Document-level bubble listener: React's root-container handlers run
+ // first (the click does its job), then focus is released. A handler that
+ // deliberately focuses something else during the click (e.g. the search
+ // bar's clear button refocusing its text input) wins, because the guard
+ // checks what is focused NOW, not what was clicked.
+ const onClick = () => {
+ const el = document.activeElement;
+ if (isClickActivatedChrome(el)) el.blur();
+ };
+ document.addEventListener('click', onClick);
+ return () => document.removeEventListener('click', onClick);
+ }, []);
+}