diff --git a/AGENTS.md b/AGENTS.md
index 47b4015..7debfbb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,7 +41,17 @@ Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml
### Frontend (hoover)
-Custom reactive SPA framework at `webui/static/hoover/`. Provides VDOM rendering, reactivity, router, WebSocket bindings, API helpers, and shared UI components. Exported via `hoover/index.js`. Pages in `webui/static/pages/` each define a route using `definePage()`. Bootstrap is `webui/static/app.js`. No build step — served raw.
+Custom reactive SPA framework at `webui/static/hoover/`. See `docs/hoover.md` for full API reference.
+
+Conventions:
+- All imports from `/static/hoover/index.js` (barrel export of reactivity, VDOM, router, API, components).
+- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })` as default.
+- Bootstrap: `webui/static/app.js` mounts two render roots (`#sidebar`, `#main`), then `connect()` for WS.
+- `h()` builds VNodes; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff.
+- Events use `on:` prefix (`on:click`, `on:submit`). `class` prop accepts object.
+- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
+- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission. `ToastContainer()` in main root.
+- No build step — ES modules served raw. Assets versioned via `?v=N` query string.
### Daemon Endpoints
diff --git a/docs/architecture.md b/docs/architecture.md
index 0114225..e879279 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -138,6 +138,33 @@ The following file system locations are used for integration with system service
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
+## Frontend Architecture
+
+The web UI is a single-page application built on **Hoover**, a custom lightweight VDOM framework. See [Hoover Framework Reference](hoover.md) for the complete API.
+
+### Request Flow (Frontend)
+
+```
+Client requests index.html ──→ nginx ──→ Flask (server-side __WS_URL_PLACEHOLDER__ substitution)
+Client loads app.js ──→ Hoover initializes, mounts #sidebar and #main render roots
+Hoover connects WebSocket ──→ daemon/ws (127.0.0.1:9091)
+Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM
+User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld
+WebSocket message (versions) ──→ topic match ──→ page load() re-executed ──→ state updated ──→ render engine patches DOM
+```
+
+### Component Model
+
+Each route is a `definePage()` component with reactive state, async data loading, and WebSocket auto-refresh. Pages are mounted using `hComp(page, key)` in the router, where the key determines lifecycle boundaries. The same key reuses the component instance (preserving state); a different key unmounts the old page and mounts the new one.
+
+### No Build Step
+
+All JavaScript is served as ES modules. The `?v=N` query string param version-pins asset imports for cache invalidation. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching.
+
+### WebSocket Broadcast
+
+The daemon broadcasts state-change notifications via WebSocket. Hoover's `subscribe` mechanism maps page-level topic subscriptions to automatic `load()` re-executions. Messages are debounced (300ms) and in-flight loads are aborted before re-loading, ensuring the UI always displays the latest available data.
+
## Zone Model
The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level:
diff --git a/docs/hoover.md b/docs/hoover.md
new file mode 100644
index 0000000..b364aa6
--- /dev/null
+++ b/docs/hoover.md
@@ -0,0 +1,963 @@
+# Hoover — SPA Framework
+
+Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It provides a lightweight VDOM rendering engine, reactive state, a hash-based router, WebSocket bindings, and shared UI components. No build step is required — all code runs as ES modules served raw by nginx.
+
+## Overview
+
+| Module | File | Purpose |
+|---|---|---|
+| Reactivity | `reactivity.js` | Reactive Proxy state with batched render requests |
+| VDOM | `vdom.js` | Virtual DOM: `h()` factory, diffing, patching |
+| Render | `render.js` | Render engine: container-level diffing, component lifecycle |
+| Component | `component.js` | Page definitions, lifecycle hooks, state caching |
+| Router | `router.js` | Hash-based SPA router, `Link` navigation component |
+| WebSocket | `websocket.js` | Auto-reconnect WS, topic subscriptions, auto-refresh |
+| API | `api.js` | JSON fetch wrapper, toast notifications, form submissions |
+| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing |
+| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts |
+| Barrel | `index.js` | Single import point for all public APIs |
+
+All public APIs are exported from `hoover/index.js`. Pages and app bootstrap import from this single entry point.
+
+## Architecture
+
+```
+index.html — static shell with #sidebar, #main, #modal-root
+ └── app.js — SPA bootstrap
+ ├── render(sidebarEl, Sidebar) — sidebar render root
+ ├── render(mainEl, MainContent) — main content render root
+ └── connect() — WebSocket lifecycle
+```
+
+The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots. The server substitutes `__WS_URL_PLACEHOLDER__` in `index.html` to set `window.__WS_URL__` for WebSocket routing.
+
+Each render root registers a render function via `render(container, fn)`. When reactive state changes, all registered render functions re-execute in a single batched microtask, producing new VNodes that are diffed against the previous tree and patched into the DOM.
+
+## Bootstrap
+
+The app starts from `webui/static/app.js`:
+
+```javascript
+import { h, render, Link, hComp, ToastContainer, connect, reactive } from '/static/hoover/index.js?v=4';
+
+// 1. Create reactive router state
+const router = {
+ state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
+ component() {
+ const name = this.state.path.replace(/^\//, '');
+ const page = Pages[name] || NotFoundPage;
+ return hComp(page, this.state.path);
+ },
+};
+
+// 2. Listen for hash changes
+window.addEventListener('hashchange', () => {
+ router.state.path = location.hash.slice(1) || '/dashboard';
+});
+
+// 3. Mount render roots
+render(sidebarEl, Sidebar);
+render(mainEl, MainContent);
+
+// 4. Start WebSocket (deferred to avoid initial render conflict)
+setTimeout(connect, 0);
+```
+
+## Reactivity
+
+### `reactive(obj)`
+
+Wraps a plain object in a reactive `Proxy`. Any property assignment that changes the value automatically schedules a batched re-render across all registered render roots.
+
+```javascript
+const state = reactive({ data: null, loading: true, error: null });
+
+// Triggers re-render
+state.loading = false;
+state.data = result;
+```
+
+Multiple property mutations in the same microtask tick produce a single render cycle. Read properties normally; only writes trigger updates.
+
+**Important:** Hoover's reactivity proxy intercepts property `set` only. It does not track property additions/deletions, array mutations (e.g., `push`, `splice`), or nested object deep changes. Always mutate top-level properties by assignment:
+
+```javascript
+// Correct — assigns a new array
+state.items = [...state.items, newItem];
+
+// Incorrect — push won't trigger re-render
+state.items.push(newItem);
+```
+
+### `requestUpdate()`
+
+Manually schedule a re-render. Only one microtask is queued regardless of how many times it's called in the same tick.
+
+## Virtual DOM
+
+### `h(tag, props, ...children)`
+
+The VNode factory. Three forms:
+
+```javascript
+// Element
+h('div', { class: 'card' }, h('span', null, 'Hello'))
+
+// Text node
+h('#text', 'some text')
+
+// Component (Hoover component, not function — must use hComp or h('#comp', ...))
+h('#comp', { component: MyPage, key: '/dashboard' }, [])
+```
+
+**Children flattening:** `null`, `undefined`, and `false` children are filtered out. String and number primitives are automatically converted to text VNodes.
+
+### Props
+
+| Prop | Behavior |
+|---|---|
+| `class` | String or object (`{ active: bool }` → truthy keys joined as class names) |
+| `style` | String or object (`{ color: 'red' }` → applies to `el.style`) |
+| `html` / `innerHTML` | Sets `innerHTML` directly |
+| `textContent` | Sets `textContent` directly |
+| `value` | On ``, `