git.lucas.co / hou-control
SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git

commit1451bf8ff1cb5a48a1bce87b784e5ba140b6a041
parent5ed7cba849
authorLucas Galante <[email protected]>
date2026-09-10 15:21
hc: declare settings, centralise per-tab state, generate the command map

Five structural changes, each closing a class of bug rather than an instance.

Settings are declared once in hcschema.py -- kind, default, label, range --
and both HCSettings.DEFAULTS and every HCSettingsPanel control are generated
from that. The keycam section, the largest in hc_settings.json, previously had
no defaults at all, so the deep merge added last commit did not cover it and
keycam.py and hcguides.py still walked unguarded
prefs().get('keycam').get('units') chains that raise AttributeError on None the
moment the section is absent. Both now go through HCSettings.section(), which
always returns a dict.

Declaring the kind also fixes a widget bug: the panel used to guess from the
current value, and `value in (0, 1)` meant checkbox, so delta_ow and delta_z --
step magnitudes that happen to equal 1 -- rendered as toggles. They are floats
now. BOOL_HINT_KEYS and SLIDER_RANGES are gone; they existed only to recover
type information a schema carries. All 28 settings were round-tripped through
the panel offscreen: values and JSON types preserved, apart from that
deliberate int -> float on the two nothing reads yet.

Per-pane and per-network state moves into hcstate.py. Seven module-level dicts
spread over five files had drifted into four incompatible key schemes:
(pane.id(), pwd().path()) in one, pane.id() in two, editor.name() in a fourth.
They disagreed. The hcnetcursor is tracked per network within a pane, but the
selection signature compared against it was tracked per pane, so descending
into a subnetwork compared the child's selection against the parent's. That
store is now NETWORK-scoped like the cursor it feeds. Nothing evicted entries
for closed panes either, and Houdini reuses pane ids, so a reopened pane could
inherit a stale cursor position; stores are now swept on a rate limit.

Command labels move onto the methods as @command("Label"). A command used to be
declared twice -- the method, and a hand-written entry in HCMaps -- with
nothing checking they agreed, and SelectionDialog swallowed the AttributeError
when they did not, so the command silently did nothing. hcmaps.py drops from 97
lines of hand-maintained dicts to 23 lines of generation, and hcPanel's
four-way branch to one call. The decorators were applied mechanically from the
parsed originals and the result diffed against them: the panel offers exactly
the same commands per tab type as before (56 network editor, 63 scene viewer,
46 parameter and details, 44 elsewhere). Toggle Path and Toggle Pin live on
HCTab but were only ever offered on path tabs, so @command grew a `tabs=`
scope to keep that true rather than let them widen.

Tab chrome becomes polymorphic. isVisibleMenus and toggleMenus were if/elif
ladders over tab.type() calling a different set of methods per branch; each tab
class now answers isChromeVisible() / showChrome(visible) and HCSession just
iterates. hou.PaneTab carries hasNetworkControls, so the base implementation is
safe for the tab types the ladder skipped entirely.

tools/check.py goes from 14 checks to 24, covering schema coverage, the
keycam-absent case, declared widget kinds, per-tab command binding, scope
isolation, duplicate labels, store eviction, network-key separation, and that
chrome overrides chain to super(). CLAUDE.md's architecture and conventions
sections described the hand-written HCMaps and advised branching on .type();
both are rewritten.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md                            |  14 +-
 python3.13libs/hc/__init__.py        |   2 +
 python3.13libs/hc/hccommands.py      |  89 ++++++++++
 python3.13libs/hc/hcguides.py        |   2 +-
 python3.13libs/hc/hcleader.py        |  11 +-
 python3.13libs/hc/hcmaps.py          | 112 +++---------
 python3.13libs/hc/hcnetworkeditor.py |  64 ++++---
 python3.13libs/hc/hcpane.py          |  11 ++
 python3.13libs/hc/hcparametertab.py  |  17 +-
 python3.13libs/hc/hcsceneviewer.py   |  34 ++++
 python3.13libs/hc/hcschema.py        | 131 ++++++++++++++
 python3.13libs/hc/hcsession.py       | 105 +++++-------
 python3.13libs/hc/hcsettings.py      | 322 +++++++++++++++--------------------
 python3.13libs/hc/hcstate.py         | 177 +++++++++++++++++++
 python3.13libs/hc/hctab.py           |  21 +++
 python3.13libs/nodegraphhooks.py     |  74 ++++----
 tools/check.py                       | 173 +++++++++++++++++--
 viewer_states/keycam.py              |   8 +-
 18 files changed, 925 insertions(+), 442 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7561e9a..51c357a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,10 +43,15 @@ Key hierarchy:
 - **`HCPane`** wraps `hou.Pane`; knows how to split, resize, convert its current `hou.PaneTab` into the right `HC*` subclass via `HCPane.convertTab`.
 - **`HCTab`** → **`HCPathTab`** → **`HCNetworkEditor`** / **`HCSceneViewer`**. `convertTab` dispatches on `hou.paneTabType` to the correct subclass; `HCPathTab` is used for Parm and DetailsView tabs. Tab type is also identified via a string `.type()` method (e.g. `'HCNetworkEditor'`) used in `isinstance`-style branching throughout `HCSession` and `HCMaps`.
 - **`HCBindings`** — loads hotkey assignments by reading `hc_hotkeys.json` and calling `hou.hotkeys.addAssignment`. Before each assignment, `hou.hotkeys.findConflicts` identifies any ancestor/descendant bindings using the same key and clears them. The JSON keys are Houdini symbol paths like `h.pane.gview.foo`; the context is derived via `symbol.rpartition('.')[0]`.
-- **`HCMaps`** (`hcmaps.py`) — central registry mapping human-readable command names → bound methods on session/pane/tab. Composed at runtime by `HCSession.hcPanel()` based on the current tab's type (`tab_map_base` always, plus one of `tab_map_path` / `tab_map_network_editor` / `tab_map_scene_viewer`). Used to populate the HC Panel selection dialog.
+- **`hccommands.py`** — the `@command("Label")` decorator and the registry that reads it. A command's panel label lives on the method itself. `tabs=(...)` restricts a command to certain tab `.type()` strings, needed for the few commands defined on `HCTab` that only make sense on some tabs.
+- **`HCMaps`** (`hcmaps.py`) — generated, not hand-written. `commands(session, pane, tab)` binds every `@command` reachable from those three objects, filtered by tab type. `HCSession.hcPanel()` calls it and hands the result to `SelectionDialog`.
+- **`hcstate.py`** — all per-pane and per-network state, in one place with one key scheme. Wrappers are stateless (Houdini hands out fresh SWIG wrappers per callback and holding one is a crash), so anything persisting between events goes in a `Store` here. Two scopes: `hcstate.PANE` and `hcstate.NETWORK` (`(pane id, network path)`). Entries for closed panes are swept periodically — pane ids get reused.
+- **`hcschema.py`** — one `Setting` per configurable value: kind, default, label, range. `HCSettings.DEFAULTS` and every control in `HCSettingsPanel` are generated from it.
 - **`HCWidgets`** (`hcwidgets.py`) — PySide6 widgets, notably `SelectionDialog` used for the HC Panel.
 
-When adding a new command: implement it on the appropriate wrapper (`HCSession`/`HCPane`/`HC*Tab`), then register it in the matching `HCMaps.tab_map_*` so it appears in the HC Panel. If it should be hotkey-bound, add a Houdini symbol entry to `hc_hotkeys.json`.
+When adding a new command: implement it on the appropriate wrapper (`HCSession`/`HCPane`/`HC*Tab`) and decorate it with `@command("Label")`. That is the whole registration — there is no separate map to update. If it should be hotkey-bound, add a Houdini symbol entry to `hc_hotkeys.json`; menus still need their own `scriptItem` in the XML.
+
+When adding a new setting: add a `Setting` to `hcschema.SCHEMA`. Defaults, the settings panel control, and the widget type all follow from it.
 
 ### Non-package Python (`python3.13libs/`)
 
@@ -59,7 +64,7 @@ These are recognized by Houdini's startup/event system by filename convention:
 - `123.py` / `456.py` are Houdini's magic filenames: `123.py` runs when Houdini starts without a `.hip`, `456.py` runs after any `.hip` load. Currently used for "open last file" tracking via `$HOUDINI_USER_PREF_DIR/st_data/state.json`.
 - `OnCreated.py` — node OnCreated event script.
 - `hc_hotkeys.json` (at repo root) — source of truth for keybindings, loaded by `HCBindings`. Conflicts with existing bindings are detected and cleared automatically.
-- `hc_settings.json` (at repo root) — runtime settings for the `keycam` viewer state and node graph defaults, read through `HCSettings`, which overlays the file on `HCSettings.DEFAULTS` so a missing key never reaches a caller.
+- `hc_settings.json` (at repo root) — runtime settings for the `keycam` viewer state and node graph defaults, read through `HCSettings`, which overlays the file on `HCSettings.DEFAULTS` (generated from `hcschema.SCHEMA`) so a missing key never reaches a caller. Use `settings.section("keycam", "units")` rather than chained `.get()` — the chained form returns `None` and raises on the next `.get`.
 
 ### Viewer states (`viewer_states/`)
 
@@ -85,7 +90,8 @@ These are reloaded via `HCSession.reloadColorSchemes()` (`hou.ui.reloadColorSche
 ## Conventions
 
 - Wrappers never subclass `hou.*` types; they store the hou object on `self.hou_tab` / `self.hou_pane` and delegate.
-- `.type()` methods return string discriminators (`'HCNetworkEditor'`, `'HCSceneViewer'`, `'HCParameterTab'`, `'HCPathTab'`) — this is the idiomatic way to branch on tab kind in this codebase, not `isinstance`. When branching, remember that `'HCParameterTab'` is a sibling of `'HCPathTab'` (Parm tabs return the former, DetailsView returns the latter) — code that wants both should check `tab.type() in ('HCPathTab', 'HCParameterTab')`.
+- `.type()` methods return string discriminators (`'HCNetworkEditor'`, `'HCSceneViewer'`, `'HCParameterTab'`, `'HCPathTab'`). Use them for *filtering* — which tabs a command applies to, which tabs to collect — not for dispatching behaviour. When behaviour differs by tab kind, put a method on each tab class and let the caller iterate: `isChromeVisible()` / `showChrome(visible)` are the worked example, and they replaced two if/elif ladders in `HCSession`. When you do filter, remember `'HCParameterTab'` is a sibling of `'HCPathTab'` (Parm tabs return the former, DetailsView the latter) — code wanting both checks `tab.type() in ('HCPathTab', 'HCParameterTab')`.
+- Per-pane or per-network state belongs in an `hcstate.Store`, never a bare module dict. Bare dicts drifted into four incompatible key schemes and never evicted closed panes.
 - Toggle-style methods often use a small string map (e.g. `{'0': '1', '1': '0'}`) because Houdini prefs are stored as strings.
 - Paths to bundled files are built from `hou.getenv("HC_PATH")` — don't hardcode absolute paths.
 - **`HCWidgets.SelectionDialog.execute()` surfaces command errors**: it wraps the callable from `self.list_dict` in a try/except that prints a traceback and writes the exception to the status bar. A HC Panel command that fails now says so — check the console. (Before this it swallowed everything and the user just saw "nothing happens.")
diff --git a/python3.13libs/hc/__init__.py b/python3.13libs/hc/__init__.py
index 3cf1108..1cbac91 100755
--- a/python3.13libs/hc/__init__.py
+++ b/python3.13libs/hc/__init__.py
@@ -20,6 +20,8 @@ from .hctab           import HCTab
 from .hcviewport      import HCViewport
 from .hcwidgets       import HCWidgets
 
+from . import hcschema, hcstate  # modules, imported for `from hc import hcstate`
+
 __version__ = "1.0.0"
 
 __all__ = [
diff --git a/python3.13libs/hc/hccommands.py b/python3.13libs/hc/hccommands.py
new file mode 100644
index 0000000..98fa000
--- /dev/null
+++ b/python3.13libs/hc/hccommands.py
@@ -0,0 +1,89 @@
+"""Command registry: the HC Panel's label for a method lives on the method.
+
+A command used to be declared in two places -- the method, and a hand-written
+``label -> bound method`` entry in HCMaps -- with nothing checking they agreed.
+Renaming the method silently emptied the panel entry, and the panel swallowed
+the resulting AttributeError, so it looked like the command simply did nothing.
+
+Now the label is a decorator on the method:
+
+    @command("Replace Node")
+    def replaceNode(self):
+        ...
+
+and HCMaps is generated by scanning the classes. There is no second place to
+forget. Hotkeys (hc_hotkeys.json) and the XML menus still carry their own
+entries, because Houdini owns those formats -- but `symbols()` lets a check
+verify they point at commands that exist.
+
+`scope` controls where a command shows up. Commands on HCSession and HCPane
+are always available; commands on a tab class appear whenever the current tab
+is an instance of it, so a method on HCTab shows everywhere and one on
+HCNetworkEditor only in network editors.
+"""
+
+ATTR = "_hc_command"
+
+
+class Command:
+    __slots__ = ("label", "tabs", "help")
+
+    def __init__(self, label, tabs=None, help=None):
+        self.label = label
+        # Tab .type() strings this command applies to, or None for all. Needed
+        # because a few commands live on HCTab (every tab has the method) but
+        # only make sense on some tab types.
+        self.tabs = tuple(tabs) if tabs else None
+        self.help = help
+
+    def appliesTo(self, tab_type):
+        return self.tabs is None or tab_type in self.tabs
+
+    def __repr__(self):
+        return f"<Command {self.label!r}>"
+
+
+def command(label, tabs=None, help=None):
+    """Expose the decorated method in the HC Panel under `label`."""
+    def decorate(fn):
+        setattr(fn, ATTR, Command(label, tabs=tabs, help=help))
+        return fn
+    return decorate
+
+
+def declared(cls):
+    """{label: (method name, Command)} for commands on `cls`, inherited included."""
+    found = {}
+    for name in dir(cls):
+        if name.startswith("__"):
+            continue
+        try:
+            attr = getattr(cls, name)
+        except AttributeError:
+            continue
+        spec = getattr(attr, ATTR, None)
+        if isinstance(spec, Command):
+            found[spec.label] = (name, spec)
+    return found
+
+
+def bind(instance, tab_type=None):
+    """{label: bound method} for the commands on `instance` that apply.
+
+    `tab_type` is the current tab's .type() string; commands scoped to other
+    tab types are left out.
+    """
+    if instance is None:
+        return {}
+    bound = {}
+    for label, (name, spec) in declared(type(instance)).items():
+        if tab_type is not None and not spec.appliesTo(tab_type):
+            continue
+        method = getattr(instance, name, None)
+        if method is not None:
+            bound[label] = method
+    return bound
+
+
+def labels(cls):
+    return sorted(declared(cls))
diff --git a/python3.13libs/hc/hcguides.py b/python3.13libs/hc/hcguides.py
index f454c25..8ca3494 100644
--- a/python3.13libs/hc/hcguides.py
+++ b/python3.13libs/hc/hcguides.py
@@ -89,7 +89,7 @@ class HCGuides:
 
     def update(self):
         from hc import HCSettings
-        prefs = HCSettings().prefs().get('keycam').get('guides')
+        prefs = HCSettings().keycam('guides')
         self.axis_size = prefs.get('axis_size')
         self.tie_axis_to_radius = prefs.get('tie_axis_to_radius')
         self.bbox = prefs.get('bbox')
diff --git a/python3.13libs/hc/hcleader.py b/python3.13libs/hc/hcleader.py
index 6c124b2..9c9abbe 100644
--- a/python3.13libs/hc/hcleader.py
+++ b/python3.13libs/hc/hcleader.py
@@ -1,8 +1,10 @@
 import time
 
+from . import hcstate
+
 
 _TIMEOUT = 1.0
-_pending = {}  # {pane tab name: timestamp}
+_pending = hcstate.Store("leader_chord", hcstate.PANE)
 
 
 def chord(editor, action_fn):
@@ -23,13 +25,12 @@ def chord(editor, action_fn):
     The elapsed-time check below already ignores a stale entry, so no timer is
     needed: a leftover entry is harmless and the next keypress overwrites it."""
     now = time.monotonic()
-    key = editor.name()
-    old_ts = _pending.get(key)
+    old_ts = _pending.get(editor)
 
     if old_ts and (now - old_ts) < _TIMEOUT:
-        _pending.pop(key, None)
+        _pending.pop(editor)
         action_fn()
         return True
 
-    _pending[key] = now
+    _pending.set(editor, now)
     return False
diff --git a/python3.13libs/hc/hcmaps.py b/python3.13libs/hc/hcmaps.py
index 18b9632..1c77b9d 100644
--- a/python3.13libs/hc/hcmaps.py
+++ b/python3.13libs/hc/hcmaps.py
@@ -1,97 +1,23 @@
-class HCMaps:
-    def __init__(self):
-        return
+"""HC Panel command map, generated from the @command decorators.
+
+This used to be four hand-written dicts of ``label -> bound method``, a second
+place to keep in sync with the methods themselves. Renaming a method left a
+stale entry that raised AttributeError, which SelectionDialog then swallowed --
+so the command silently did nothing.
 
-    def tab_map_base(self, session, pane, tab):
-        return {
-            'Close Other Tabs':          tab.closeOtherTabs,
-            'Close Tab':                 tab.close,
-            'Color Editor':              session.colorEditor,
-            'Contract Pane':             pane.contract,
-            'Expand Pane':               pane.expand,
-            'Floating Parameter Editor': session.floatingParameterEditor,
-            'Hide Shelf':                session.hideShelf,
-            'HC Info':                   session.hcInfo,
-            'Maximize Pane':             pane.toggleMaximize,
-            'New File':                  session.newFile,
-            'New Floating Pane':         session.newFloatingPaneDialog,
-            'New Tab':                   session.newTabDialog,
-            'Next Tab':                  pane.nextTab,
-            'Open File':                 session.openFile,
-            'Open Preferences':          session.openPreferences,
-            'Open HC Settings':          session.openSettings,
-            'Previous Tab':              pane.prevTab,
-            'Reload Colors':             session.reloadColorSchemes,
-            'Reload HC':                 session.reloadHC,
-            'Reload Hotkeys':            session.reloadHotkeys,
-            'Reload Keycam':             session.reloadKeycam,
-            'Restart Houdini':           session.restartHoudini,
-            # 'Rename Tabs':               session.renameTabs,
-            'Save':                      session.save,
-            'Save As':                   session.saveAs,
-            'Set Tab Type':              tab.changeTypeDialog,
-            'Show Shelf':                session.showShelf,
-            'Split Pane Horizontally':   pane.splitHorizontal,
-            'Split Pane Vertically':     pane.splitVertical,
-            'Split Rotate':              pane.splitRotate,
-            'Split Swap':                pane.splitSwap,
-            'Toggle All Menus':          session.toggleMenus,
-            'Toggle All Paths':          session.toggleAllTabs,
-            'Toggle Autosave':           session.toggleAutoSave,
-            'Toggle HC Status':          session.toggleStatus,
-            'Toggle Main Menu':          session.toggleMainMenu,
-            'Toggle Split Handles':      session.toggleSplitHandles,
-            'Refresh Split Handles':     session.refreshSplitHandles,
-            'Toggle Spreadsheet':        session.toggleSpreadsheet,
-            'Toggle Window Watcher':     session.toggleWindowWatcher,
-            'Toggle Stowbars':           session.toggleStowbars,
-            'Toggle Tabs':               pane.toggleTabs,
-            'Toggle Update Mode':        session.toggleUpdateMode,
-            'Update Mode: Auto':         session.setUpdateModeAuto,
-            'Update Mode: Manual':       session.setUpdateModeManual
-        }
+The map is now derived. The four tab_map_* methods that callers composed by
+hand are one call: every command the session, the pane and the current tab
+expose, with tab-scoped commands filtered to the tab types they declare.
+"""
 
-    def tab_map_scene_viewer(self, session, pane, tab):
-        return {
-            'Close Scene Viewer Toolbars': tab.closeToolbars,
-            'Frame':                tab.frameAllViewports,
-            'Home Viewports':       tab.homeAllViewports,
-            'Keycam':               tab.keycam,
-            'Toggle Backface':      tab.toggleBackface,
-            'Toggle Bars':          tab.toggleBars,
-            'Toggle Display Bar':   tab.toggleDisplayBar,
-            'Toggle Event Listener': tab.toggleEventListener,
-            'Toggle Grid':          tab.toggleGrid,
-            'Toggle Group List':    tab.toggleGroupList,
-            'Toggle Light Geo':     tab.toggleLightGeo,
-            'Toggle Operation Bar': tab.toggleOperationBar,
-            'Toggle Point Markers': tab.togglePointMarkers,
-            'Toggle Point Normals': tab.togglePointNormals,
-            'Toggle Point Numbers': tab.togglePointNumbers,
-            'Toggle Prim Normals':  tab.togglePrimNormals,
-            'Toggle Prim Numbers':  tab.togglePrimNumbers,
-            'Toggle Selection Bar': tab.toggleSelectionBar,
-            'Toggle Vectors':       tab.toggleVectors
-        }
+from . import hccommands
 
-    def tab_map_path(self, session, pane, tab):
-        return {
-            'Toggle Path': tab.toggleNetworkControls,
-            'Toggle Pin':  tab.togglePin
-        }
 
-    def tab_map_network_editor(self, session, pane, tab):
-        return {
-            'Deselect All':       tab.deselectAllNodes,
-            'Frame All':          tab.frameAll,
-            'Frame HC Cursor':    tab.frameHcnetcursor,
-            'Recook Selection':   tab.recookSelection,
-            'Reload Node Shapes': tab.reloadNodeShapes,
-            'Rename Node':        tab.renameNode,
-            'Replace Node':       tab.replaceNode,
-            'Set Node Colors':    tab.setNodeColors,
-            'Set Node Shapes':    tab.setNodeShapes,
-            'Show Path Message':  tab.showPathMessage,
-            'ToggleGrid Mode':    tab.toggleGridMode,
-            'Toggle Menu':        tab.toggleMenu
-        }
+class HCMaps:
+    def commands(self, session, pane, tab):
+        """{label: bound method} for the current session, pane and tab."""
+        tab_type = tab.type() if tab is not None else None
+        merged = {}
+        for instance in (session, pane, tab):
+            merged.update(hccommands.bind(instance, tab_type))
+        return dict(sorted(merged.items()))
diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index 1641fb5..e4e45f2 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -1,16 +1,16 @@
 import hou, math, os, types
+from . import hcstate
 from .hcpathtab import HCPathTab
 from .hcsettings import HCSettings
+from .hccommands import command
 
 
-# hcnetcursor state, keyed by (pane id, pwd path).
-_hcnetcursors = {}
-# Last drawn overlay signature per key, so a redraw is skipped when nothing
-# visible changed. See updateCurrentNodeOverlay().
-_overlay_signatures = {}
-# Last grid step mirrored onto each pane's gridxstep/gridystep prefs.
-_grid_pref_sync = {}
-# hcnetcursor_NxN.png presence, resolved once per path.
+# Per-network: where the cursor sits, and what was last drawn for it.
+_cursors = hcstate.Store("hcnetcursor", hcstate.NETWORK)
+_overlays = hcstate.Store("hcnetcursor_overlay", hcstate.NETWORK)
+# Per-pane: the grid step last mirrored onto gridxstep/gridystep.
+_grid_prefs = hcstate.Store("grid_pref_sync", hcstate.PANE)
+# Not per-tab at all: hcnetcursor_NxN.png presence, resolved once per path.
 _cursor_asset_exists = {}
 
 
@@ -222,9 +222,7 @@ class HCNetworkEditor(HCPathTab):
         ]
 
     def _hcnetcursorKey(self):
-        pane = self.hou_tab.pane()
-        pane_id = pane.id() if pane is not None else id(self.hou_tab)
-        return (pane_id, self.hou_tab.pwd().path())
+        return hcstate.networkKey(self.hou_tab)
 
     def _gridStep(self):
         """Return grid step from HCSettings, mirroring it onto the pane pref."""
@@ -234,12 +232,11 @@ class HCNetworkEditor(HCPathTab):
         # Mirror onto the pane pref, but only when it actually changed --
         # this runs on every network editor UI event, and setPref is a HOM
         # write that is not free.
-        key = self._hcnetcursorKey()
-        if _grid_pref_sync.get(key) != (x, y):
+        if _grid_prefs.get(self.hou_tab) != (x, y):
             try:
                 self.hou_tab.setPref("gridxstep", str(x))
                 self.hou_tab.setPref("gridystep", str(y))
-                _grid_pref_sync[key] = (x, y)
+                _grid_prefs.set(self.hou_tab, (x, y))
             except hou.Error:
                 pass
         return hou.Vector2(x, y)
@@ -308,11 +305,9 @@ class HCNetworkEditor(HCPathTab):
         return hou.BoundingRect(min_x, min_y, max_x, max_y)
 
     def _hcnetcursorState(self):
-        key = self._hcnetcursorKey()
-        state = _hcnetcursors.get(key)
+        state = _cursors.get(self.hou_tab)
         if state is None:
-            state = self._initialHcnetcursorState()
-            _hcnetcursors[key] = state
+            state = _cursors.set(self.hou_tab, self._initialHcnetcursorState())
         elif "origin" not in state:
             rect = self._hcnetcursorRectFromState(state)
             min_v = rect.min()
@@ -321,16 +316,15 @@ class HCNetworkEditor(HCPathTab):
                 "grow_x": 0,
                 "grow_y": 0,
             }
-            _hcnetcursors[key] = state
+            _cursors.set(self.hou_tab, state)
         return dict(state)
 
     def _setHcnetcursorState(self, state):
-        key = self._hcnetcursorKey()
-        _hcnetcursors[key] = {
+        _cursors.set(self.hou_tab, {
             "origin": (state["origin"][0], state["origin"][1]),
             "grow_x": int(state.get("grow_x", 0)),
             "grow_y": int(state.get("grow_y", 0)),
-        }
+        })
 
     def _snapCursorEdgeDown(self, value, step):
         return math.floor((value + step * 0.5) / step) * step - step * 0.5
@@ -526,11 +520,10 @@ class HCNetworkEditor(HCPathTab):
         rect = self.hcnetcursorRect() if enabled else None
         cursor_path = self._hcnetcursorImageFile() if enabled else None
 
-        key = self._hcnetcursorKey()
         signature = self._overlaySignature(rect, cursor_path)
-        if not force and _overlay_signatures.get(key) == signature:
+        if not force and _overlays.get(self.hou_tab) == signature:
             return
-        _overlay_signatures[key] = signature
+        _overlays.set(self.hou_tab, signature)
 
         if enabled:
             self._updateHcnetcursorBackground(rect, cursor_path)
@@ -652,6 +645,7 @@ class HCNetworkEditor(HCPathTab):
     def currentNode(self):
         return self.hou_tab.currentNode()
 
+    @command("Deselect All")
     def deselectAllNodes(self):
         self.hou_tab.clearAllSelected()
 
@@ -660,6 +654,7 @@ class HCNetworkEditor(HCPathTab):
         for node in nodes:
             node.setSelected(True)
 
+    @command("Recook Selection")
     def recookSelection(self):
         nodes = list(self.hou_tab.pwd().selectedChildren())
         if not nodes:
@@ -956,6 +951,7 @@ class HCNetworkEditor(HCPathTab):
             return self.hcnetcursorCenter()
         return self.cursorPosition()
 
+    @command("Frame All")
     def frameAll(self):
         self.hou_tab.requestZoomReset()
 
@@ -994,6 +990,7 @@ class HCNetworkEditor(HCPathTab):
         view.translate(delta)
         self.setBounds(view)
 
+    @command("Frame HC Cursor")
     def frameHcnetcursor(self, margin_cells=1.0, min_view_cells=(6.0, 4.0)):
         """Zoom/pan the viewport to the hcnetcursor, ignoring node selection."""
         rect = self.hcnetcursorRect()
@@ -1074,6 +1071,7 @@ class HCNetworkEditor(HCPathTab):
     def setMenuOpen(self, value):
         self.hou_tab.setPref('showmenu', str(value))
 
+    @command("Show Path Message")
     def showPathMessage(self):
         self.hou_tab.flashMessage(image=None, message=self.path(), duration=1)
 
@@ -1085,6 +1083,7 @@ class HCNetworkEditor(HCPathTab):
         mode = self.hou_tab.getPref('dimunusednodes')
         self.hou_tab.setPref('dimunusednodes', map[mode])
 
+    @command("ToggleGrid Mode")
     def toggleGridMode(self):
         map = {
             '0': '1',
@@ -1094,6 +1093,16 @@ class HCNetworkEditor(HCPathTab):
         mode = self.hou_tab.getPref('gridmode')
         self.hou_tab.setPref('gridmode', map[mode])
 
+    def isChromeVisible(self):
+        return bool(super().isChromeVisible() or self.isMenuOpen())
+
+    def showChrome(self, visible):
+        super().showChrome(visible)
+        # The node graph menu is always collapsed by a chrome toggle, never
+        # restored -- matching the original toggleMenus behaviour.
+        self.setMenuOpen(0)
+
+    @command("Toggle Menu")
     def toggleMenu(self):
         map = {
             '0': '1',
@@ -1106,6 +1115,7 @@ class HCNetworkEditor(HCPathTab):
     """ Node replacement """
 
 
+    @command("Replace Node")
     def replaceNode(self):
         """Open a fuzzy-search node type picker and replace the single
         selected node with the chosen type, preserving position and wiring."""
@@ -1192,9 +1202,11 @@ class HCNetworkEditor(HCPathTab):
     def type(self):
         return "HCNetworkEditor"
 
+    @command("Reload Node Shapes")
     def reloadNodeShapes(self):
         self.hou_tab.reloadNodeShapes()
 
+    @command("Rename Node")
     def renameNode(self):
         node = self.currentNode()
         if node is None:
@@ -1211,6 +1223,7 @@ class HCNetworkEditor(HCPathTab):
         except hou.OperationFailed as e:
             hou.ui.setStatusMessage(f"Cannot rename: {e}", hou.severityType.Error)
 
+    @command("Set Node Colors")
     def setNodeColors(self):
         nodes = list(self.hou_tab.pwd().selectedChildren())
         if not nodes:
@@ -1223,6 +1236,7 @@ class HCNetworkEditor(HCPathTab):
         for node in nodes:
             node.setColor(color)
 
+    @command("Set Node Shapes")
     def setNodeShapes(self):
         shape = HCSettings().nodeShape()
         nodes = list(self.hou_tab.pwd().children())
diff --git a/python3.13libs/hc/hcpane.py b/python3.13libs/hc/hcpane.py
index e978b4a..28cce26 100644
--- a/python3.13libs/hc/hcpane.py
+++ b/python3.13libs/hc/hcpane.py
@@ -4,6 +4,7 @@ from .hcpathtab import HCPathTab
 from .hcparametertab import HCParameterTab
 from .hcsceneviewer import HCSceneViewer
 from .hcnetworkeditor import HCNetworkEditor
+from .hccommands import command
 
 class HCPane:
     def __init__(self, hou_pane):
@@ -78,23 +79,29 @@ class HCPane:
     def setSplitFraction(self, fraction):
         self.hou_pane.setSplitFraction(fraction)
 
+    @command("Split Pane Horizontally")
     def splitHorizontal(self):
         return self._splitAndInitialize(self.hou_pane.splitHorizontally)
 
+    @command("Split Rotate")
     def splitRotate(self):
         self.hou_pane.splitRotate()
 
+    @command("Split Swap")
     def splitSwap(self):
         self.hou_pane.splitSwap()
 
+    @command("Split Pane Vertically")
     def splitVertical(self):
         return self._splitAndInitialize(self.hou_pane.splitVertically)
 
+    @command("Contract Pane")
     def contract(self):
         fraction = round(self.splitFraction(), 3) + 0.1
         self.setSplitFraction(fraction)
         hou.ui.setStatusMessage("Pane fraction: " + str(fraction))
 
+    @command("Expand Pane")
     def expand(self):
         fraction = round(self.splitFraction(), 3) - 0.1
         self.setSplitFraction(fraction)
@@ -112,6 +119,7 @@ class HCPane:
     def toggleSplitMaximized(self):
         self.setIsSplitMaximized(not self.isSplitMaximized())
 
+    @command("Maximize Pane")
     def toggleMaximize(self):
         self.hou_pane.setIsMaximized(not self.isMaximized())
 
@@ -176,9 +184,11 @@ class HCPane:
             index = 0
         hou_tabs[(index + step) % len(hou_tabs)].setIsCurrentTab()
 
+    @command("Next Tab")
     def nextTab(self):
         self._stepTab(1)
 
+    @command("Previous Tab")
     def prevTab(self):
         self._stepTab(-1)
 
@@ -198,5 +208,6 @@ class HCPane:
             tab_names.append(tab.name())
         return tab_names
 
+    @command("Toggle Tabs")
     def toggleTabs(self):
         self.showTabs(not self.isShowingTabs())
diff --git a/python3.13libs/hc/hcparametertab.py b/python3.13libs/hc/hcparametertab.py
index 4bb9d91..69061f7 100644
--- a/python3.13libs/hc/hcparametertab.py
+++ b/python3.13libs/hc/hcparametertab.py
@@ -1,8 +1,10 @@
-_last_focused_parm_by_pane = {}
-
 import hou
+from . import hcstate
 from .hcpathtab import HCPathTab
 
+# Which parameter this pane last moved focus to.
+_focused_parm = hcstate.Store("focused_parm", hcstate.PANE)
+
 
 def _deferredEcho(label):
     """Set status message on next event loop tick, then self-remove."""
@@ -22,10 +24,6 @@ class HCParameterTab(HCPathTab):
     def initialize(self):
         self.showNetworkControls(False)
 
-    def _paneKey(self):
-        pane = self.hou_tab.pane()
-        return pane.id() if pane is not None else id(self.hou_tab)
-
     def _focusableParms(self):
         """Return list of (parm_tuple, first_parm) for focusable parameters."""
         result = []
@@ -38,8 +36,7 @@ class HCParameterTab(HCPathTab):
         return result
 
     def _currentFocusIndex(self, entries):
-        pane_key = self._paneKey()
-        parm_path = _last_focused_parm_by_pane.get(pane_key)
+        parm_path = _focused_parm.get(self.hou_tab)
         if parm_path is None:
             return None
         for index, (_, parm) in enumerate(entries):
@@ -56,7 +53,7 @@ class HCParameterTab(HCPathTab):
         next_index = 0 if current_index is None else (current_index + 1) % len(entries)
         parm_tuple, parm = entries[next_index]
         self.hou_tab.moveFocusTo(parm)
-        _last_focused_parm_by_pane[self._paneKey()] = parm.path()
+        _focused_parm.set(self.hou_tab, parm.path())
         label = parm_tuple.parmTemplate().label()
         _deferredEcho(label)
 
@@ -69,6 +66,6 @@ class HCParameterTab(HCPathTab):
         prev_index = len(entries) - 1 if current_index is None else (current_index - 1) % len(entries)
         parm_tuple, parm = entries[prev_index]
         self.hou_tab.moveFocusTo(parm)
-        _last_focused_parm_by_pane[self._paneKey()] = parm.path()
+        _focused_parm.set(self.hou_tab, parm.path())
         label = parm_tuple.parmTemplate().label()
         _deferredEcho(label)
diff --git a/python3.13libs/hc/hcsceneviewer.py b/python3.13libs/hc/hcsceneviewer.py
index 24dabd8..a1c0f24 100644
--- a/python3.13libs/hc/hcsceneviewer.py
+++ b/python3.13libs/hc/hcsceneviewer.py
@@ -2,6 +2,7 @@ import hou, types
 from .hcdrawmode import get_draw_mode_label, next_draw_mode
 from .hcpathtab import HCPathTab
 from .hcviewport import HCViewport
+from .hccommands import command
 
 
 def _hcPrintSceneViewerEvent(**kwargs):
@@ -61,9 +62,11 @@ class HCSceneViewer(HCPathTab):
             displaySets.append(displaySet)
         return displaySets
 
+    @command("Toggle Light Geo")
     def toggleLightGeo(self):
         self.setShowLights(not self.showLights())
 
+    @command("Toggle Backface")
     def toggleBackface(self):
         visible = 0
         displaySets = self.allDisplaySets()
@@ -73,6 +76,7 @@ class HCSceneViewer(HCPathTab):
         for displaySet in displaySets:
             displaySet.showPrimBackfaces(not visible)
 
+    @command("Toggle Point Markers")
     def togglePointMarkers(self):
         visible = 0
         displaySets = self.allDisplaySets()
@@ -82,6 +86,7 @@ class HCSceneViewer(HCPathTab):
         for displaySet in displaySets:
             displaySet.showPointMarkers(not visible)
 
+    @command("Toggle Point Normals")
     def togglePointNormals(self):
         visible = 0
         displaySets = self.allDisplaySets()
@@ -91,6 +96,7 @@ class HCSceneViewer(HCPathTab):
         for displaySet in displaySets:
             displaySet.showPointNormals(not visible)
 
+    @command("Toggle Point Numbers")
     def togglePointNumbers(self):
         visible = 0
         displaySets = self.allDisplaySets()
@@ -100,6 +106,7 @@ class HCSceneViewer(HCPathTab):
         for displaySet in displaySets:
             displaySet.showPointNumbers(not visible)
 
+    @command("Toggle Prim Normals")
     def togglePrimNormals(self):
         visible = 0
         displaySets = self.allDisplaySets()
@@ -109,6 +116,7 @@ class HCSceneViewer(HCPathTab):
         for displaySet in displaySets:
             displaySet.showPrimNormals(not visible)
 
+    @command("Toggle Prim Numbers")
     def togglePrimNumbers(self):
         visible = 0
         displaySets = self.allDisplaySets()
@@ -118,6 +126,7 @@ class HCSceneViewer(HCPathTab):
         for displaySet in displaySets:
             displaySet.showPrimNumbers(not visible)
 
+    @command("Toggle Vectors")
     def toggleVectors(self):
         for viewport in self.allViewports():
             settings = viewport.settings()
@@ -159,6 +168,7 @@ class HCSceneViewer(HCPathTab):
     def referencePlane(self):
         return self.hou_tab.referencePlane()
 
+    @command("Toggle Grid")
     def toggleGrid(self):
         reference_plane = self.referencePlane()
         reference_plane.setIsVisible(not reference_plane.isVisible())
@@ -236,18 +246,37 @@ class HCSceneViewer(HCPathTab):
     def showSelectionBar(self, value):
         self.hou_tab.showSelectionBar(value)
 
+    @command("Toggle Display Bar")
     def toggleDisplayBar(self):
         self.showDisplayBar(not self.isVisibleDisplayBar())
 
+    @command("Toggle Group List")
     def toggleGroupList(self):
         self.showGroupList(not self.isVisibleGroupList())
 
+    @command("Toggle Operation Bar")
     def toggleOperationBar(self):
         self.showOperationBar(not self.isVisibleOperationBar())
 
+    @command("Toggle Selection Bar")
     def toggleSelectionBar(self):
         self.showSelectionBar(not self.isVisibleSelectionBar())
 
+    def isChromeVisible(self):
+        return bool(
+            super().isChromeVisible()
+            or self.isVisibleOperationBar()
+            or self.isVisibleDisplayBar()
+            or self.isVisibleSelectionBar()
+        )
+
+    def showChrome(self, visible):
+        super().showChrome(visible)
+        self.showOperationBar(visible)
+        self.showDisplayBar(visible)
+        self.showSelectionBar(visible)
+
+    @command("Toggle Bars")
     def toggleBars(self):
         state = self.hou_tab.isShowingOperationBar() + self.hou_tab.isShowingDisplayOptionsBar() + self.hou_tab.isShowingSelectionBar()
         if state > 0:
@@ -259,6 +288,7 @@ class HCSceneViewer(HCPathTab):
             self.showDisplayBar(1)
             self.showSelectionBar(1)
 
+    @command("Close Scene Viewer Toolbars")
     def closeToolbars(self):
         """Hide all currently visible toolbars/bars in this scene viewer pane."""
         closed = 0
@@ -302,6 +332,7 @@ class HCSceneViewer(HCPathTab):
     def isListening(self):
         return _hcPrintSceneViewerEvent in self.hou_tab.eventCallbacks()
 
+    @command("Toggle Event Listener")
     def toggleEventListener(self):
         if self.isListening():
             self.removeEventListener()
@@ -314,6 +345,7 @@ class HCSceneViewer(HCPathTab):
     def type(self):
         return "HCSceneViewer"
 
+    @command("Keycam")
     def keycam(self):
         contexts = ('Object', 'Sop', "Lop")
         context = self.pwd().childCat()
@@ -325,6 +357,7 @@ class HCSceneViewer(HCPathTab):
 
     """ Viewports """
 
+    @command("Home Viewports")
     def homeAllViewports(self):
         for viewport in self.allViewports():
             viewport.home()
@@ -338,6 +371,7 @@ class HCSceneViewer(HCPathTab):
             viewports.append(HCViewport(viewport))
         return viewports
 
+    @command("Frame")
     def frameAllViewports(self):
         for viewport in self.allViewports():
             viewport.frameAll()
diff --git a/python3.13libs/hc/hcschema.py b/python3.13libs/hc/hcschema.py
new file mode 100644
index 0000000..e69a8df
--- /dev/null
+++ b/python3.13libs/hc/hcschema.py
@@ -0,0 +1,131 @@
+"""Declarative schema for hc_settings.json.
+
+Every setting is declared once, here. Three things read from this:
+
+- ``HCSettings.DEFAULTS`` is generated from it, so a section missing from the
+  file always merges back to something complete. Before this, ``keycam`` -- the
+  largest section -- had no defaults at all, and ``keycam.py`` and
+  ``hcguides.py`` both did unguarded ``prefs().get('keycam').get('units')``
+  chains that raise AttributeError on None the moment the section is absent.
+- ``HCSettingsPanel`` builds its controls from it rather than by reflecting
+  over whatever happens to be in the JSON, so deleting a key from the file no
+  longer makes its control disappear.
+- The widget for a setting comes from its declared ``kind``, not from guessing
+  at the Python type of the current value. The guess was wrong for
+  ``delta_ow`` and ``delta_z``: both are step magnitudes that happen to be 1,
+  and the panel rendered them as checkboxes.
+
+Adding a setting means adding a Setting here and nothing else.
+"""
+
+DESKTOP_MODES = ("attached", "detached")
+NODE_SHAPES = (
+    "rect", "rounded_rect", "circle", "diamond",
+    "tilted_rect", "trapezoid_down", "trapezoid_up",
+)
+ZOOM_CENTERS = (("Mouse Cursor", "mouse_cursor"), ("HC Cursor", "hc_cursor"))
+
+
+class Setting:
+    """One leaf setting.
+
+    kind is one of:
+      bool    -- JSON true/false, checkbox
+      flag    -- JSON 0/1 integer, checkbox (round-trips as int)
+      int     -- spin box
+      float   -- spin box
+      slider  -- slider + spin box, needs `range`
+      text    -- line edit
+      choice  -- combo box, needs `choices`
+      color   -- "#rrggbb" line edit
+    """
+
+    __slots__ = ("kind", "default", "label", "choices", "range", "decimals", "help")
+
+    def __init__(self, kind, default, label=None, choices=None, range=None,
+                 decimals=2, help=None):
+        self.kind = kind
+        self.default = default
+        self.label = label
+        self.choices = choices
+        self.range = range
+        self.decimals = decimals
+        self.help = help
+
+
+# Nested dicts mirror the JSON. A dict value is a section; a Setting is a leaf.
+SCHEMA = {
+    "desktop_mode": Setting(
+        "choice", "attached", label="Desktop Mode",
+        choices=tuple((m.title(), m) for m in DESKTOP_MODES),
+        help="Takes effect on next start.",
+    ),
+    "startup": {
+        "default_autosave_state": Setting("bool", True, label="Default Autosave State"),
+    },
+    "keycam": {
+        "guides": {
+            "axis_size":          Setting("float", 0.05, decimals=4),
+            "tie_axis_to_radius": Setting("flag", 0),
+            "bbox":               Setting("flag", 0, label="Bounding Box"),
+            "cam_axis":           Setting("flag", 0, label="Camera Axis"),
+            "cam_geo":            Setting("flag", 0, label="Camera Geometry"),
+            "pivot_axis":         Setting("flag", 1),
+            "pivot_2d":           Setting("flag", 0, label="Pivot 2D"),
+            "pivot_3d":           Setting("flag", 0, label="Pivot 3D"),
+            "perim":              Setting("flag", 0, label="Perimeter"),
+            "ray":                Setting("flag", 0),
+        },
+        "startup": {
+            "center_on_geo": Setting("flag", 1),
+            "lock_cam":      Setting("flag", 1, label="Lock Camera"),
+            "reset":         Setting("flag", 1),
+        },
+        "units": {
+            "delta_t":  Setting("float", 0.2, label="Translate Step"),
+            "delta_r":  Setting("float", 15.0, label="Rotate Step (degrees)"),
+            # delta_z and delta_ow are declared so the panel shows them and the
+            # file round-trips, but nothing reads them yet -- keycam.py only
+            # pulls delta_r and delta_t.
+            "delta_z":  Setting("float", 1.0, label="Zoom Step"),
+            "delta_ow": Setting("float", 1.0, label="Ortho Width Step"),
+        },
+        "drag_sensitivity": Setting("slider", 0.25, range=(0.0, 1.0)),
+    },
+    "node_graph": {
+        "node_shape":  Setting("choice", "rect", choices=tuple((s, s) for s in NODE_SHAPES)),
+        "node_color":  Setting("color", "#607070"),
+        "zoom_center": Setting("choice", "mouse_cursor", choices=ZOOM_CENTERS),
+        "hcnetcursor": Setting("bool", True, label="hcnetcursor"),
+        "grid_x_step": Setting("slider", 2.0, range=(0.25, 8.0)),
+        "grid_y_step": Setting("slider", 1.0, range=(0.25, 8.0)),
+        "node_center_offset_x": Setting("slider", 0.5, range=(0.0, 2.0)),
+        "node_center_offset_y": Setting("slider", 0.15, range=(0.0, 2.0)),
+    },
+}
+
+
+def defaults(schema=None):
+    """The SCHEMA collapsed to a plain nested dict of default values."""
+    if schema is None:
+        schema = SCHEMA
+    out = {}
+    for key, value in schema.items():
+        out[key] = defaults(value) if isinstance(value, dict) else value.default
+    return out
+
+
+def lookup(path, schema=None):
+    """Return the Setting at a tuple path, or None if the path is not declared."""
+    node = schema if schema is not None else SCHEMA
+    for key in path:
+        if not isinstance(node, dict) or key not in node:
+            return None
+        node = node[key]
+    return node if isinstance(node, Setting) else None
+
+
+def label_for(key, setting=None):
+    if setting is not None and setting.label:
+        return setting.label
+    return key.replace("_", " ").title()
diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index 646bb77..08d7e89 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -8,6 +8,7 @@ from .hcpane import HCPane
 from .hcpathtab import HCPathTab
 from .hctab import HCTab
 from .hcwidgets import HCWidgets
+from .hccommands import command
 
 
 class HCSession:
@@ -116,9 +117,11 @@ class HCSession:
 
     """ Windows """
 
+    @command("Color Editor")
     def colorEditor(self):
         hou.ui.selectColor()
 
+    @command("Floating Parameter Editor")
     def floatingParameterEditor(self):
         tab = self.currentTab()
         if tab is None or tab.type() != 'HCNetworkEditor':
@@ -135,17 +138,7 @@ class HCSession:
 
         pane = self.currentPane()
         tab = self.currentTab()
-        tab_type = tab.type()
-
-        maps = HCMaps()
-        list_dict = maps.tab_map_base(self, pane, tab)
-        if tab_type in ('HCPathTab', 'HCParameterTab'):
-            list_dict = list_dict | maps.tab_map_path(self, pane, tab)
-        elif tab_type == 'HCNetworkEditor':
-            list_dict = list_dict | maps.tab_map_network_editor(self, pane, tab)
-        elif tab_type == 'HCSceneViewer':
-            list_dict = list_dict | maps.tab_map_scene_viewer(self, pane, tab)
-
+        list_dict = HCMaps().commands(self, pane, tab)
 
         anchor_geometry = pane.qtScreenGeometry() if pane is not None else None
         panel = HCWidgets.SelectionDialog('hcpanel', list_dict, anchor_geometry=anchor_geometry)
@@ -153,6 +146,7 @@ class HCSession:
         panel.raise_()
         panel.activateWindow()
 
+    @command("New File")
     def newFile(self):
         file_path = hou.ui.selectFile(
             title="New File", 
@@ -172,6 +166,7 @@ class HCSession:
         except (hou.Error, OSError) as e:
             hou.ui.displayMessage(f"Error creating file: {e}", severity=hou.severityType.Error)
 
+    @command("Open File")
     def openFile(self):
         file_path = hou.ui.selectFile(title="Open File", file_type=hou.fileType.Hip)
         if file_path:
@@ -184,9 +179,11 @@ class HCSession:
         except hou.Error as e:
             hou.ui.displayMessage(f"Error opening file: {e}", severity=hou.severityType.Error)
 
+    @command("Open Preferences")
     def openPreferences(self):
         hou.ui.openPreferences('ui', '')
 
+    @command("Toggle HC Status")
     def toggleStatus(self):
         from .hcstatus import HCStatus
         existing = hou.qt.mainWindow().findChild(HCStatus, HCStatus.OBJECT_NAME)
@@ -208,6 +205,7 @@ class HCSession:
             bar.overlay.show()
             bar.overlay.raise_()
 
+    @command("Open HC Settings")
     def openSettings(self):
         from .hcsettings import HCSettingsPanel
         existing = hou.qt.mainWindow().findChild(HCSettingsPanel, HCSettingsPanel.OBJECT_NAME)
@@ -223,9 +221,11 @@ class HCSession:
             hou.session._hc_split_handles = HCSplitHandles()
         return hou.session._hc_split_handles
 
+    @command("Toggle Split Handles")
     def toggleSplitHandles(self):
         self.splitHandles().toggle()
 
+    @command("Toggle Spreadsheet")
     def toggleSpreadsheet(self):
         # Search for any floating panel containing a DetailsView tab
         for fp in hou.ui.floatingPanels():
@@ -240,6 +240,7 @@ class HCSession:
         self.newFloatingPane(hou.paneTabType.DetailsView)
         hou.ui.setStatusMessage("Opened Spreadsheet")
 
+    @command("Refresh Split Handles")
     def refreshSplitHandles(self):
         self.splitHandles().refresh()
 
@@ -257,6 +258,7 @@ class HCSession:
             hou.session._hc_window_watcher = HCWindowWatcher()
         return hou.session._hc_window_watcher
 
+    @command("Toggle Window Watcher")
     def toggleWindowWatcher(self):
         self.windowWatcher().toggle()
 
@@ -266,11 +268,13 @@ class HCSession:
     def isAutoSave(self):
         return hou.getPreference('autoSave')
 
+    @command("Reload Colors")
     def reloadColorSchemes(self):
         hou.ui.reloadColorScheme()
         hou.ui.reloadViewportColorSchemes()
         self.updateNodeColors()
 
+    @command("HC Info")
     def hcInfo(self):
         from . import __version__
         from PySide6.QtCore import Qt
@@ -325,6 +329,7 @@ class HCSession:
         win.raise_()
         win.activateWindow()
 
+    @command("Reload HC")
     def reloadHC(self):
         import sys
         from PySide6.QtWidgets import QWidget
@@ -345,6 +350,7 @@ class HCSession:
         from .hcstatusbar import HCStatusBar
         HCStatusBar().show()
 
+    @command("Reload Hotkeys")
     def reloadHotkeys(self):
         HCBindings().load()
 
@@ -419,24 +425,29 @@ class HCSession:
             return
         hou.ui.setStatusMessage('No scene viewer available', hou.severityType.Error)
 
+    @command("Reload Keycam")
     def reloadKeycam(self):
         hou.ui.reloadViewerState('keycam')
 
+    @command("Update Mode: Auto")
     def setUpdateModeAuto(self):
         hou.setUpdateMode(hou.updateMode.AutoUpdate)
 
+    @command("Update Mode: Manual")
     def setUpdateModeManual(self):
         hou.setUpdateMode(hou.updateMode.Manual)
 
     def triggerUpdate(self):
         hou.ui.triggerUpdate()
 
+    @command("Toggle Autosave")
     def toggleAutoSave(self):
         map = {'0': '1', '1': '0'}
         hou.setPreference('autoSave', map[hou.getPreference('autoSave')])
         from .hcstatusbar import HCStatusBar
         HCStatusBar().updateAutosave()
 
+    @command("Toggle Update Mode")
     def toggleUpdateMode(self):
         # hou.updateModeSetting is a function; the old code stringified the
         # function object and then called the lookup dict, so this never ran.
@@ -461,6 +472,7 @@ class HCSession:
         for callback in callbacks:
             hou.ui.removeEventLoopCallback(callback)
 
+    @command("Restart Houdini")
     def restartHoudini(self):
         import os
         import subprocess
@@ -509,6 +521,7 @@ class HCSession:
         )
         return saved_path
 
+    @command("Save")
     def save(self):
         try:
             hou.hipFile.save()
@@ -516,6 +529,7 @@ class HCSession:
         except (hou.Error, OSError) as e:
             hou.ui.displayMessage(f"Error saving file: {e}", severity=hou.severityType.Error)
 
+    @command("Save As")
     def saveAs(self):
         file_path = hou.ui.selectFile(
             title="Save As", 
@@ -593,46 +607,27 @@ class HCSession:
         return int(value)
 
     def isVisibleMenus(self):
-        visible = 0
-        panes = self.allPanes()
-        tabs = self.allTabs()
-        # Main menu
+        """Whether any chrome is showing: main menu, tab chrome, or pane tabs."""
         if self.isVisibleMainMenu():
-            visible = 1
-        # Tabs
-        for tab in tabs:
-            if tab.type() == 'HCNetworkEditor':
-                if tab.isMenuOpen():
-                    visible = 1
-                elif tab.isShowingNetworkControls():
-                    visible = 1
-            elif tab.type() in ('HCPathTab', 'HCParameterTab'):
-                if tab.isShowingNetworkControls():
-                    visible = 1
-            elif tab.type() == 'HCSceneViewer':
-                if tab.isShowingNetworkControls():
-                    visible = 1
-                elif tab.isVisibleOperationBar():
-                    visible = 1
-                elif tab.isVisibleDisplayBar():
-                    visible = 1
-                elif tab.isVisibleSelectionBar():
-                    visible = 1
-        # Panes
-        for pane in panes:
-            if pane.isShowingTabs():
-                visible = 1
-        return visible
-
+            return 1
+        if any(tab.isChromeVisible() for tab in self.allTabs()):
+            return 1
+        if any(pane.isShowingTabs() for pane in self.allPanes()):
+            return 1
+        return 0
+
+    @command("Hide Shelf")
     def hideShelf(self):
         self.desktop().shelfDock().show(0)
 
     def showMainMenu(self, value):
         hou.setPreference('showmenu.val', str(value))
 
+    @command("Show Shelf")
     def showShelf(self):
         self.desktop().shelfDock().show(1)
 
+    @command("Toggle Main Menu")
     def toggleMainMenu(self):
         value = (self.isVisibleMainMenu()+1) % 2
         self.showMainMenu(value)
@@ -640,25 +635,15 @@ class HCSession:
         if tab is not None and tab.type() == 'HCNetworkEditor':
             tab.toggleMenu()
 
+    @command("Toggle All Menus")
     def toggleMenus(self):
         visible = self.isVisibleMenus()
-        panes = self.allPanes()
-        tabs = self.allTabs()
-        # Set state
-        self.showMainMenu((visible+1) % 2)
-        for tab in tabs:
-            if tab.type() == 'HCNetworkEditor':
-                tab.showNetworkControls(not visible)
-                tab.setMenuOpen(0)
-            elif tab.type() in ('HCPathTab', 'HCParameterTab'):
-                tab.showNetworkControls(not visible)
-            elif tab.type() == 'HCSceneViewer':
-                tab.showNetworkControls(not visible)
-                tab.showOperationBar(not visible)
-                tab.showDisplayBar(not visible)
-                tab.showSelectionBar(not visible)
-        for pane in panes:
-            pane.showTabs(not visible)
+        show = not visible
+        self.showMainMenu(int(show))
+        for tab in self.allTabs():
+            tab.showChrome(show)
+        for pane in self.allPanes():
+            pane.showTabs(show)
         # Apply (needs to be called twice for some reason)
         # hou.ui.setHideAllMinimizedStowbars(visible)
         # hou.ui.setHideAllMinimizedStowbars(visible)
@@ -672,6 +657,7 @@ class HCSession:
         for tab in tabs:
             tab.showNetworkControls(not visible)
 
+    @command("Toggle Stowbars")
     def toggleStowbars(self):
         if hou.ui.hideAllMinimizedStowbars():
             hou.ui.setHideAllMinimizedStowbars(False)
@@ -693,6 +679,7 @@ class HCSession:
         dialog.activateWindow()
         return dialog
 
+    @command("New Tab")
     def newTabDialog(self):
         pane = self.currentPane()
         if pane is None:
@@ -709,11 +696,13 @@ class HCSession:
         hou.ui.setStatusMessage('Created new floating pane', hou.severityType.Message)
         return panel
 
+    @command("New Floating Pane")
     def newFloatingPaneDialog(self):
         pane = self.currentPane()
         anchor_geometry = pane.qtScreenGeometry() if pane is not None else None
         return self._paneTabTypeDialog("New Floating Pane", self.newFloatingPane, anchor_geometry=anchor_geometry)
 
+    @command("Toggle All Paths")
     def toggleAllTabs(self):
         visible = 0
         panes = self.allPanes()
diff --git a/python3.13libs/hc/hcsettings.py b/python3.13libs/hc/hcsettings.py
index fba5e23..f1f9352 100644
--- a/python3.13libs/hc/hcsettings.py
+++ b/python3.13libs/hc/hcsettings.py
@@ -23,10 +23,8 @@ from PySide6.QtWidgets import (
     QWidget,
 )
 
-
-DESKTOP_MODES = ("attached", "detached")
-NODE_SHAPES = ("rect", "rounded_rect", "circle", "diamond", "tilted_rect", "trapezoid_down", "trapezoid_up")
-NODE_GRAPH_ZOOM_BEHAVIORS = ("mouse_cursor", "hc_cursor")
+from . import hcschema
+from .hcschema import DESKTOP_MODES
 
 # prefs() sits on the network editor's hot path -- nodegraphhooks reads it on
 # every UI event, and a single overlay update reads it several times over. Hold
@@ -37,22 +35,8 @@ _cache_prefs = None
 
 
 class HCSettings:
-    DEFAULTS = {
-        "desktop_mode": "attached",
-        "startup": {
-            "default_autosave_state": True,
-        },
-        "node_graph": {
-            "node_shape": "rect",
-            "node_color": "#607070",
-            "zoom_center": "mouse_cursor",
-            "hcnetcursor": True,
-            "grid_x_step": 2.0,
-            "grid_y_step": 1.0,
-            "node_center_offset_x": 0.5,
-            "node_center_offset_y": 0.15,
-        },
-    }
+    # Generated from hcschema.SCHEMA -- add settings there, not here.
+    DEFAULTS = hcschema.defaults()
 
     def __init__(self):
         self.node_color = (0.38, 0.38, 0.56)
@@ -78,20 +62,20 @@ class HCSettings:
     def prefs(self):
         """Settings from disk, overlaid on DEFAULTS.
 
-        Callers index straight into nested sections (prefs()['node_graph']
-        ['node_color']), so a key missing from the file must never produce a
-        missing key here -- an unmerged read used to raise AttributeError on
-        None during startup, before any of hc had loaded.
+        Callers index straight into nested sections (prefs()['keycam']['units']
+        ['delta_r']), so a key missing from the file must never produce a
+        missing key here -- unmerged reads used to raise AttributeError on None
+        in keycam.py and hcguides.py.
         """
         global _cache_stat, _cache_prefs
 
         path = self._path()
         if path is None:
-            return dict(self.DEFAULTS)
+            return copy.deepcopy(self.DEFAULTS)
         try:
             st = path.stat()
         except OSError:
-            return dict(self.DEFAULTS)
+            return copy.deepcopy(self.DEFAULTS)
 
         stat_key = (str(path), st.st_mtime_ns, st.st_size)
         if _cache_stat == stat_key and _cache_prefs is not None:
@@ -101,9 +85,9 @@ class HCSettings:
             data = json.loads(path.read_text())
         except (OSError, ValueError) as e:
             print(f"[HCSettings] could not read {path}: {e}")
-            return dict(self.DEFAULTS)
+            return copy.deepcopy(self.DEFAULTS)
         if not isinstance(data, dict):
-            return dict(self.DEFAULTS)
+            return copy.deepcopy(self.DEFAULTS)
 
         _cache_prefs = self._merged(self.DEFAULTS, data)
         _cache_stat = stat_key
@@ -116,6 +100,21 @@ class HCSettings:
     def get(self, key, default=None):
         return self.prefs().get(key, default)
 
+    def section(self, *path):
+        """A nested section by path, always a dict (never None).
+
+        Replaces `prefs().get('keycam').get('units')` chains, which raise
+        AttributeError as soon as any level is absent.
+        """
+        node = self.prefs()
+        for key in path:
+            if not isinstance(node, dict):
+                return {}
+            node = node.get(key)
+            if node is None:
+                return {}
+        return node if isinstance(node, dict) else {}
+
     def set(self, key, value):
         data = self.prefsCopy()
         data[key] = value
@@ -137,7 +136,10 @@ class HCSettings:
         self.set("desktop_mode", mode)
 
     def nodeGraph(self):
-        return self.get("node_graph", {})
+        return self.section("node_graph")
+
+    def keycam(self, *path):
+        return self.section("keycam", *path)
 
     def hcnetcursorEnabled(self):
         return bool(self.nodeGraph().get("hcnetcursor"))
@@ -159,30 +161,17 @@ class HCSettings:
 
 class HCSettingsPanel(QDialog):
     OBJECT_NAME = "hc_settings_panel"
-    BOOL_HINT_KEYS = {
-        "bbox", "cam_axis", "cam_geo", "perim",
-        "pivot_2d", "pivot_3d", "pivot_axis", "ray",
-        "tie_axis_to_radius", "center_on_geo", "lock_cam", "reset",
-    }
-    # Keys that render as a QSlider + QDoubleSpinBox combo.
-    # Value is (min, max, decimals) for the spinbox; slider uses 0..1000 integer range.
-    SLIDER_RANGES = {
-        "drag_sensitivity": (0.0, 1.0, 2),
-        "grid_x_step": (0.25, 8.0, 2),
-        "grid_y_step": (0.25, 8.0, 2),
-        "node_center_offset_x": (0.0, 2.0, 2),
-        "node_center_offset_y": (0.0, 2.0, 2),
-    }
+    SLIDER_STEPS = 1000
 
     def __init__(self):
         super().__init__(hou.qt.mainWindow())
         self.setObjectName(self.OBJECT_NAME)
         self.setWindowTitle("HC Settings")
         self.setWindowFlags(Qt.Tool)
-        self.resize(720, 600) # Adjusted height for tabbed view
+        self.resize(720, 600)
         self.settings = HCSettings()
 
-        # path_tuple (e.g. ("keycam","guides","axis_size")) -> (widget, original_value)
+        # path tuple, e.g. ("keycam","guides","axis_size") -> widget
         self._fields = {}
 
         self.tabs = QTabWidget()
@@ -223,124 +212,110 @@ class HCSettingsPanel(QDialog):
 
 
     def _rebuild(self):
-        # Clear existing tabs
+        """Build the form from the schema, filling in values from prefs().
+
+        Walking the schema rather than the file means every declared setting
+        gets a control even when the file omits it.
+        """
         self._fields.clear()
         while self.tabs.count():
-            w = self.tabs.widget(0)
+            widget = self.tabs.widget(0)
             self.tabs.removeTab(0)
-            if w:
-                w.deleteLater()
+            if widget:
+                widget.deleteLater()
 
-        data = self.settings.prefs()
-        
-        # Separate root-level scalars and top-level dicts
-        scalars = {k: v for k, v in data.items() if not isinstance(v, dict)}
-        dicts = {k: v for k, v in data.items() if isinstance(v, dict)}
+        values = self.settings.prefs()
+
+        scalars = {k: v for k, v in hcschema.SCHEMA.items()
+                   if isinstance(v, hcschema.Setting)}
+        sections = {k: v for k, v in hcschema.SCHEMA.items() if isinstance(v, dict)}
 
         if scalars:
-            self.tabs.addTab(self._createTabPage(scalars, ()), "General")
-        
-        for key, value in dicts.items():
-            self.tabs.addTab(self._createTabPage(value, (key,)), self._prettify(key))
+            self.tabs.addTab(self._createTabPage(scalars, values, ()), "General")
+        for key, section in sections.items():
+            page = self._createTabPage(section, values.get(key, {}), (key,))
+            self.tabs.addTab(page, hcschema.label_for(key))
 
-    def _createTabPage(self, data, path):
-        # Create a scrollable page for the tab
+    def _createTabPage(self, schema, values, path):
         scroll = QScrollArea()
         scroll.setWidgetResizable(True)
         content = QWidget()
         layout = QVBoxLayout(content)
-        self._addSection(data, layout, path)
+        self._addSection(schema, values, layout, path)
         layout.addStretch()
         scroll.setWidget(content)
         return scroll
 
-    def _addSection(self, data, parent_layout, path):
-        scalars = {k: v for k, v in data.items() if not isinstance(v, dict)}
-        dicts = {k: v for k, v in data.items() if isinstance(v, dict)}
+    def _addSection(self, schema, values, parent_layout, path):
+        if not isinstance(values, dict):
+            values = {}
+
+        scalars = {k: v for k, v in schema.items() if isinstance(v, hcschema.Setting)}
+        sections = {k: v for k, v in schema.items() if isinstance(v, dict)}
 
         if scalars:
             form = QFormLayout()
             parent_layout.addLayout(form)
-            for key, value in scalars.items():
-                sub_path = path + (key,)
-                widget = self._makeWidget(key, value)
-                self._fields[sub_path] = widget
-                form.addRow(self._prettify(key) + ":", widget)
-
-        for key, value in dicts.items():
-            sub_path = path + (key,)
-            group = QGroupBox(self._prettify(key))
-            gl = QVBoxLayout(group)
-            self._addSection(value, gl, sub_path)
+            for key, setting in scalars.items():
+                value = values.get(key, setting.default)
+                widget = self._makeWidget(setting, value)
+                self._fields[path + (key,)] = widget
+                label = hcschema.label_for(key, setting)
+                if setting.help:
+                    widget.setToolTip(setting.help)
+                form.addRow(label + ":", widget)
+
+        for key, section in sections.items():
+            group = QGroupBox(hcschema.label_for(key))
+            group_layout = QVBoxLayout(group)
+            self._addSection(section, values.get(key, {}), group_layout, path + (key,))
             parent_layout.addWidget(group)
 
-    def _prettify(self, key):
-        if key == "hcnetcursor":
-            return "hcnetcursor"
-        return key.replace("_", " ").title()
-
-    def _makeWidget(self, key, value):
-        if isinstance(value, bool):
-            w = QCheckBox()
-            w.setChecked(value)
-            return w
-        if isinstance(value, int) and (key in self.BOOL_HINT_KEYS or value in (0, 1)):
-            w = QCheckBox()
-            w.setChecked(bool(value))
-            return w
-        if key in self.SLIDER_RANGES:
-            return self._makeSliderWidget(key, value)
-        if isinstance(value, float):
-            w = QDoubleSpinBox()
-            w.setDecimals(4)
-            w.setRange(-1e9, 1e9)
-            w.setSingleStep(0.1)
-            w.setValue(value)
-            return w
-        if isinstance(value, int):
-            w = QSpinBox()
-            w.setRange(-1000000, 1000000)
-            w.setValue(value)
-            return w
-        if isinstance(value, str):
-            if key == "desktop_mode":
-                w = QComboBox()
-                w.addItems(DESKTOP_MODES)
-                w.setCurrentText(value)
-                return w
-            if key == "node_shape":
-                w = QComboBox()
-                w.addItems(NODE_SHAPES)
-                if value not in NODE_SHAPES:
-                    w.addItem(value)
-                w.setCurrentText(value)
-                return w
-            if key == "zoom_center":
-                w = QComboBox()
-                w.addItem("Mouse Cursor", "mouse_cursor")
-                w.addItem("HC Cursor", "hc_cursor")
-                index = w.findData(value)
-                if index >= 0:
-                    w.setCurrentIndex(index)
-                else:
-                    w.addItem(value, value)
-                    w.setCurrentIndex(w.count() - 1)
-                return w
-            if key == "hcnetcursor":
-                w = QComboBox()
-                w.addItem("Enabled", True)
-                w.addItem("Disabled", False)
-                index = w.findData(bool(value))
-                if index >= 0:
-                    w.setCurrentIndex(index)
-                return w
-            w = QLineEdit(value)
-            return w
-        return QLabel(repr(value))
-
-    def _makeSliderWidget(self, key, value):
-        """Create a QSlider + QDoubleSpinBox combo that stay in sync."""
-        vmin, vmax, decimals = self.SLIDER_RANGES[key]
+    def _makeWidget(self, setting, value):
+        """Widget from the declared kind -- never guessed from the value's type."""
+        kind = setting.kind
+
+        if kind in ("bool", "flag"):
+            widget = QCheckBox()
+            widget.setChecked(bool(value))
+            return widget
+
+        if kind == "slider":
+            return self._makeSliderWidget(setting, value)
+
+        if kind == "float":
+            widget = QDoubleSpinBox()
+            widget.setDecimals(setting.decimals)
+            widget.setRange(-1e9, 1e9)
+            widget.setSingleStep(0.1)
+            widget.setValue(float(value))
+            return widget
+
+        if kind == "int":
+            widget = QSpinBox()
+            widget.setRange(-1000000, 1000000)
+            widget.setValue(int(value))
+            return widget
+
+        if kind == "choice":
+            widget = QComboBox()
+            for label, data in setting.choices:
+                widget.addItem(label, data)
+            index = widget.findData(value)
+            if index < 0:
+                # Preserve an unrecognised value rather than silently resetting.
+                widget.addItem(str(value), value)
+                index = widget.count() - 1
+            widget.setCurrentIndex(index)
+            return widget
+
+        return QLineEdit(str(value))
+
+    def _makeSliderWidget(self, setting, value):
+        """A QSlider + QDoubleSpinBox that stay in sync."""
+        vmin, vmax = setting.range
+        decimals = setting.decimals
+        steps = self.SLIDER_STEPS
 
         container = QWidget()
         layout = QHBoxLayout(container)
@@ -348,44 +323,34 @@ class HCSettingsPanel(QDialog):
 
         slider = QSlider(Qt.Horizontal)
         slider.setMinimum(0)
-        slider.setMaximum(1000)
-        slider.setPageStep(50)
+        slider.setMaximum(steps)
+        slider.setPageStep(steps // 20)
 
         spin = QDoubleSpinBox()
         spin.setDecimals(decimals)
         spin.setRange(vmin, vmax)
         spin.setSingleStep(round((vmax - vmin) / 100, decimals))
-        spin.setValue(value)
+        spin.setValue(float(value))
 
-        # Map float value -> slider position [0..1000]
-        def _float_to_pos(v):
-            return int((v - vmin) / (vmax - vmin) * 1000)
+        span = (vmax - vmin) or 1.0
 
-        def _pos_to_float(pos):
-            return vmin + (pos / 1000.0) * (vmax - vmin)
-
-        # Sync slider -> spinbox
-        def _on_slider_changed(pos):
+        def _onSliderChanged(pos):
             spin.blockSignals(True)
-            spin.setValue(round(_pos_to_float(pos), decimals))
+            spin.setValue(round(vmin + (pos / steps) * span, decimals))
             spin.blockSignals(False)
 
-        # Sync spinbox -> slider
-        def _on_spin_changed(v):
+        def _onSpinChanged(v):
             slider.blockSignals(True)
-            slider.setValue(_float_to_pos(v))
+            slider.setValue(int((v - vmin) / span * steps))
             slider.blockSignals(False)
 
-        slider.valueChanged.connect(_on_slider_changed)
-        spin.valueChanged.connect(_on_spin_changed)
-
-        # Set initial slider position
-        slider.setValue(_float_to_pos(value))
+        slider.valueChanged.connect(_onSliderChanged)
+        spin.valueChanged.connect(_onSpinChanged)
+        slider.setValue(int((float(value) - vmin) / span * steps))
 
-        layout.addWidget(slider, 3)   # slider takes most space
-        layout.addWidget(spin, 1)     # spinbox for precise input
+        layout.addWidget(slider, 3)
+        layout.addWidget(spin, 1)
 
-        # Store references so _readWidget can get the value
         container._spinbox = spin
         return container
 
@@ -393,11 +358,12 @@ class HCSettingsPanel(QDialog):
     """ IO """
 
 
-    def _readWidget(self, widget, original_value):
+    def _readWidget(self, setting, widget):
+        """Read a widget back in the JSON type the schema declares."""
         if isinstance(widget, QCheckBox):
-            return int(widget.isChecked()) if isinstance(original_value, int) and not isinstance(original_value, bool) else widget.isChecked()
-        if hasattr(widget, '_spinbox'):
-            # Slider+spinbox combo -- read from the spinbox
+            # `flag` settings round-trip as 0/1 ints, `bool` as true/false.
+            return int(widget.isChecked()) if setting.kind == "flag" else widget.isChecked()
+        if hasattr(widget, "_spinbox"):
             return widget._spinbox.value()
         if isinstance(widget, QDoubleSpinBox):
             return widget.value()
@@ -408,25 +374,17 @@ class HCSettingsPanel(QDialog):
             return data if data is not None else widget.currentText()
         if isinstance(widget, QLineEdit):
             return widget.text()
-        return original_value
+        return setting.default
 
     def _save(self):
         data = self.settings.prefsCopy()
         for path, widget in self._fields.items():
-            original = self._lookup(data, path)
-            new_value = self._readWidget(widget, original)
-            self._assign(data, path, new_value)
+            setting = hcschema.lookup(path)
+            if setting is None:
+                continue
+            self._assign(data, path, self._readWidget(setting, widget))
         self.settings.write(data)
 
-    def _lookup(self, data, path):
-        node = data
-        for key in path:
-            if isinstance(node, dict) and key in node:
-                node = node[key]
-            else:
-                return None
-        return node
-
     def _assign(self, data, path, value):
         node = data
         for key in path[:-1]:
diff --git a/python3.13libs/hc/hcstate.py b/python3.13libs/hc/hcstate.py
new file mode 100644
index 0000000..313ec61
--- /dev/null
+++ b/python3.13libs/hc/hcstate.py
@@ -0,0 +1,177 @@
+"""Per-pane and per-network state, kept in one place with one key scheme.
+
+The HC wrappers are deliberately stateless: Houdini hands out fresh SWIG
+wrappers on every callback and holding one past the current call is a
+dangling-pointer crash (see hcleader for the segfault that taught us). So
+anything that has to persist between events lives here instead of on the
+wrapper.
+
+That state used to sit in module globals scattered across five files, each
+keyed differently -- ``(pane.id(), pwd().path())`` in one place, ``pane.id()``
+in two others, ``editor.name()`` in a fourth. They disagreed: the hcnetcursor
+was tracked per network-within-pane while the selection signature compared
+against it was tracked per pane, so descending into a subnetwork compared the
+child's selection against the parent's.
+
+Two scopes are legitimate and both are offered:
+
+``PANE``    -- belongs to the pane regardless of what it is looking at
+               (modifier state, focused parameter, the grid pref mirror).
+``NETWORK`` -- belongs to a specific network inside a specific pane
+               (hcnetcursor position, selection and overlay signatures).
+
+Entries for panes Houdini has closed are swept periodically; pane ids get
+reused, and a reopened pane inheriting a stale cursor position was the
+practical symptom.
+"""
+
+import time
+
+import hou
+
+
+PANE = "pane"
+NETWORK = "network"
+
+_SWEEP_INTERVAL = 10.0
+
+_stores = {}
+_last_sweep = 0.0
+
+
+def _houTab(obj):
+    """Accept an HC wrapper, a hou.PaneTab, or anything with .pane()."""
+    return getattr(obj, "hou_tab", obj)
+
+
+def paneId(obj):
+    """Stable id for the pane holding `obj`, or a per-object fallback."""
+    tab = _houTab(obj)
+    pane = None
+    if hasattr(tab, "pane"):
+        try:
+            pane = tab.pane()
+        except hou.ObjectWasDeleted:
+            pane = None
+    if pane is not None:
+        return pane.id()
+    if hasattr(tab, "id"):
+        return tab.id()
+    return id(tab)
+
+
+def paneKey(obj):
+    return paneId(obj)
+
+
+def networkKey(obj):
+    """(pane id, network path) -- distinguishes each network within a pane."""
+    tab = _houTab(obj)
+    try:
+        path = tab.pwd().path()
+    except (AttributeError, hou.ObjectWasDeleted):
+        path = ""
+    return (paneId(obj), path)
+
+
+_KEYERS = {PANE: paneKey, NETWORK: networkKey}
+
+
+class Store:
+    """A named dict of per-pane or per-network values.
+
+    Construct one at module level and use it in place of a bare global dict:
+
+        _cursors = hcstate.Store("hcnetcursor", hcstate.NETWORK)
+        state = _cursors.get(editor)
+        _cursors.set(editor, state)
+    """
+
+    def __init__(self, name, scope):
+        if scope not in _KEYERS:
+            raise ValueError(f"unknown state scope: {scope}")
+        self.name = name
+        self.scope = scope
+        self._data = {}
+        _stores[name] = self
+
+    def key(self, obj):
+        return _KEYERS[self.scope](obj)
+
+    def get(self, obj, default=None):
+        return self._data.get(self.key(obj), default)
+
+    def set(self, obj, value):
+        self._data[self.key(obj)] = value
+        sweep()
+        return value
+
+    def setdefault(self, obj, factory):
+        """Value for `obj`, calling `factory()` to create it if absent."""
+        key = self.key(obj)
+        if key not in self._data:
+            self._data[key] = factory()
+            sweep()
+        return self._data[key]
+
+    def pop(self, obj, default=None):
+        return self._data.pop(self.key(obj), default)
+
+    def clear(self):
+        self._data.clear()
+
+    def __len__(self):
+        return len(self._data)
+
+    def _evict(self, live_pane_ids):
+        for key in list(self._data):
+            pane_id = key[0] if isinstance(key, tuple) else key
+            if pane_id not in live_pane_ids:
+                del self._data[key]
+
+
+def livePaneIds():
+    ids = set()
+    desktop = hou.ui.curDesktop() if hou.isUIAvailable() else None
+    if desktop is not None:
+        for pane in desktop.panes():
+            ids.add(pane.id())
+        for panel in hou.ui.floatingPanels():
+            if panel.qtParentWindow() is None:
+                continue
+            for pane in panel.panes():
+                ids.add(pane.id())
+    return ids
+
+
+def sweep(force=False):
+    """Drop entries for panes that no longer exist.
+
+    Rate-limited, because writes happen on the network editor's event path.
+    """
+    global _last_sweep
+    # Cheapest check first: this runs on every state write, which for the
+    # hcnetcursor means every network editor UI event.
+    now = time.monotonic()
+    if not force and (now - _last_sweep) < _SWEEP_INTERVAL:
+        return
+    _last_sweep = now
+    if not hou.isUIAvailable():
+        return
+
+    live = livePaneIds()
+    if not live:
+        # A desktop swap can momentarily report nothing; never evict on that.
+        return
+    for store in _stores.values():
+        store._evict(live)
+
+
+def info():
+    """Entry counts per store, for debugging."""
+    return {name: len(store) for name, store in sorted(_stores.items())}
+
+
+def clearAll():
+    for store in _stores.values():
+        store.clear()
diff --git a/python3.13libs/hc/hctab.py b/python3.13libs/hc/hctab.py
index fde882e..0fc1e55 100644
--- a/python3.13libs/hc/hctab.py
+++ b/python3.13libs/hc/hctab.py
@@ -1,5 +1,6 @@
 import hou
 import types
+from .hccommands import command
 
 class HCTab():
     def __init__(self, hou_tab):
@@ -13,9 +14,11 @@ class HCTab():
 
     """ Status """
 
+    @command("Close Tab")
     def close(self):
         self.hou_tab.close()
 
+    @command("Close Other Tabs")
     def closeOtherTabs(self):
         for tab in self.pane().tabs():
             if tab.hou_tab != self.hou_tab:
@@ -94,6 +97,7 @@ class HCTab():
                 type_map[label] = tab_type
         return type_map
 
+    @command("Set Tab Type")
     def changeTypeDialog(self):
         from .hcwidgets import HCWidgets
 
@@ -144,9 +148,26 @@ class HCTab():
     def showNetworkControls(self, value):
         self.hou_tab.showNetworkControls(value)
 
+    @command("Toggle Path", tabs=("HCPathTab", "HCParameterTab"))
     def toggleNetworkControls(self):
         if self.hasNetworkControls():
             self.showNetworkControls(not self.isShowingNetworkControls())
 
+
+    """ Chrome """
+
+    # "Chrome" is whatever bars and controls a tab draws around its content.
+    # HCSession.toggleMenus and isVisibleMenus used to switch on tab.type()
+    # and call a different set of methods in each branch; each tab class now
+    # answers for itself and those two methods just iterate.
+
+    def isChromeVisible(self):
+        return bool(self.hasNetworkControls() and self.isShowingNetworkControls())
+
+    def showChrome(self, visible):
+        if self.hasNetworkControls():
+            self.showNetworkControls(visible)
+
+    @command("Toggle Pin", tabs=("HCPathTab", "HCParameterTab"))
     def togglePin(self):
         self.setPin(not self.isPin())
diff --git a/python3.13libs/nodegraphhooks.py b/python3.13libs/nodegraphhooks.py
index d6f2988..61607f5 100755
--- a/python3.13libs/nodegraphhooks.py
+++ b/python3.13libs/nodegraphhooks.py
@@ -1,38 +1,40 @@
 import hou
-import time
 from canvaseventtypes import *
 import nodegraphbase as base
 import nodegraphdisplay as display
 from hc import HCNetworkEditor
+from hc import hcstate
 
-_modifier_state_by_editor = {}
-_selection_state_by_editor = {}
+# Modifier state belongs to the pane; the selection signature is compared
+# against the hcnetcursor, which is per network, so it must match that scope.
+_modifiers = hcstate.Store("nodegraph_modifiers", hcstate.PANE)
+_selections = hcstate.Store("nodegraph_selection", hcstate.NETWORK)
 
 
-class _PendingSelectionSyncAction(base.PendingDelayedAction):
-    def __init__(self, editor, editor_key, delay=0.0):
-        super().__init__(editor, delay)
-        self.editor_key = editor_key
+def _syncSelection(hc_editor, editor):
+    """Refit the hcnetcursor when the selection envelope has changed."""
+    signature = hc_editor.selectedNodesEnvelopeSignature()
+    if signature == _selections.get(editor):
+        return
+    _selections.set(editor, signature)
+    if signature:
+        hc_editor.fitHcnetcursorToSelectedNodes()
+    else:
+        # Preserve the current hcnetcursor size when the selection is cleared.
+        # Manual reset remains available via the reset command.
+        hc_editor.updateCurrentNodeOverlay()
+
 
+class _PendingSelectionSyncAction(base.PendingDelayedAction):
     def runDelayedAction(self):
-        hc_editor = HCNetworkEditor(self.editor)
-        selection_state = hc_editor.selectedNodesEnvelopeSignature()
-        previous_selection_state = _selection_state_by_editor.get(self.editor_key)
-        if selection_state != previous_selection_state:
-            _selection_state_by_editor[self.editor_key] = selection_state
-            if selection_state:
-                hc_editor.fitHcnetcursorToSelectedNodes()
-            else:
-                # Preserve the current hcnetcursor size when the selection is
-                # cleared. Manual reset remains available via the reset command.
-                hc_editor.updateCurrentNodeOverlay()
+        _syncSelection(HCNetworkEditor(self.editor), self.editor)
 
 
-def _queueSelectionSync(editor, editor_key, pending_actions):
+def _queueSelectionSync(editor, pending_actions):
     for action in pending_actions:
         if isinstance(action, _PendingSelectionSyncAction) and action.editor == editor:
             return
-    pending_actions.append(_PendingSelectionSyncAction(editor, editor_key))
+    pending_actions.append(_PendingSelectionSyncAction(editor))
 
 
 def createEventHandler(uievent, pending_actions):
@@ -40,23 +42,17 @@ def createEventHandler(uievent, pending_actions):
 
     if editor is not None:
         hc_editor = HCNetworkEditor(editor)
-        pane = editor.pane()
-        editor_key = pane.id() if pane is not None else id(editor)
         modifierstate = getattr(uievent, "modifierstate", None)
-        current_state = {
-            "ctrl": bool(getattr(modifierstate, "ctrl", False)),
-            "shift": bool(getattr(modifierstate, "shift", False)),
-            "alt": bool(getattr(modifierstate, "alt", False)),
-        }
-        previous_state = _modifier_state_by_editor.get(
-            editor_key,
-            {"ctrl": False, "shift": False, "alt": False},
+        current_state = (
+            bool(getattr(modifierstate, "ctrl", False)),
+            bool(getattr(modifierstate, "shift", False)),
+            bool(getattr(modifierstate, "alt", False)),
         )
-        if current_state != previous_state:
+        if current_state != _modifiers.get(editor, (False, False, False)):
             # Houdini repaints the graph when a modifier changes, which can
             # drop the cursor's background image; force it back on.
             hc_editor.refreshHcnetcursor()
-            _modifier_state_by_editor[editor_key] = current_state
+            _modifiers.set(editor, current_state)
         else:
             # Runs on every event, mouse moves included -- this is a no-op
             # unless the drawn overlay would actually differ.
@@ -66,19 +62,9 @@ def createEventHandler(uievent, pending_actions):
         if editor is not None:
             if uievent.eventtype == 'mousedown' and not uievent.located:
                 hc_editor.moveHcnetcursorToPosition(uievent.mousepos)
-            selection_state = hc_editor.selectedNodesEnvelopeSignature()
-            previous_selection_state = _selection_state_by_editor.get(editor_key)
-            if selection_state != previous_selection_state:
-                _selection_state_by_editor[editor_key] = selection_state
-                if selection_state:
-                    hc_editor.fitHcnetcursorToSelectedNodes()
-                else:
-                    # Preserve the current hcnetcursor size when the selection
-                    # is cleared. Manual reset remains available via the reset
-                    # command.
-                    hc_editor.updateCurrentNodeOverlay()
+            _syncSelection(hc_editor, editor)
             if uievent.eventtype in ('mousedown', 'mouseup', 'mousedoubleclick'):
-                _queueSelectionSync(editor, editor_key, pending_actions)
+                _queueSelectionSync(editor, pending_actions)
         # Ctrl+scroll zoom disabled
         return None, False
 
diff --git a/tools/check.py b/tools/check.py
index 657878b..db0befb 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -34,7 +34,10 @@ from hc import (  # noqa: E402
     HCSession,
     HCSettings,
     HCTab,
+    hcschema,
+    hcstate,
 )
+from hc import hccommands  # noqa: E402
 from hc.hcmaps import HCMaps  # noqa: E402
 
 
@@ -95,23 +98,159 @@ def check_settings():
 
     check("prefs() is cached on the hot path", cache_is_warm)
 
-
-def check_maps():
-    print("HCMaps")
+    def schema_covers_the_file():
+        """Every section in the shipped file must be declared in the schema.
+
+        keycam used to be absent from DEFAULTS entirely, so keycam.py and
+        hcguides.py had nothing to fall back on.
+        """
+        on_disk = set(settings.prefs())
+        declared = set(hcschema.defaults())
+        undeclared = on_disk - declared
+        assert not undeclared, f"settings not in the schema: {sorted(undeclared)}"
+        return f"{len(declared)} top-level sections"
+
+    check("schema declares every section", schema_covers_the_file)
+
+    def keycam_survives_a_missing_section():
+        merged = HCSettings._merged(HCSettings.DEFAULTS, {"desktop_mode": "attached"})
+        # The exact chains keycam.py and hcguides.py walk.
+        assert merged["keycam"]["units"]["delta_r"] is not None
+        assert merged["keycam"]["guides"]["axis_size"] is not None
+        assert settings.keycam("units").get("delta_r") is not None
+        assert settings.section("nope", "nothing") == {}, "section() must not return None"
+        return "keycam reads survive an absent section"
+
+    check("keycam has defaults", keycam_survives_a_missing_section)
+
+    def widgets_come_from_declared_kinds():
+        """delta_ow/delta_z are step magnitudes that happen to equal 1; the old
+        panel guessed from the value and rendered them as checkboxes."""
+        for name in ("delta_ow", "delta_z"):
+            setting = hcschema.lookup(("keycam", "units", name))
+            assert setting is not None, f"{name} not declared"
+            assert setting.kind == "float", f"{name} is {setting.kind}, expected float"
+        assert hcschema.lookup(("keycam", "guides", "bbox")).kind == "flag"
+        assert hcschema.lookup(("node_graph", "hcnetcursor")).kind == "bool"
+        return "step magnitudes declared float, flags declared flag"
+
+    check("widget kinds are declared, not guessed", widgets_come_from_declared_kinds)
+
+
+def check_commands():
+    print("commands")
     maps = HCMaps()
     session, pane = blank(HCSession), blank(HCPane)
-    specs = (
-        ("base", "tab_map_base", HCTab),
-        ("path", "tab_map_path", HCPathTab),
-        ("parm", "tab_map_path", HCParameterTab),
-        ("network editor", "tab_map_network_editor", HCNetworkEditor),
-        ("scene viewer", "tab_map_scene_viewer", HCSceneViewer),
-    )
-    for label, name, cls in specs:
-        check(
-            f"{label} map binds",
-            lambda n=name, c=cls: f"{len(getattr(maps, n)(session, pane, blank(c)))} commands",
-        )
+
+    for label, cls in (
+        ("network editor", HCNetworkEditor),
+        ("scene viewer", HCSceneViewer),
+        ("details view", HCPathTab),
+        ("parameters", HCParameterTab),
+        ("other tabs", HCTab),
+    ):
+        def binds(c=cls):
+            commands = maps.commands(session, pane, blank(c))
+            assert commands, "no commands bound"
+            for name, method in commands.items():
+                assert callable(method), f"{name} is not callable"
+            return f"{len(commands)} commands"
+
+        check(f"{label} panel binds", binds)
+
+    def scoping_holds():
+        """Toggle Path/Pin live on HCTab but only apply to path-like tabs."""
+        network = maps.commands(session, pane, blank(HCNetworkEditor))
+        parm = maps.commands(session, pane, blank(HCParameterTab))
+        assert "Toggle Pin" in parm, "path tabs lost Toggle Pin"
+        assert "Toggle Pin" not in network, "Toggle Pin leaked into network editors"
+        assert "Replace Node" in network, "network editors lost Replace Node"
+        assert "Replace Node" not in parm, "Replace Node leaked into parameter tabs"
+        return "tab-scoped commands stay scoped"
+
+    check("command scoping", scoping_holds)
+
+    def labels_are_unique():
+        seen = {}
+        for cls in (HCSession, HCPane, HCTab, HCPathTab, HCParameterTab,
+                    HCNetworkEditor, HCSceneViewer):
+            for label, (name, _) in hccommands.declared(cls).items():
+                if label in seen and seen[label] != (cls.__name__, name):
+                    # Inherited commands legitimately repeat; a genuine clash is
+                    # two different methods claiming one label.
+                    prev_cls, prev_name = seen[label]
+                    if prev_name != name:
+                        raise AssertionError(
+                            f"{label!r} claimed by {prev_cls}.{prev_name} and {cls.__name__}.{name}"
+                        )
+                seen[label] = (cls.__name__, name)
+        return f"{len(seen)} distinct labels"
+
+    check("no duplicate command labels", labels_are_unique)
+
+
+def check_state():
+    print("state")
+
+    def scopes_differ():
+        assert hcstate.PANE != hcstate.NETWORK
+        store = hcstate.Store("_check_pane", hcstate.PANE)
+        net = hcstate.Store("_check_network", hcstate.NETWORK)
+        assert store.scope == hcstate.PANE and net.scope == hcstate.NETWORK
+        return "PANE and NETWORK scopes registered"
+
+    check("two declared scopes", scopes_differ)
+
+    def eviction_drops_dead_panes():
+        store = hcstate.Store("_check_evict", hcstate.PANE)
+        store._data[999001] = "stale"
+        store._data[999002] = "also stale"
+        assert len(store) == 2
+        store._evict(live_pane_ids={999002})
+        assert len(store) == 1 and 999002 in store._data, "wrong entry evicted"
+        store.clear()
+        return "closed-pane entries are dropped"
+
+    check("eviction", eviction_drops_dead_panes)
+
+    def network_keys_separate_networks():
+        store = hcstate.Store("_check_netkey", hcstate.NETWORK)
+        store._data[(7, "/obj")] = "parent"
+        store._data[(7, "/obj/geo1")] = "child"
+        # Same pane, different network: descending must not read the parent's
+        # entry. This was the selection-signature bug.
+        assert store._data[(7, "/obj")] != store._data[(7, "/obj/geo1")]
+        store._evict(live_pane_ids={7})
+        assert len(store) == 2, "live pane entries were evicted"
+        store._evict(live_pane_ids=set())
+        store.clear()
+        return "per-network entries stay distinct within a pane"
+
+    check("network scope", network_keys_separate_networks)
+
+
+def check_chrome():
+    print("chrome")
+
+    def every_tab_answers():
+        for cls in (HCTab, HCPathTab, HCParameterTab, HCNetworkEditor, HCSceneViewer):
+            for name in ("isChromeVisible", "showChrome"):
+                assert callable(getattr(cls, name, None)), f"{cls.__name__} lacks {name}"
+        return "all tab classes implement the chrome pair"
+
+    check("polymorphic chrome", every_tab_answers)
+
+    def overrides_extend_the_base():
+        # Each override must call up, or a subclass silently drops the base
+        # network-controls handling.
+        import inspect
+        for cls in (HCNetworkEditor, HCSceneViewer):
+            for name in ("isChromeVisible", "showChrome"):
+                source = inspect.getsource(getattr(cls, name))
+                assert "super()" in source, f"{cls.__name__}.{name} does not call super()"
+        return "subclass overrides chain to HCTab"
+
+    check("chrome overrides chain", overrides_extend_the_base)
 
 
 def check_node_ops():
@@ -188,7 +327,9 @@ def check_node_ops():
 
 def main():
     check_settings()
-    check_maps()
+    check_commands()
+    check_state()
+    check_chrome()
     check_node_ops()
     print(f"\n{passed} passed, {failed} failed")
     return 1 if failed else 0
diff --git a/viewer_states/keycam.py b/viewer_states/keycam.py
index 62dd07b..8886450 100644
--- a/viewer_states/keycam.py
+++ b/viewer_states/keycam.py
@@ -33,9 +33,9 @@ class State(object):
         self.cam = HCCam(self.cam_node, self.scene_viewer)
         # Restore captured viewport state so keycam starts where you were
         self.cam.initFromState(self._captured_state)
-        prefs = HCSettings().prefs().get('keycam')
-        self.cam.delta_r = prefs.get('units').get('delta_r')
-        self.cam.delta_t = prefs.get('units').get('delta_t')
+        units = HCSettings().keycam('units')
+        self.cam.delta_r = units.get('delta_r')
+        self.cam.delta_t = units.get('delta_t')
         # drag_sensitivity is read live from settings each event (not cached)
         # so changes in HC Settings take effect immediately without reload
         self._settings = HCSettings()
@@ -133,7 +133,7 @@ class State(object):
                 # Vertical movement   -> pitch (up/down)
                 # Sensitivity 0..1: higher = more degrees per pixel (more responsive)
                 # Maps to approx 0.03 deg/px (smooth) .. 3.0 deg/px (twitchy)
-                sensitivity = self._settings.prefs().get('keycam', {}).get('drag_sensitivity', 0.25)
+                sensitivity = self._settings.keycam().get('drag_sensitivity', 0.25)
                 scale = 0.03 + sensitivity * 2.97
 
                 if abs(dx) >= 1: