# 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 ``, `