diff --git a/docs/superpowers/plans/2026-06-02-void-v2-plan6-sacred-valley.md b/docs/superpowers/plans/2026-06-02-void-v2-plan6-sacred-valley.md
new file mode 100644
index 0000000..ffb19d3
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-02-void-v2-plan6-sacred-valley.md
@@ -0,0 +1,1922 @@
+# Plan 6 — Sacred Valley Widgets — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the `#/sacred-valley` stub with a working two-band homelab dashboard — draggable data cards + Little Blue's read-only Health band — on Void 2.0's real backend.
+
+**Architecture:** New backend endpoints (`/api/dashboard/layout`, `/api/weather`, `/api/host`, speedtest, `/api/health/*`, `/api/icons`) follow the existing `Router`+`zod`+`asyncWrap`+repo pattern mounted under `agentOrOwner`. Health checks + hourly speedtest run as pg-boss jobs enqueued by `node-cron`. The frontend is the existing no-build vanilla-JS SPA: each card is a self-contained ESM module with a uniform `{id,title,size,mount,start,stop}` contract, rendered into a CSS-grid with hand-rolled drag-to-reorder, layout persisted server-side.
+
+**Tech Stack:** Node 22 (ESM), Express 5, pg-boss, node-cron, Postgres 16, vitest+supertest, vanilla browser ESM. No new runtime deps (config is JSON, not YAML, to avoid adding `js-yaml`).
+
+**Spec:** `docs/superpowers/specs/2026-06-02-void-v2-plan6-sacred-valley-design.md`
+
+**Conventions to follow (verified in repo):**
+- Migrations: `lib/db/migrations/NNN_name.sql`, plain SQL, applied in filename order by `lib/db/migrate.js`. Next free numbers: **012, 013, 014**.
+- Routes: `export const router = Router()`, validate via `validate({query/body})` (zod), wrap handlers in `asyncWrap`, call a repo in `lib/db/repos/`. Mount in `lib/api/index.js` (already behind `agentOrOwner`). Owner-only: `router.use(requireOwner)` from `lib/api/cap.js`.
+- Tests: `tests/api/*.test.js` use `setup()` from `tests/api/helpers.js` (resetDb + migrateUp + `OWNER_TOKEN='test-token'`, `ownerHeaders`). Repo tests in `tests/repos/`. Run a single file: `npx vitest run tests/api/NAME.test.js`.
+- Frontend views export `render(main, ctx)`. Build DOM only with `el()/mount()/safeHref()` from `public/dom.js` — never `innerHTML` from data.
+- `VERSION` const is `server.js:11`.
+
+---
+
+## Phase 1 — Grid framework, chrome, reorder, persistence (proven on clock/weather/host-perf)
+
+### Task 1: `dashboard_layout` table + repo
+
+**Files:**
+- Create: `lib/db/migrations/012_dashboard_layout.sql`
+- Create: `lib/db/repos/dashboard_layout.js`
+- Test: `tests/repos/dashboard_layout.test.js`
+
+- [ ] **Step 1: Write the migration**
+
+```sql
+-- 012_dashboard_layout.sql
+-- Single global, owner-scoped dashboard layout. One logical row keyed by a
+-- stable owner key (v2 is single-owner; 'owner' is the only key for now).
+CREATE TABLE dashboard_layout (
+ owner_key text PRIMARY KEY DEFAULT 'owner',
+ card_order jsonb NOT NULL DEFAULT '[]'::jsonb, -- ["clock","weather",...]
+ hidden jsonb NOT NULL DEFAULT '[]'::jsonb, -- ["speedtest"]
+ sizes jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"weather":"s"}
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+```
+
+- [ ] **Step 2: Write the failing repo test**
+
+```js
+// tests/repos/dashboard_layout.test.js
+import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
+import { resetDb } from '../helpers/db.js';
+import { migrateUp } from '../../lib/db/migrate.js';
+import * as repo from '../../lib/db/repos/dashboard_layout.js';
+
+beforeAll(async () => { await resetDb(); await migrateUp(); });
+
+describe('dashboard_layout repo', () => {
+ it('returns defaults when unset', async () => {
+ const l = await repo.get();
+ expect(l).toEqual({ card_order: [], hidden: [], sizes: {} });
+ });
+
+ it('upserts and reads back', async () => {
+ await repo.put({ card_order: ['clock', 'weather'], hidden: ['jobs'], sizes: { weather: 's' } });
+ const l = await repo.get();
+ expect(l.card_order).toEqual(['clock', 'weather']);
+ expect(l.hidden).toEqual(['jobs']);
+ expect(l.sizes).toEqual({ weather: 's' });
+ });
+
+ it('second put overwrites the same single row', async () => {
+ await repo.put({ card_order: ['host-perf'], hidden: [], sizes: {} });
+ const l = await repo.get();
+ expect(l.card_order).toEqual(['host-perf']);
+ });
+});
+```
+
+- [ ] **Step 3: Run it — expect FAIL** (`Cannot find module dashboard_layout.js`)
+
+Run: `npx vitest run tests/repos/dashboard_layout.test.js`
+
+- [ ] **Step 4: Implement the repo**
+
+```js
+// lib/db/repos/dashboard_layout.js
+import { pool } from '../pool.js';
+
+const DEFAULTS = { card_order: [], hidden: [], sizes: {} };
+
+export async function get() {
+ const { rows } = await pool.query(
+ `SELECT card_order, hidden, sizes FROM dashboard_layout WHERE owner_key = 'owner'`
+ );
+ return rows[0] || { ...DEFAULTS };
+}
+
+export async function put({ card_order = [], hidden = [], sizes = {} }) {
+ await pool.query(
+ `INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, updated_at)
+ VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, now())
+ ON CONFLICT (owner_key) DO UPDATE
+ SET card_order = EXCLUDED.card_order,
+ hidden = EXCLUDED.hidden,
+ sizes = EXCLUDED.sizes,
+ updated_at = now()`,
+ [JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes)]
+ );
+ return get();
+}
+```
+
+- [ ] **Step 5: Run — expect PASS.** `npx vitest run tests/repos/dashboard_layout.test.js`
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/db/migrations/012_dashboard_layout.sql lib/db/repos/dashboard_layout.js tests/repos/dashboard_layout.test.js
+git commit -m "feat(dashboard): dashboard_layout table + repo"
+```
+
+---
+
+### Task 2: `/api/dashboard/layout` route (owner-only GET/PUT)
+
+**Files:**
+- Create: `lib/api/routes/dashboard.js`
+- Modify: `lib/api/index.js` (import + mount)
+- Test: `tests/api/dashboard.test.js`
+
+- [ ] **Step 1: Write the failing test**
+
+```js
+// tests/api/dashboard.test.js
+import { describe, it, expect, beforeAll } from 'vitest';
+import request from 'supertest';
+import { setup } from './helpers.js';
+
+let app, ownerHeaders;
+beforeAll(async () => { ({ app, ownerHeaders } = await setup()); });
+
+describe('dashboard layout api', () => {
+ it('401 without auth', async () => {
+ const res = await request(app).get('/api/dashboard/layout');
+ expect(res.status).toBe(401);
+ });
+
+ it('GET returns defaults', async () => {
+ const res = await request(app).get('/api/dashboard/layout').set(ownerHeaders);
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {} });
+ });
+
+ it('PUT persists and GET reflects it', async () => {
+ const body = { card_order: ['clock', 'weather'], hidden: [], sizes: { weather: 's' } };
+ const put = await request(app).put('/api/dashboard/layout').set(ownerHeaders).send(body);
+ expect(put.status).toBe(200);
+ const get = await request(app).get('/api/dashboard/layout').set(ownerHeaders);
+ expect(get.body.card_order).toEqual(['clock', 'weather']);
+ expect(get.body.sizes).toEqual({ weather: 's' });
+ });
+
+ it('PUT rejects a bad size value', async () => {
+ const res = await request(app).put('/api/dashboard/layout').set(ownerHeaders)
+ .send({ card_order: [], hidden: [], sizes: { weather: 'huge' } });
+ expect(res.status).toBe(400);
+ });
+});
+```
+
+- [ ] **Step 2: Run — expect FAIL** (404 route not found). `npx vitest run tests/api/dashboard.test.js`
+
+- [ ] **Step 3: Implement the route**
+
+```js
+// lib/api/routes/dashboard.js
+import { Router } from 'express';
+import { z } from 'zod';
+import { validate } from '../validate.js';
+import { asyncWrap } from '../errors.js';
+import { requireOwner } from '../cap.js';
+import * as repo from '../../db/repos/dashboard_layout.js';
+
+export const router = Router();
+router.use(requireOwner);
+
+const layoutSchema = z.object({
+ card_order: z.array(z.string()).default([]),
+ hidden: z.array(z.string()).default([]),
+ sizes: z.record(z.enum(['s', 'm', 'l'])).default({})
+});
+
+router.get('/layout', asyncWrap(async (_req, res) => {
+ res.json(await repo.get());
+}));
+
+router.put('/layout',
+ validate({ body: layoutSchema }),
+ asyncWrap(async (req, res) => {
+ res.json(await repo.put(req.body));
+ })
+);
+```
+
+- [ ] **Step 4: Mount it** — in `lib/api/index.js`, add the import next to the other route imports and the mount line next to the others:
+
+```js
+import { router as dashboardRouter } from './routes/dashboard.js';
+// ...
+api.use('/dashboard', dashboardRouter);
+```
+
+- [ ] **Step 5: Run — expect PASS.** `npx vitest run tests/api/dashboard.test.js`
+
+> Confirmed in repo: `lib/api/validate.js` assigns the parsed body back to `req.body`, so the handler reading `req.body` is correct; a zod failure becomes a `ValidationError` → 400.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/api/routes/dashboard.js lib/api/index.js tests/api/dashboard.test.js
+git commit -m "feat(dashboard): owner-only GET/PUT /api/dashboard/layout"
+```
+
+---
+
+### Task 3: Refined-B card chrome + theme tokens (CSS)
+
+**Files:**
+- Modify: `public/style.css` (append a Sacred Valley section)
+
+No unit test (pure CSS); verified visually in Task 10.
+
+- [ ] **Step 1: Append tokens + chrome to `public/style.css`**
+
+```css
+/* ===== Sacred Valley (Plan 6) ===== */
+:root {
+ --lb: #7dd3d8; /* Little Blue cyan */
+ /* reserved for a future agent-output phase — unused now:
+ --hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */
+}
+#sv-cards { display: grid; grid-template-columns: repeat(6, 1fr); gap: 16px; align-items: start; }
+.sv-card { grid-column: span 2; } /* m default */
+.sv-card[data-size="s"] { grid-column: span 2; }
+.sv-card[data-size="m"] { grid-column: span 3; }
+.sv-card[data-size="l"] { grid-column: span 6; }
+@media (max-width: 900px) { #sv-cards { grid-template-columns: 1fr; } .sv-card { grid-column: 1 / -1 !important; } }
+
+.sv-card {
+ position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px;
+ background: radial-gradient(120% 90% at 100% 0%, rgba(255,79,46,.05), transparent 55%),
+ linear-gradient(160deg, #16131a, #0f0d12);
+ box-shadow: inset 0 0 30px rgba(255,79,46,.015);
+ transition: box-shadow .35s ease, border-color .35s ease, transform .35s ease;
+}
+.sv-card::after {
+ content: ""; position: absolute; inset: 0; border-radius: 10px; pointer-events: none;
+ background-image: repeating-linear-gradient(115deg, rgba(255,255,255,.015) 0 1px, transparent 1px 9px);
+ mix-blend-mode: overlay;
+}
+.sv-card:hover {
+ border-color: #4a2c28; transform: translateY(-2px);
+ box-shadow: 0 8px 28px rgba(0,0,0,.45), inset 0 0 46px rgba(255,79,46,.06), 0 0 0 1px rgba(255,79,46,.10);
+}
+.sv-card.dragging { opacity: .5; }
+.sv-card.drag-over { border-color: var(--accent); }
+.sv-card-title {
+ font-family: var(--font-display); font-size: 12px; letter-spacing: .16em; text-transform: uppercase;
+ color: var(--text); padding-bottom: 7px; margin-bottom: 12px;
+ border-bottom: 1px solid transparent; border-image: linear-gradient(90deg, var(--accent), transparent 60%) 1;
+ cursor: grab;
+}
+.sv-row { display: flex; justify-content: space-between; align-items: center; font-family: var(--font-mono); font-size: 11px; margin: 7px 0; }
+.sv-row .k { color: var(--muted); letter-spacing: .04em; }
+.sv-bar { height: 5px; border-radius: 3px; background: #221820; overflow: hidden; margin-top: 3px; }
+.sv-bar > i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg, var(--accent-dim), var(--accent)); transition: box-shadow .35s; }
+.sv-card:hover .sv-bar > i { box-shadow: 0 0 9px rgba(255,79,46,.55); }
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add public/style.css
+git commit -m "feat(sacred-valley): refined-B card chrome + theme tokens"
+```
+
+---
+
+### Task 4: Card factory + view skeleton + scheduler
+
+**Files:**
+- Create: `public/components/sv_card.js`
+- Create: `public/views/cards/registry.js`
+- Rewrite: `public/views/sacred_valley.js`
+- Test: `tests/frontend/card_registry.test.js`
+
+- [ ] **Step 1: Write the failing logic test** (registry ordering is pure logic — no DOM)
+
+```js
+// tests/frontend/card_registry.test.js
+import { describe, it, expect } from 'vitest';
+import { orderCards } from '../../public/views/cards/registry.js';
+
+const defs = [{ id: 'clock' }, { id: 'weather' }, { id: 'host-perf' }];
+
+describe('orderCards', () => {
+ it('uses saved order first, then appends new cards in default order', () => {
+ const out = orderCards(defs, { card_order: ['weather'], hidden: [] });
+ expect(out.map(c => c.id)).toEqual(['weather', 'clock', 'host-perf']);
+ });
+ it('drops hidden cards', () => {
+ const out = orderCards(defs, { card_order: [], hidden: ['clock'] });
+ expect(out.map(c => c.id)).toEqual(['weather', 'host-perf']);
+ });
+ it('ignores stale ids in saved order', () => {
+ const out = orderCards(defs, { card_order: ['gone', 'clock'], hidden: [] });
+ expect(out.map(c => c.id)).toEqual(['clock', 'weather', 'host-perf']);
+ });
+});
+```
+
+- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/frontend/card_registry.test.js`
+
+- [ ] **Step 3: Implement the registry**
+
+```js
+// public/views/cards/registry.js
+// Pure ordering logic (kept DOM-free so it is unit-testable). The card MODULES
+// themselves are imported by sacred_valley.js, which passes their defs here.
+export function orderCards(defs, layout = { card_order: [], hidden: [] }) {
+ const byId = new Map(defs.map(d => [d.id, d]));
+ const hidden = new Set(layout.hidden || []);
+ const out = [];
+ for (const id of layout.card_order || []) {
+ if (byId.has(id) && !hidden.has(id)) { out.push(byId.get(id)); byId.delete(id); }
+ }
+ for (const d of defs) {
+ if (byId.has(d.id) && !hidden.has(d.id)) out.push(d);
+ }
+ return out;
+}
+```
+
+- [ ] **Step 4: Run — expect PASS.** `npx vitest run tests/frontend/card_registry.test.js`
+
+- [ ] **Step 5: Implement the card factory**
+
+```js
+// public/components/sv_card.js
+import { el } from '../dom.js';
+
+// Builds the refined-B chrome shell and returns { root, body }. The card module
+// fills `body` in its mount(); start()/stop() own its refresh timer.
+export function svCard(def) {
+ const body = el('div', { class: 'sv-card-body' });
+ const root = el('div', { class: 'sv-card', dataset: { size: def.size || 'm', cardId: def.id } },
+ el('div', { class: 'sv-card-title' }, def.title),
+ body
+ );
+ return { root, body };
+}
+```
+
+- [ ] **Step 6: Rewrite the view skeleton** (cards mount; health band stub for now)
+
+```js
+// public/views/sacred_valley.js
+import { el, mount } from '../dom.js';
+import { api } from '../api.js';
+import { svCard } from '../components/sv_card.js';
+import { orderCards } from './cards/registry.js';
+import clock from './cards/clock.js';
+import weather from './cards/weather.js';
+import hostPerf from './cards/host_perf.js';
+
+const CARD_MODULES = [clock, weather, hostPerf]; // grows in later tasks
+let active = []; // mounted cards needing stop()
+
+export async function render(main) {
+ active.forEach(c => c.stop && c.stop()); active = [];
+ mount(main,
+ el('h1', { class: 'view-h1' }, 'Sacred Valley'),
+ el('p', { class: 'view-sub' }, 'The homelab, at a glance.'),
+ el('div', { id: 'sv-cards' }),
+ el('div', { id: 'sv-health' })
+ );
+
+ let layout = { card_order: [], hidden: [], sizes: {} };
+ try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
+
+ const grid = document.getElementById('sv-cards');
+ const ordered = orderCards(CARD_MODULES, layout);
+ for (const def of ordered) {
+ const size = layout.sizes?.[def.id] || def.size;
+ const { root, body } = svCard({ ...def, size });
+ grid.appendChild(root);
+ try { def.mount(body); def.start && def.start(); active.push(def); }
+ catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
+ }
+ // health band + drag wiring arrive in Tasks 22 and 10.
+}
+```
+
+> The view imports `clock`, `weather`, `host_perf` — create empty stubs now so the import resolves, filled in Tasks 5/7/9:
+> ```js
+> // public/views/cards/clock.js (temporary stub)
+> export default { id: 'clock', title: 'Clock', size: 's', mount() {}, start() {}, stop() {} };
+> ```
+> (and identical stubs for `weather.js` title 'Weather' size 's', `host_perf.js` title 'Host Perf' size 'm').
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add public/components/sv_card.js public/views/cards/ public/views/sacred_valley.js tests/frontend/card_registry.test.js
+git commit -m "feat(sacred-valley): card factory, registry ordering, view skeleton"
+```
+
+---
+
+### Task 5: Clock card
+
+**Files:** Modify `public/views/cards/clock.js`
+
+- [ ] **Step 1: Implement** (Melbourne primary; pure client; 1s tick)
+
+```js
+// public/views/cards/clock.js
+import { el, mount } from '../../dom.js';
+
+let body, timer;
+function fmt(tz) {
+ return new Intl.DateTimeFormat('en-AU', {
+ timeZone: tz, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
+ }).format(new Date());
+}
+function tick() {
+ if (!body) return;
+ mount(body,
+ el('div', { class: 'sv-row', style: { fontSize: '22px' } },
+ el('span', { style: { fontFamily: 'var(--font-mono)' } }, fmt('Australia/Melbourne'))),
+ el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'Melbourne'), el('span', {}, 'AEST/AEDT'))
+ );
+}
+export default {
+ id: 'clock', title: 'Clock', size: 's',
+ mount(el_) { body = el_; tick(); },
+ start() { timer = setInterval(tick, 1000); },
+ stop() { clearInterval(timer); body = null; }
+};
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add public/views/cards/clock.js
+git commit -m "feat(card): clock (Melbourne)"
+```
+
+---
+
+### Task 6: Weather backend — `/api/weather` (Open-Meteo proxy, 15-min cache)
+
+**Files:**
+- Create: `lib/weather.js`
+- Create: `lib/api/routes/weather.js`
+- Modify: `lib/api/index.js`
+- Test: `tests/api/weather.test.js`
+
+- [ ] **Step 1: Write the failing test** (inject a fake fetcher to assert caching)
+
+```js
+// tests/api/weather.test.js
+import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
+import request from 'supertest';
+import { setup } from './helpers.js';
+import * as weather from '../../lib/weather.js';
+
+let app, ownerHeaders;
+beforeAll(async () => { ({ app, ownerHeaders } = await setup()); });
+beforeEach(() => weather._resetCache());
+
+const SAMPLE = { current: { temperature_2m: 14.2, apparent_temperature: 12.1, relative_humidity_2m: 71, wind_speed_10m: 9, weather_code: 3 } };
+
+describe('weather api', () => {
+ it('401 without auth', async () => {
+ expect((await request(app).get('/api/weather')).status).toBe(401);
+ });
+ it('returns mapped weather and caches the upstream call', async () => {
+ const fetcher = vi.fn().mockResolvedValue(SAMPLE);
+ weather._setFetcher(fetcher);
+ const r1 = await request(app).get('/api/weather').set(ownerHeaders);
+ expect(r1.status).toBe(200);
+ expect(r1.body.temp).toBe(14.2);
+ expect(r1.body.humidity).toBe(71);
+ expect(typeof r1.body.label).toBe('string'); // weather_code → text
+ await request(app).get('/api/weather').set(ownerHeaders); // 2nd hit
+ expect(fetcher).toHaveBeenCalledTimes(1); // cached
+ });
+});
+```
+
+- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/api/weather.test.js`
+
+- [ ] **Step 3: Implement `lib/weather.js`**
+
+```js
+// lib/weather.js — Melbourne, Open-Meteo, no API key, 15-min cache.
+const LAT = -37.81, LON = 144.96, TTL_MS = 15 * 60 * 1000;
+const CODES = { 0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',
+ 51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',
+ 71:'Light snow',73:'Snow',75:'Heavy snow',80:'Showers',81:'Showers',82:'Violent showers',
+ 95:'Thunderstorm',96:'Thunderstorm',99:'Thunderstorm' };
+
+let cache = null; // { at, data }
+let fetcher = defaultFetcher;
+
+async function defaultFetcher() {
+ const url = `https://api.open-meteo.com/v1/forecast?latitude=${LAT}&longitude=${LON}` +
+ `¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m`;
+ const res = await fetch(url, { signal: AbortSignal.timeout(6000) });
+ if (!res.ok) throw new Error(`open-meteo ${res.status}`);
+ return res.json();
+}
+
+export function _setFetcher(fn) { fetcher = fn; }
+export function _resetCache() { cache = null; fetcher = defaultFetcher; }
+
+export async function current() {
+ if (cache && Date.now() - cache.at < TTL_MS) return cache.data;
+ const raw = await fetcher();
+ const c = raw.current || {};
+ const data = {
+ temp: c.temperature_2m, feels_like: c.apparent_temperature,
+ humidity: c.relative_humidity_2m, wind: c.wind_speed_10m,
+ code: c.weather_code, label: CODES[c.weather_code] || 'Unknown'
+ };
+ cache = { at: Date.now(), data };
+ return data;
+}
+```
+
+- [ ] **Step 4: Implement the route + mount it**
+
+```js
+// lib/api/routes/weather.js
+import { Router } from 'express';
+import { asyncWrap } from '../errors.js';
+import * as weather from '../../weather.js';
+export const router = Router();
+router.get('/', asyncWrap(async (_req, res) => res.json(await weather.current())));
+```
+In `lib/api/index.js`: `import { router as weatherRouter } from './routes/weather.js';` and `api.use('/weather', weatherRouter);`
+
+- [ ] **Step 5: Run — expect PASS.** `npx vitest run tests/api/weather.test.js`
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/weather.js lib/api/routes/weather.js lib/api/index.js tests/api/weather.test.js
+git commit -m "feat(weather): /api/weather Open-Meteo proxy with 15-min cache"
+```
+
+---
+
+### Task 7: Weather card
+
+**Files:** Modify `public/views/cards/weather.js`
+
+- [ ] **Step 1: Implement** (fetch on mount; refresh every 15 min)
+
+```js
+// public/views/cards/weather.js
+import { el, mount } from '../../dom.js';
+import { api } from '../../api.js';
+
+let body, timer;
+async function load() {
+ if (!body) return;
+ try {
+ const w = await api.get('/api/weather');
+ mount(body,
+ el('div', { class: 'sv-row', style: { fontSize: '22px' } },
+ el('span', { style: { fontFamily: 'var(--font-mono)' } }, `${Math.round(w.temp)}°C`),
+ el('span', { class: 'k' }, w.label)),
+ el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'Feels like'), el('span', {}, `${Math.round(w.feels_like)}°C`)),
+ el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'Humidity'), el('span', {}, `${w.humidity}%`)),
+ el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'Wind'), el('span', {}, `${Math.round(w.wind)} km/h`))
+ );
+ } catch { mount(body, el('span', { class: 'muted' }, 'Weather unavailable')); }
+}
+export default {
+ id: 'weather', title: 'Weather · Melbourne', size: 's',
+ mount(el_) { body = el_; load(); },
+ start() { timer = setInterval(load, 15 * 60 * 1000); },
+ stop() { clearInterval(timer); body = null; }
+};
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add public/views/cards/weather.js
+git commit -m "feat(card): weather"
+```
+
+---
+
+### Task 8: Host backend — `/api/host` (CT 311 /proc)
+
+**Files:**
+- Create: `lib/host/resources.js`
+- Create: `lib/api/routes/host.js`
+- Modify: `lib/api/index.js`
+- Test: `tests/api/host.test.js`
+
+- [ ] **Step 1: Write the failing test** (shape + ranges; runs on the test box's real /proc)
+
+```js
+// tests/api/host.test.js
+import { describe, it, expect, beforeAll } from 'vitest';
+import request from 'supertest';
+import { setup } from './helpers.js';
+
+let app, ownerHeaders;
+beforeAll(async () => { ({ app, ownerHeaders } = await setup()); });
+
+describe('host api', () => {
+ it('401 without auth', async () => {
+ expect((await request(app).get('/api/host')).status).toBe(401);
+ });
+ it('returns cpu/mem/disk/net shape', async () => {
+ const res = await request(app).get('/api/host').set(ownerHeaders);
+ expect(res.status).toBe(200);
+ expect(res.body.cpu_pct).toBeGreaterThanOrEqual(0);
+ expect(res.body.cpu_pct).toBeLessThanOrEqual(100);
+ expect(res.body.mem.total).toBeGreaterThan(0);
+ expect(res.body.mem.used).toBeGreaterThanOrEqual(0);
+ expect(res.body.disk).toHaveProperty('pct');
+ expect(res.body.net).toHaveProperty('rx_bytes');
+ });
+});
+```
+
+- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/api/host.test.js`
+
+- [ ] **Step 3: Implement `lib/host/resources.js`**
+
+```js
+// lib/host/resources.js — reads CT 311's own /proc + statfs. Node 22 fs.statfs.
+import { readFile } from 'node:fs/promises';
+import { statfs } from 'node:fs/promises';
+
+async function cpuSample() {
+ const line = (await readFile('/proc/stat', 'utf8')).split('\n')[0]; // "cpu u n s i ..."
+ const v = line.trim().split(/\s+/).slice(1).map(Number);
+ const idle = v[3] + (v[4] || 0);
+ const total = v.reduce((a, b) => a + b, 0);
+ return { idle, total };
+}
+
+export async function snapshot() {
+ // CPU%: two samples ~100ms apart.
+ const a = await cpuSample();
+ await new Promise(r => setTimeout(r, 100));
+ const b = await cpuSample();
+ const dTotal = b.total - a.total, dIdle = b.idle - a.idle;
+ const cpu_pct = dTotal > 0 ? Math.round((1 - dIdle / dTotal) * 100) : 0;
+
+ // Memory from /proc/meminfo (kB).
+ const mem = Object.fromEntries(
+ (await readFile('/proc/meminfo', 'utf8')).split('\n').filter(Boolean).map(l => {
+ const [k, val] = l.split(':'); return [k.trim(), parseInt(val) * 1024];
+ })
+ );
+ const total = mem.MemTotal, avail = mem.MemAvailable ?? mem.MemFree;
+ const memOut = { total, used: total - avail, pct: Math.round((1 - avail / total) * 100) };
+
+ // Disk for / via statfs.
+ const fs = await statfs('/');
+ const dTotalB = fs.blocks * fs.bsize, dFree = fs.bavail * fs.bsize;
+ const disk = { total: dTotalB, free: dFree, pct: Math.round((1 - dFree / dTotalB) * 100) };
+
+ // Net totals from /proc/net/dev (sum non-lo interfaces).
+ let rx = 0, tx = 0;
+ for (const l of (await readFile('/proc/net/dev', 'utf8')).split('\n')) {
+ const m = l.match(/^\s*([^:]+):\s+(\d+)(?:\s+\d+){7}\s+(\d+)/);
+ if (m && m[1].trim() !== 'lo') { rx += Number(m[2]); tx += Number(m[3]); }
+ }
+ return { cpu_pct, mem: memOut, disk, net: { rx_bytes: rx, tx_bytes: tx }, at: Date.now() };
+}
+```
+
+- [ ] **Step 4: Implement the route + mount**
+
+```js
+// lib/api/routes/host.js
+import { Router } from 'express';
+import { asyncWrap } from '../errors.js';
+import { snapshot } from '../../host/resources.js';
+export const router = Router();
+router.get('/', asyncWrap(async (_req, res) => res.json(await snapshot())));
+```
+`lib/api/index.js`: `import { router as hostRouter } from './routes/host.js';` + `api.use('/host', hostRouter);`
+
+- [ ] **Step 5: Run — expect PASS.** `npx vitest run tests/api/host.test.js`
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/host/resources.js lib/api/routes/host.js lib/api/index.js tests/api/host.test.js
+git commit -m "feat(host): /api/host CPU/mem/disk/net from /proc"
+```
+
+---
+
+### Task 9: Host-perf card
+
+**Files:** Modify `public/views/cards/host_perf.js`
+
+- [ ] **Step 1: Implement** (30s refresh; net rate from successive samples)
+
+```js
+// public/views/cards/host_perf.js
+import { el, mount } from '../../dom.js';
+import { api } from '../../api.js';
+
+let body, timer, prev;
+const GB = 1024 ** 3;
+function bar(pct) { return el('div', { class: 'sv-bar' }, el('i', { style: { width: Math.min(100, pct) + '%' } })); }
+async function load() {
+ if (!body) return;
+ try {
+ const h = await api.get('/api/host');
+ let rate = '';
+ if (prev) {
+ const dt = (h.at - prev.at) / 1000 || 1;
+ const dn = (b) => ((b) / dt / 1e6).toFixed(1);
+ rate = `↓${dn(h.net.rx_bytes - prev.net.rx_bytes)} ↑${dn(h.net.tx_bytes - prev.net.tx_bytes)} MB/s`;
+ }
+ prev = h;
+ mount(body,
+ el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'CPU'), el('span', {}, h.cpu_pct + '%')), bar(h.cpu_pct),
+ el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'RAM'),
+ el('span', {}, `${(h.mem.used / GB).toFixed(1)} / ${(h.mem.total / GB).toFixed(0)} GB`)), bar(h.mem.pct),
+ el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'DISK'), el('span', {}, h.disk.pct + '%')), bar(h.disk.pct),
+ el('div', { class: 'sv-row' }, el('span', { class: 'k' }, 'NET'), el('span', {}, rate || '—'))
+ );
+ } catch { mount(body, el('span', { class: 'muted' }, 'Host unavailable')); }
+}
+export default {
+ id: 'host-perf', title: 'Host Perf · CT 311', size: 'm',
+ mount(el_) { body = el_; prev = null; load(); },
+ start() { timer = setInterval(load, 30000); },
+ stop() { clearInterval(timer); body = null; }
+};
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add public/views/cards/host_perf.js
+git commit -m "feat(card): host-perf"
+```
+
+---
+
+### Task 10: Drag-to-reorder + persistence
+
+**Files:**
+- Create: `public/components/sv_reorder.js`
+- Modify: `public/views/sacred_valley.js` (wire reorder after mounting cards)
+- Test: `tests/frontend/reorder.test.js`
+
+- [ ] **Step 1: Write the failing logic test** (the order-computing helper is pure)
+
+```js
+// tests/frontend/reorder.test.js
+import { describe, it, expect } from 'vitest';
+import { moveId } from '../../public/components/sv_reorder.js';
+
+describe('moveId', () => {
+ it('moves an id before a target', () => {
+ expect(moveId(['a', 'b', 'c'], 'c', 'a')).toEqual(['c', 'a', 'b']);
+ });
+ it('moving onto itself is a no-op', () => {
+ expect(moveId(['a', 'b', 'c'], 'b', 'b')).toEqual(['a', 'b', 'c']);
+ });
+ it('moving to end when target is null appends', () => {
+ expect(moveId(['a', 'b', 'c'], 'a', null)).toEqual(['b', 'c', 'a']);
+ });
+});
+```
+
+- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/frontend/reorder.test.js`
+
+- [ ] **Step 3: Implement `sv_reorder.js`** (pure `moveId` + a DOM wiring function)
+
+```js
+// public/components/sv_reorder.js
+export function moveId(order, dragId, beforeId) {
+ const out = order.filter(id => id !== dragId);
+ if (beforeId == null) { out.push(dragId); return out; }
+ const i = out.indexOf(beforeId);
+ if (i < 0) { out.push(dragId); return out; }
+ out.splice(i, 0, dragId);
+ return out;
+}
+
+// Wires HTML5 drag on .sv-card elements in `grid`. onReorder(newOrderIds) fires
+// after a drop. Drag handle = the whole card title (cursor:grab via CSS).
+export function attachReorder(grid, onReorder) {
+ let dragId = null;
+ grid.querySelectorAll('.sv-card').forEach(card => {
+ card.draggable = true;
+ card.addEventListener('dragstart', () => { dragId = card.dataset.cardId; card.classList.add('dragging'); });
+ card.addEventListener('dragend', () => { card.classList.remove('dragging'); dragId = null; });
+ card.addEventListener('dragover', e => { e.preventDefault(); card.classList.add('drag-over'); });
+ card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
+ card.addEventListener('drop', e => {
+ e.preventDefault(); card.classList.remove('drag-over');
+ if (!dragId || dragId === card.dataset.cardId) return;
+ const ids = [...grid.querySelectorAll('.sv-card')].map(c => c.dataset.cardId);
+ onReorder(moveId(ids, dragId, card.dataset.cardId));
+ });
+ });
+}
+```
+
+- [ ] **Step 4: Run — expect PASS.** `npx vitest run tests/frontend/reorder.test.js`
+
+- [ ] **Step 5: Wire it into the view** — at the end of `render()` in `sacred_valley.js`, after the card loop:
+
+```js
+import { attachReorder } from '../components/sv_reorder.js';
+// ... after the for-loop that appends cards:
+attachReorder(grid, async (newOrder) => {
+ // reflect immediately
+ const frag = document.createDocumentFragment();
+ newOrder.forEach(id => { const n = grid.querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
+ grid.appendChild(frag);
+ try { await api.put('/api/dashboard/layout', { ...layout, card_order: newOrder }); layout.card_order = newOrder; }
+ catch (e) { console.error('save layout', e); }
+});
+```
+
+> Confirmed in repo: `public/api.js` exports `get/post/patch/del` but **no `put`**. Add one line to the exported `api` object: `put: (p, body) => call('PUT', p, body ?? {}),` (mirrors `post`). The internal `call()` already handles any method + JSON body.
+
+- [ ] **Step 6: Manual browser verification.** Run the app (or deploy to a dev box), open `#/sacred-valley`, drag a card by its title onto another, reload — order persists. Confirm clock ticks, weather + host-perf populate.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add public/components/sv_reorder.js public/views/sacred_valley.js public/api.js tests/frontend/reorder.test.js
+git commit -m "feat(sacred-valley): drag-to-reorder with server-persisted layout"
+```
+
+---
+
+## Phase 2 — Reuse cards (jobs / inbox / search)
+
+> Before writing these, read the existing response shapes: `lib/api/routes/jobs.js` + `lib/db/repos` for jobs, `lib/api/routes/pending_changes.js`, and `public/views/jobs.js` / `public/views/inbox.js` / `public/views/search.js` for how the full views already call them. Match those shapes exactly.
+
+### Task 11: Jobs card
+
+**Files:** Create `public/views/cards/jobs.js`; Modify `public/views/sacred_valley.js` (import + add to `CARD_MODULES`).
+
+- [ ] **Step 1: Implement** (counts by state; 10s refresh; links to `#/jobs`). Use the same endpoint `public/views/jobs.js` uses — confirm its path/shape first and adapt the fields below to match.
+
+```js
+// public/views/cards/jobs.js
+import { el, mount } from '../../dom.js';
+import { api } from '../../api.js';
+
+let body, timer;
+async function load() {
+ if (!body) return;
+ try {
+ const jobs = await api.get('/api/jobs'); // adapt to real path/shape
+ const counts = {};
+ for (const j of (jobs.items || jobs)) counts[j.state] = (counts[j.state] || 0) + 1;
+ const rows = ['active', 'created', 'completed', 'failed', 'retry']
+ .filter(s => counts[s])
+ .map(s => el('div', { class: 'sv-row' }, el('span', { class: 'k' }, s), el('span', {}, String(counts[s]))));
+ mount(body,
+ rows.length ? rows : el('span', { class: 'muted' }, 'No jobs'),
+ el('a', { href: '#/jobs', class: 'k', style: { display: 'block', marginTop: '8px' } }, 'Open Jobs →')
+ );
+ } catch { mount(body, el('span', { class: 'muted' }, 'Jobs unavailable')); }
+}
+export default {
+ id: 'jobs', title: 'Capture Queue', size: 'm',
+ mount(el_) { body = el_; load(); }, start() { timer = setInterval(load, 10000); }, stop() { clearInterval(timer); body = null; }
+};
+```
+
+- [ ] **Step 2:** Add to `sacred_valley.js`: `import jobs from './cards/jobs.js';` and append `jobs` to `CARD_MODULES`.
+
+- [ ] **Step 3: Manual check** the card renders counts. **Commit:**
+
+```bash
+git add public/views/cards/jobs.js public/views/sacred_valley.js
+git commit -m "feat(card): jobs / capture queue"
+```
+
+---
+
+### Task 12: Inbox card
+
+**Files:** Create `public/views/cards/inbox.js`; Modify `sacred_valley.js`.
+
+- [ ] **Step 1: Implement** (pending-changes count + recent; links `#/inbox`). The app already polls `/api/pending-changes` (see `public/app.js::pollPending`) — reuse it.
+
+```js
+// public/views/cards/inbox.js
+import { el, mount } from '../../dom.js';
+import { api } from '../../api.js';
+
+let body, timer;
+async function load() {
+ if (!body) return;
+ try {
+ const rows = await api.get('/api/pending-changes');
+ mount(body,
+ el('div', { class: 'sv-row', style: { fontSize: '22px' } },
+ el('span', { style: { fontFamily: 'var(--font-mono)' } }, String(rows.length)),
+ el('span', { class: 'k' }, 'awaiting review')),
+ el('a', { href: '#/inbox', class: 'k', style: { display: 'block', marginTop: '8px' } }, 'Open Inbox →')
+ );
+ } catch (e) {
+ mount(body, el('span', { class: 'muted' }, e.status === 403 ? 'Owner only' : 'Inbox unavailable'));
+ }
+}
+export default {
+ id: 'inbox', title: 'Inbox', size: 's',
+ mount(el_) { body = el_; load(); }, start() { timer = setInterval(load, 10000); }, stop() { clearInterval(timer); body = null; }
+};
+```
+
+- [ ] **Step 2:** Wire into `sacred_valley.js` (`import inbox` + append). **Commit:**
+
+```bash
+git add public/views/cards/inbox.js public/views/sacred_valley.js
+git commit -m "feat(card): inbox"
+```
+
+---
+
+### Task 13: Search spotlight card
+
+**Files:** Create `public/views/cards/search.js`; Modify `sacred_valley.js`.
+
+- [ ] **Step 1: Implement** (debounced query → `/api/search`; click result → navigate). Match the result shape used by `public/views/search.js` (each hit has `kind` + `id` + `title`).
+
+```js
+// public/views/cards/search.js
+import { el, mount } from '../../dom.js';
+import { api } from '../../api.js';
+import { navigate } from '../../router.js';
+
+const ROUTE = { page: id => '#/page/' + id, ref: id => '#/ref/' + id, source_doc: () => '#/', message: () => '#/' };
+let body, input, results, deb;
+async function run(q) {
+ if (!q) { mount(results); return; }
+ try {
+ const hits = await api.get('/api/search?q=' + encodeURIComponent(q));
+ mount(results, (hits || []).slice(0, 6).map(h =>
+ el('div', { class: 'sv-row', style: { cursor: 'pointer' },
+ onclick: () => { const r = ROUTE[h.kind]; if (r) navigate(r(h.id)); } },
+ el('span', {}, h.title || '(untitled)'), el('span', { class: 'k' }, h.kind))
+ ));
+ } catch { mount(results, el('span', { class: 'muted' }, 'Search failed')); }
+}
+export default {
+ id: 'search', title: 'Spotlight', size: 'l',
+ mount(el_) {
+ body = el_;
+ input = el('input', { class: 'sv-search-input', placeholder: 'Search the Void…',
+ oninput: e => { clearTimeout(deb); const q = e.target.value.trim(); deb = setTimeout(() => run(q), 250); } });
+ results = el('div', { style: { marginTop: '10px' } });
+ mount(body, input, results);
+ },
+ start() {}, stop() { body = null; }
+};
+```
+
+Add to `style.css`: `.sv-search-input{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-family:var(--font-mono);font-size:12px}`
+
+- [ ] **Step 2:** Wire into `sacred_valley.js`. **Manual check.** **Commit:**
+
+```bash
+git add public/views/cards/search.js public/views/sacred_valley.js public/style.css
+git commit -m "feat(card): search spotlight"
+```
+
+---
+
+## Phase 3 — Speedtest
+
+### Task 14: `speedtest_results` table + repo
+
+**Files:**
+- Create: `lib/db/migrations/013_speedtest.sql`
+- Create: `lib/db/repos/speedtest.js`
+- Test: `tests/repos/speedtest.test.js`
+
+- [ ] **Step 1: Migration**
+
+```sql
+-- 013_speedtest.sql
+CREATE TABLE speedtest_results (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ down_mbps numeric NOT NULL,
+ up_mbps numeric NOT NULL,
+ ping_ms numeric,
+ ran_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX idx_speedtest_ran_at ON speedtest_results (ran_at DESC);
+```
+
+- [ ] **Step 2: Failing test**
+
+```js
+// tests/repos/speedtest.test.js
+import { describe, it, expect, beforeAll } from 'vitest';
+import { resetDb } from '../helpers/db.js';
+import { migrateUp } from '../../lib/db/migrate.js';
+import * as repo from '../../lib/db/repos/speedtest.js';
+
+beforeAll(async () => { await resetDb(); await migrateUp(); });
+describe('speedtest repo', () => {
+ it('records and lists newest-first', async () => {
+ await repo.record({ down_mbps: 100, up_mbps: 20, ping_ms: 8 });
+ await repo.record({ down_mbps: 110, up_mbps: 22, ping_ms: 7 });
+ const hist = await repo.history(30);
+ expect(hist.length).toBe(2);
+ expect(Number(hist[0].down_mbps)).toBe(110); // newest first
+ });
+});
+```
+
+- [ ] **Step 3: Run — expect FAIL.** `npx vitest run tests/repos/speedtest.test.js`
+
+- [ ] **Step 4: Implement repo**
+
+```js
+// lib/db/repos/speedtest.js
+import { pool } from '../pool.js';
+export async function record({ down_mbps, up_mbps, ping_ms = null }) {
+ const { rows } = await pool.query(
+ `INSERT INTO speedtest_results (down_mbps, up_mbps, ping_ms) VALUES ($1,$2,$3) RETURNING *`,
+ [down_mbps, up_mbps, ping_ms]);
+ return rows[0];
+}
+export async function history(limit = 30) {
+ const { rows } = await pool.query(
+ `SELECT * FROM speedtest_results ORDER BY ran_at DESC LIMIT $1`, [limit]);
+ return rows;
+}
+```
+
+- [ ] **Step 5: Run — expect PASS.** **Commit:**
+
+```bash
+git add lib/db/migrations/013_speedtest.sql lib/db/repos/speedtest.js tests/repos/speedtest.test.js
+git commit -m "feat(speedtest): results table + repo"
+```
+
+---
+
+### Task 15: Speedtest worker, cron schedule, routes
+
+**Files:**
+- Create: `lib/jobs/workers/speedtest.js`
+- Modify: `lib/jobs/index.js` (add to WORKERS)
+- Modify: `lib/cron/index.js` (hourly enqueue)
+- Create: `lib/api/routes/speedtest.js`
+- Modify: `lib/api/index.js`
+- Test: `tests/api/speedtest.test.js`, `tests/jobs/speedtest_worker.test.js`
+
+- [ ] **Step 1: Worker test** (inject a fake runner so no real network)
+
+```js
+// tests/jobs/speedtest_worker.test.js
+import { describe, it, expect, beforeAll, vi } from 'vitest';
+import { resetDb } from '../helpers/db.js';
+import { migrateUp } from '../../lib/db/migrate.js';
+import * as worker from '../../lib/jobs/workers/speedtest.js';
+import * as repo from '../../lib/db/repos/speedtest.js';
+
+beforeAll(async () => { await resetDb(); await migrateUp(); });
+describe('speedtest worker', () => {
+ it('runs the CLI runner and records the result', async () => {
+ worker._setRunner(vi.fn().mockResolvedValue({ down_mbps: 95.5, up_mbps: 18.3, ping_ms: 9 }));
+ await worker.handler({ id: 'j1', data: {} });
+ const hist = await repo.history(1);
+ expect(Number(hist[0].down_mbps)).toBe(95.5);
+ });
+});
+```
+
+- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/jobs/speedtest_worker.test.js`
+
+- [ ] **Step 3: Implement the worker** (Ookla/`speedtest-cli` JSON; injectable runner)
+
+```js
+// lib/jobs/workers/speedtest.js
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+import * as repo from '../../db/repos/speedtest.js';
+import { log } from '../../log.js';
+const pexec = promisify(execFile);
+
+export const NAME = 'speedtest';
+
+// Default runner uses speedtest-cli --json (bits/s → Mbps). Swap binary/flags
+// here if the box has the Ookla `speedtest -f json` CLI instead.
+async function defaultRunner() {
+ const { stdout } = await pexec('speedtest-cli', ['--json'], { timeout: 120000 });
+ const j = JSON.parse(stdout);
+ return { down_mbps: j.download / 1e6, up_mbps: j.upload / 1e6, ping_ms: j.ping };
+}
+let runner = defaultRunner;
+export function _setRunner(fn) { runner = fn; }
+
+export async function handler(_job) {
+ const r = await runner();
+ await repo.record(r);
+ log.info(r, 'speedtest recorded');
+}
+```
+
+- [ ] **Step 4: Register the worker** — in `lib/jobs/index.js`: `import * as speedtest from './workers/speedtest.js';` and add `speedtest` to the `WORKERS` array.
+
+- [ ] **Step 5: Schedule hourly** — in `lib/cron/index.js`, inside `startCron()`, add:
+
+```js
+import { enqueue } from '../jobs/queue.js';
+// ... inside startCron():
+cron.schedule('0 * * * *', async () => {
+ try { await enqueue('speedtest', {}); log.info('cron speedtest enqueued'); }
+ catch (e) { log.error({ err: e }, 'cron speedtest failed'); }
+});
+```
+
+- [ ] **Step 6: API test**
+
+```js
+// tests/api/speedtest.test.js
+import { describe, it, expect, beforeAll } from 'vitest';
+import request from 'supertest';
+import { setup } from './helpers.js';
+import * as repo from '../../lib/db/repos/speedtest.js';
+
+let app, ownerHeaders;
+beforeAll(async () => { ({ app, ownerHeaders } = await setup()); await repo.record({ down_mbps: 50, up_mbps: 10, ping_ms: 12 }); });
+describe('speedtest api', () => {
+ it('401 without auth', async () => expect((await request(app).get('/api/speedtest/history')).status).toBe(401));
+ it('history returns rows', async () => {
+ const res = await request(app).get('/api/speedtest/history').set(ownerHeaders);
+ expect(res.status).toBe(200);
+ expect(res.body.length).toBeGreaterThanOrEqual(1);
+ });
+});
+```
+
+- [ ] **Step 7: Implement routes + mount** (`run` is owner-only, enqueues a job)
+
+```js
+// lib/api/routes/speedtest.js
+import { Router } from 'express';
+import { asyncWrap } from '../errors.js';
+import { requireOwner } from '../cap.js';
+import * as repo from '../../db/repos/speedtest.js';
+import { enqueue } from '../../jobs/queue.js';
+export const router = Router();
+router.get('/history', asyncWrap(async (_req, res) => res.json(await repo.history(30))));
+router.post('/run', requireOwner, asyncWrap(async (_req, res) => {
+ const id = await enqueue('speedtest', {});
+ res.status(202).json({ enqueued: id });
+}));
+```
+`lib/api/index.js`: `import { router as speedtestRouter } from './routes/speedtest.js';` + `api.use('/speedtest', speedtestRouter);`
+
+- [ ] **Step 8: Run both test files — expect PASS.** `npx vitest run tests/jobs/speedtest_worker.test.js tests/api/speedtest.test.js`
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add lib/jobs/workers/speedtest.js lib/jobs/index.js lib/cron/index.js lib/api/routes/speedtest.js lib/api/index.js tests/jobs/speedtest_worker.test.js tests/api/speedtest.test.js
+git commit -m "feat(speedtest): worker + hourly cron + history/run routes"
+```
+
+> **Deploy note (record in deploy/README.md in Task 23):** the worker box (CT 311) needs `speedtest-cli` installed (`pip install --break-system-packages speedtest-cli`) or the Ookla CLI; otherwise `speedtest` jobs fail (card still renders history).
+
+---
+
+### Task 16: Speedtest card
+
+**Files:** Create `public/views/cards/speedtest.js`; Modify `sacred_valley.js`.
+
+- [ ] **Step 1: Implement** (latest figure + sparkline bars from history; "Run" button)
+
+```js
+// public/views/cards/speedtest.js
+import { el, mount } from '../../dom.js';
+import { api } from '../../api.js';
+
+let body;
+async function load() {
+ if (!body) return;
+ try {
+ const hist = await api.get('/api/speedtest/history');
+ const latest = hist[0];
+ const max = Math.max(1, ...hist.map(h => Number(h.down_mbps)));
+ const bars = el('div', { style: { display: 'flex', gap: '2px', alignItems: 'flex-end', height: '40px', marginTop: '8px' } },
+ hist.slice(0, 30).reverse().map(h =>
+ el('div', { style: { flex: '1', background: 'var(--accent-dim)',
+ height: (Number(h.down_mbps) / max * 100) + '%' } })));
+ mount(body,
+ el('div', { class: 'sv-row', style: { fontSize: '20px' } },
+ el('span', { style: { fontFamily: 'var(--font-mono)' } }, latest ? `${Number(latest.down_mbps).toFixed(0)}↓ ${Number(latest.up_mbps).toFixed(0)}↑` : '—'),
+ el('button', { class: 'sv-run', onclick: runNow }, 'Run')),
+ bars);
+ } catch { mount(body, el('span', { class: 'muted' }, 'No speedtest data')); }
+}
+async function runNow() { try { await api.post('/api/speedtest/run', {}); } catch {} setTimeout(load, 3000); }
+export default {
+ id: 'speedtest', title: 'Speedtest', size: 'm',
+ mount(el_) { body = el_; load(); }, start() {}, stop() { body = null; }
+};
+```
+
+Add to `style.css`: `.sv-run{background:var(--accent-soft);border:1px solid var(--accent-dim);color:var(--accent);border-radius:5px;padding:3px 10px;font-family:var(--font-ui);font-size:11px;cursor:pointer}`
+
+- [ ] **Step 2:** Wire into `sacred_valley.js`. **Manual check.** **Commit:**
+
+```bash
+git add public/views/cards/speedtest.js public/views/sacred_valley.js public/style.css
+git commit -m "feat(card): speedtest"
+```
+
+---
+
+## Phase 4 — Little Blue Health band
+
+### Task 17: Service registry loader + seed config
+
+**Files:**
+- Create: `config/services.json`
+- Create: `lib/health/registry.js`
+- Test: `tests/health/registry.test.js`
+
+- [ ] **Step 1: Seed `config/services.json`** (correct titles — NOT inherited from v1; user edits later). IPs/CTs from the homelab wiki; group = agents | infrastructure | media.
+
+```json
+[
+ { "id": "void-server", "name": "Void 2.0", "category": "agents", "host": "ct311", "url": "http://192.168.1.216:3000", "icon": "void", "check": { "type": "http", "path": "/health" } },
+ { "id": "ollama", "name": "Ollama", "category": "agents", "host": "ct102", "url": "http://192.168.1.185:11434", "icon": "ollama" },
+ { "id": "gitea", "name": "Gitea", "category": "infrastructure", "host": "ct105", "url": "http://192.168.1.223:3000", "icon": "gitea" },
+ { "id": "pihole", "name": "Pi-hole", "category": "infrastructure", "host": "ct106", "url": "http://192.168.1.140/admin", "icon": "pi-hole" },
+ { "id": "bookstack", "name": "BookStack", "category": "infrastructure", "host": "ct104", "url": "http://192.168.1.213", "icon": "bookstack" },
+ { "id": "plex", "name": "Plex", "category": "media", "host": "ct100", "url": "http://192.168.1.230:32400/web", "icon": "plex" },
+ { "id": "sonarr", "name": "Sonarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:8989", "icon": "sonarr" },
+ { "id": "radarr", "name": "Radarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:7878", "icon": "radarr" },
+ { "id": "qbittorrent", "name": "qBittorrent", "category": "media", "host": "ct100", "url": "http://192.168.1.230:8080", "icon": "qbittorrent" }
+]
+```
+
+- [ ] **Step 2: Failing test**
+
+```js
+// tests/health/registry.test.js
+import { describe, it, expect } from 'vitest';
+import { load, grouped, iconSlug, CATEGORY_ORDER } from '../../lib/health/registry.js';
+
+describe('registry', () => {
+ it('loads the seed config', () => { expect(load().length).toBeGreaterThan(0); });
+ it('derives an icon slug from icon or name', () => {
+ expect(iconSlug({ name: 'Open WebUI' })).toBe('open-webui');
+ expect(iconSlug({ name: 'Plex', icon: 'plex' })).toBe('plex');
+ });
+ it('groups in agents→infrastructure→media order', () => {
+ const g = grouped(load());
+ const cats = g.map(x => x.category);
+ const ai = cats.indexOf('agents'), mi = cats.indexOf('media');
+ expect(ai).toBeLessThan(mi);
+ expect(CATEGORY_ORDER[0]).toBe('agents');
+ });
+});
+```
+
+- [ ] **Step 3: Run — expect FAIL.** `npx vitest run tests/health/registry.test.js`
+
+- [ ] **Step 4: Implement `lib/health/registry.js`**
+
+```js
+// lib/health/registry.js
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const CONFIG = path.join(__dirname, '../../config/services.json');
+export const CATEGORY_ORDER = ['agents', 'infrastructure', 'media', 'other'];
+
+let cache = null;
+export function load() {
+ if (!cache) cache = JSON.parse(readFileSync(CONFIG, 'utf8'));
+ return cache;
+}
+export function _reset() { cache = null; } // tests
+
+export function iconSlug(svc) {
+ return (svc.icon || svc.name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
+}
+
+export function grouped(services) {
+ const map = new Map();
+ for (const s of services) {
+ const cat = CATEGORY_ORDER.includes(s.category) ? s.category : 'other';
+ if (!map.has(cat)) map.set(cat, []);
+ map.get(cat).push(s);
+ }
+ return [...CATEGORY_ORDER, ...[...map.keys()].filter(c => !CATEGORY_ORDER.includes(c))]
+ .filter(c => map.has(c))
+ .map(category => ({ category, services: map.get(category) }));
+}
+```
+
+- [ ] **Step 5: Run — expect PASS.** **Commit:**
+
+```bash
+git add config/services.json lib/health/registry.js tests/health/registry.test.js
+git commit -m "feat(health): service registry loader + seed config (fresh titles)"
+```
+
+---
+
+### Task 18: `service_status` cache table + repo
+
+**Files:**
+- Create: `lib/db/migrations/014_service_status.sql`
+- Create: `lib/db/repos/service_status.js`
+- Test: `tests/repos/service_status.test.js`
+
+- [ ] **Step 1: Migration**
+
+```sql
+-- 014_service_status.sql
+CREATE TABLE service_status (
+ service_id text PRIMARY KEY,
+ status text NOT NULL CHECK (status IN ('ok','warn','down','unknown')),
+ latency_ms integer,
+ detail text,
+ checked_at timestamptz NOT NULL DEFAULT now()
+);
+```
+
+- [ ] **Step 2: Failing test**
+
+```js
+// tests/repos/service_status.test.js
+import { describe, it, expect, beforeAll } from 'vitest';
+import { resetDb } from '../helpers/db.js';
+import { migrateUp } from '../../lib/db/migrate.js';
+import * as repo from '../../lib/db/repos/service_status.js';
+
+beforeAll(async () => { await resetDb(); await migrateUp(); });
+describe('service_status repo', () => {
+ it('upserts and reads all', async () => {
+ await repo.upsert({ service_id: 'gitea', status: 'ok', latency_ms: 12, detail: '200' });
+ await repo.upsert({ service_id: 'gitea', status: 'down', latency_ms: null, detail: 'ECONN' });
+ const all = await repo.all();
+ expect(all.find(r => r.service_id === 'gitea').status).toBe('down');
+ });
+});
+```
+
+- [ ] **Step 3: Run — expect FAIL.** `npx vitest run tests/repos/service_status.test.js`
+
+- [ ] **Step 4: Implement repo**
+
+```js
+// lib/db/repos/service_status.js
+import { pool } from '../pool.js';
+export async function upsert({ service_id, status, latency_ms = null, detail = null }) {
+ await pool.query(
+ `INSERT INTO service_status (service_id, status, latency_ms, detail, checked_at)
+ VALUES ($1,$2,$3,$4, now())
+ ON CONFLICT (service_id) DO UPDATE
+ SET status=EXCLUDED.status, latency_ms=EXCLUDED.latency_ms,
+ detail=EXCLUDED.detail, checked_at=now()`,
+ [service_id, status, latency_ms, detail]);
+}
+export async function all() {
+ const { rows } = await pool.query(`SELECT * FROM service_status`);
+ return rows;
+}
+```
+
+- [ ] **Step 5: Run — expect PASS.** **Commit:**
+
+```bash
+git add lib/db/migrations/014_service_status.sql lib/db/repos/service_status.js tests/repos/service_status.test.js
+git commit -m "feat(health): service_status cache table + repo"
+```
+
+---
+
+### Task 19: Health-check engine (probe + cron)
+
+**Files:**
+- Create: `lib/health/checker.js`
+- Modify: `lib/cron/index.js` (60s schedule)
+- Test: `tests/health/checker.test.js`
+
+- [ ] **Step 1: Failing test** (status logic from an injectable probe)
+
+```js
+// tests/health/checker.test.js
+import { describe, it, expect, vi } from 'vitest';
+import { classify, checkAll } from '../../lib/health/checker.js';
+
+describe('health classify', () => {
+ it('ok when reachable and fast', () => expect(classify({ ok: true, latency: 120 }).status).toBe('ok'));
+ it('warn when reachable but slow', () => expect(classify({ ok: true, latency: 4000 }).status).toBe('warn'));
+ it('warn on non-2xx/3xx reachable', () => expect(classify({ ok: false, reachable: true, latency: 50 }).status).toBe('warn'));
+ it('down when unreachable', () => expect(classify({ ok: false, reachable: false, error: 'ECONN' }).status).toBe('down'));
+});
+
+describe('checkAll', () => {
+ it('probes each service and returns a status per id', async () => {
+ const probe = vi.fn().mockResolvedValue({ ok: true, latency: 30 });
+ const svcs = [{ id: 'a', url: 'http://x' }, { id: 'b', url: 'http://y' }];
+ const out = await checkAll(svcs, probe);
+ expect(out.map(o => o.service_id).sort()).toEqual(['a', 'b']);
+ expect(out.every(o => o.status === 'ok')).toBe(true);
+ });
+});
+```
+
+- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/health/checker.test.js`
+
+- [ ] **Step 3: Implement `lib/health/checker.js`**
+
+```js
+// lib/health/checker.js
+import net from 'node:net';
+const SLOW_MS = 3000;
+
+export function classify({ ok, reachable, latency, error }) {
+ if (ok) return { status: latency > SLOW_MS ? 'warn' : 'ok', latency_ms: latency, detail: `${latency}ms` };
+ if (reachable) return { status: 'warn', latency_ms: latency ?? null, detail: 'degraded' };
+ return { status: 'down', latency_ms: null, detail: error || 'unreachable' };
+}
+
+// Default probe: HTTP (status 2xx/3xx) or TCP connect. Only called with
+// operator-configured URLs from the registry — never user input.
+export async function probe(svc) {
+ const started = Date.now();
+ const type = svc.check?.type || 'http';
+ try {
+ if (type === 'tcp') {
+ const u = new URL(svc.url);
+ await new Promise((resolve, reject) => {
+ const sock = net.connect({ host: u.hostname, port: Number(u.port) }, () => { sock.end(); resolve(); });
+ sock.setTimeout(5000); sock.on('timeout', () => { sock.destroy(); reject(new Error('timeout')); });
+ sock.on('error', reject);
+ });
+ return { ok: true, latency: Date.now() - started };
+ }
+ const base = svc.url.replace(/\/$/, '');
+ const url = base + (svc.check?.path || '');
+ const res = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(6000) });
+ const reachable = true;
+ const ok = res.status >= 200 && res.status < 400;
+ return { ok, reachable, latency: Date.now() - started };
+ } catch (e) {
+ return { ok: false, reachable: false, latency: Date.now() - started, error: e.code || e.message };
+ }
+}
+
+export async function checkAll(services, probeFn = probe) {
+ return Promise.all(services.map(async svc => {
+ const c = classify(await probeFn(svc));
+ return { service_id: svc.id, ...c };
+ }));
+}
+```
+
+- [ ] **Step 4: Run — expect PASS.** `npx vitest run tests/health/checker.test.js`
+
+- [ ] **Step 5: Schedule the 60s check** — in `lib/cron/index.js`, add:
+
+```js
+import { load } from '../health/registry.js';
+import { checkAll } from '../health/checker.js';
+import * as statusRepo from '../db/repos/service_status.js';
+// ... inside startCron():
+cron.schedule('*/1 * * * *', async () => {
+ try {
+ const results = await checkAll(load());
+ for (const r of results) await statusRepo.upsert(r);
+ log.info({ n: results.length }, 'health check complete');
+ } catch (e) { log.error({ err: e }, 'health check failed'); }
+});
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/health/checker.js lib/cron/index.js tests/health/checker.test.js
+git commit -m "feat(health): probe + classify engine on a 60s cron"
+```
+
+---
+
+### Task 20: `/api/health/services` + `/api/health/check`
+
+**Files:**
+- Create: `lib/api/routes/health.js`
+- Modify: `lib/api/index.js`
+- Test: `tests/api/health.test.js`
+
+- [ ] **Step 1: Failing test**
+
+```js
+// tests/api/health.test.js
+import { describe, it, expect, beforeAll } from 'vitest';
+import request from 'supertest';
+import { setup } from './helpers.js';
+import * as statusRepo from '../../lib/db/repos/service_status.js';
+
+let app, ownerHeaders;
+beforeAll(async () => {
+ ({ app, ownerHeaders } = await setup());
+ await statusRepo.upsert({ service_id: 'gitea', status: 'ok', latency_ms: 10, detail: '200' });
+});
+describe('health api', () => {
+ it('401 without auth', async () => expect((await request(app).get('/api/health/services')).status).toBe(401));
+ it('returns groups with counts + merged cached status', async () => {
+ const res = await request(app).get('/api/health/services').set(ownerHeaders);
+ expect(res.status).toBe(200);
+ const infra = res.body.find(g => g.category === 'infrastructure');
+ expect(infra).toBeTruthy();
+ expect(infra.healthy).toBeGreaterThanOrEqual(1); // gitea ok
+ const gitea = infra.services.find(s => s.id === 'gitea');
+ expect(gitea.status).toBe('ok');
+ });
+});
+```
+
+- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/api/health.test.js`
+
+- [ ] **Step 3: Implement route + mount**
+
+```js
+// lib/api/routes/health.js
+import { Router } from 'express';
+import { asyncWrap } from '../errors.js';
+import { requireOwner } from '../cap.js';
+import { load, grouped, iconSlug } from '../../health/registry.js';
+import * as statusRepo from '../../db/repos/service_status.js';
+import { enqueue } from '../../jobs/queue.js';
+
+export const router = Router();
+
+router.get('/services', asyncWrap(async (_req, res) => {
+ const statuses = Object.fromEntries((await statusRepo.all()).map(s => [s.service_id, s]));
+ const groups = grouped(load()).map(g => {
+ const services = g.services.map(s => {
+ const st = statuses[s.id];
+ return {
+ id: s.id, name: s.name, host: s.host, url: s.url, icon: iconSlug(s),
+ status: st?.status || 'unknown', latency_ms: st?.latency_ms ?? null,
+ detail: st?.detail || null, checked_at: st?.checked_at || null
+ };
+ });
+ return { category: g.category, healthy: services.filter(s => s.status === 'ok').length,
+ total: services.length, services };
+ });
+ res.json(groups);
+}));
+
+router.post('/check', requireOwner, asyncWrap(async (_req, res) => {
+ const id = await enqueue('health.check', {});
+ res.status(202).json({ enqueued: id });
+}));
+```
+`lib/api/index.js`: `import { router as healthRouter } from './routes/health.js';` + `api.use('/health', healthRouter);`
+
+- [ ] **Step 4: Add the `health.check` worker** so `POST /check` resolves (re-uses checker). Create `lib/jobs/workers/health_check.js`:
+
+```js
+// lib/jobs/workers/health_check.js
+import { load } from '../../health/registry.js';
+import { checkAll } from '../../health/checker.js';
+import * as statusRepo from '../../db/repos/service_status.js';
+export const NAME = 'health.check';
+export async function handler(_job) {
+ const results = await checkAll(load());
+ for (const r of results) await statusRepo.upsert(r);
+}
+```
+Register it in `lib/jobs/index.js` (`import * as healthCheck from './workers/health_check.js';` + add `healthCheck` to `WORKERS`).
+
+- [ ] **Step 5: Run — expect PASS.** `npx vitest run tests/api/health.test.js`
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add lib/api/routes/health.js lib/jobs/workers/health_check.js lib/jobs/index.js lib/api/index.js tests/api/health.test.js
+git commit -m "feat(health): /api/health/services (grouped+counts) + owner /check"
+```
+
+---
+
+### Task 21: Local icon cache — `/api/icons/:slug.png`
+
+**Files:**
+- Create: `lib/health/icons.js`
+- Create: `lib/api/routes/icons.js`
+- Modify: `lib/api/index.js` (mount **before** `agentOrOwner`? No — icons are public assets for `
`; mount on `app` directly in `server.js` so no auth header is needed)
+- Modify: `server.js` (mount icons router on `app`, like `ingestRouter`)
+- Test: `tests/api/icons.test.js`
+
+> Rationale: `
` tags can't send the bearer header, so the icon route must be reachable without `agentOrOwner`. Mount it on `app` (like the ingest webhook), not under `/api` behind auth. Path stays `/api/icons/:slug.png` for tidiness — mount the router at `/api/icons` on `app` directly.
+
+- [ ] **Step 1: Failing test** (inject a fake fetcher; assert fetch-once + sanitize)
+
+```js
+// tests/api/icons.test.js
+import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
+import request from 'supertest';
+import { createApp } from '../../server.js';
+import * as icons from '../../lib/health/icons.js';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+
+let app;
+const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
+beforeAll(() => { app = createApp(); });
+beforeEach(() => { icons._setCacheDir(path.join(tmpdir(), 'void-icons-' + Date.now())); icons._setFetcher(null); });
+
+describe('icon cache', () => {
+ it('rejects an invalid slug', async () => {
+ const res = await request(app).get('/api/icons/..%2f..%2fetc%2fpasswd.png');
+ expect(res.status).toBe(400);
+ });
+ it('fetches once on miss then serves from cache', async () => {
+ const fetcher = vi.fn().mockResolvedValue(PNG);
+ icons._setFetcher(fetcher);
+ const r1 = await request(app).get('/api/icons/gitea.png');
+ expect(r1.status).toBe(200);
+ expect(r1.headers['content-type']).toContain('image/png');
+ await request(app).get('/api/icons/gitea.png');
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+ it('404s when upstream has no icon', async () => {
+ icons._setFetcher(vi.fn().mockResolvedValue(null));
+ expect((await request(app).get('/api/icons/nope.png')).status).toBe(404);
+ });
+});
+```
+
+- [ ] **Step 2: Run — expect FAIL.** `npx vitest run tests/api/icons.test.js`
+
+- [ ] **Step 3: Implement `lib/health/icons.js`**
+
+```js
+// lib/health/icons.js
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+
+let cacheDir = process.env.ICON_CACHE || '/var/lib/void/icons';
+let fetcher = defaultFetcher;
+export function _setCacheDir(d) { cacheDir = d; }
+export function _setFetcher(fn) { fetcher = fn || defaultFetcher; }
+
+const VALID = /^[a-z0-9-]+$/;
+export function validSlug(slug) { return VALID.test(slug); }
+
+async function defaultFetcher(slug) {
+ const url = `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${slug}.png`;
+ const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
+ if (!res.ok) return null;
+ return Buffer.from(await res.arrayBuffer());
+}
+
+function isPng(buf) { return buf && buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47; }
+
+// Returns a Buffer (cached or freshly fetched) or null if upstream has no icon.
+export async function getIcon(slug) {
+ if (!validSlug(slug)) throw new Error('invalid slug');
+ const file = path.join(cacheDir, `${slug}.png`);
+ try { return await readFile(file); } catch { /* miss → fetch */ }
+ const buf = await fetcher(slug);
+ if (!isPng(buf)) return null;
+ await mkdir(cacheDir, { recursive: true });
+ await writeFile(file, buf);
+ return buf;
+}
+```
+
+- [ ] **Step 4: Implement the route**
+
+```js
+// lib/api/routes/icons.js
+import { Router } from 'express';
+import { getIcon, validSlug } from '../../health/icons.js';
+export const router = Router();
+router.get('/:file', async (req, res) => {
+ const slug = req.params.file.replace(/\.png$/, '');
+ if (!validSlug(slug)) return res.status(400).json({ error: { code: 'bad_slug' } });
+ try {
+ const buf = await getIcon(slug);
+ if (!buf) return res.status(404).end();
+ res.set('Content-Type', 'image/png').set('Cache-Control', 'public, max-age=86400').send(buf);
+ } catch (e) {
+ res.status(e.message === 'invalid slug' ? 400 : 502).end();
+ }
+});
+```
+
+- [ ] **Step 5: Mount on `app` in `server.js`** (next to the ingest mount, before `mountApi`):
+
+```js
+import { router as iconsRouter } from './lib/api/routes/icons.js';
+// ... after app.use('/api/ingest', ingestRouter);
+app.use('/api/icons', iconsRouter);
+```
+
+- [ ] **Step 6: Run — expect PASS.** `npx vitest run tests/api/icons.test.js`
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add lib/health/icons.js lib/api/routes/icons.js server.js tests/api/icons.test.js
+git commit -m "feat(health): local icon cache /api/icons/:slug.png (no CDN leak)"
+```
+
+---
+
+### Task 22: Health band UI (avatar + tiles) + integrate
+
+**Files:**
+- Create: `public/components/littleblue_avatar.js`
+- Create: `public/components/service_tile.js`
+- Create: `public/views/health_band.js`
+- Modify: `public/views/sacred_valley.js` (render the band into `#sv-health`)
+- Modify: `public/style.css` (band + tile styles)
+
+No unit test (DOM); verified in browser (Step 5).
+
+- [ ] **Step 1: Avatar placeholder** (blue humanoid water-creature; inline SVG; final art later)
+
+```js
+// public/components/littleblue_avatar.js
+import { el } from '../dom.js';
+// Placeholder: a simple blue humanoid water-wisp. Swap for final art later.
+export function littleblueAvatar() {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('viewBox', '0 0 40 48'); svg.setAttribute('width', '40'); svg.setAttribute('height', '48');
+ svg.innerHTML = `
+
+
+
+
+
+ `;
+ return el('div', { class: 'lb-av' }, svg);
+}
+```
+
+- [ ] **Step 2: Service tile** (auto-icon from local cache + first-letter fallback)
+
+```js
+// public/components/service_tile.js
+import { el, safeHref } from '../dom.js';
+export function serviceTile(s) {
+ const img = el('img', { class: 'tile-icon', loading: 'lazy', src: `/api/icons/${s.icon}.png`, alt: s.name });
+ img.onerror = () => img.replaceWith(el('div', { class: 'tile-icon-fb' }, (s.name[0] || '?').toUpperCase()));
+ return el('a', { class: `tile status-${s.status}`, href: safeHref(s.url), target: '_blank', rel: 'noreferrer' },
+ img,
+ el('div', { class: 'tile-main' },
+ el('div', { class: 'tile-nm' }, el('span', { class: 'dot' }), s.name),
+ el('div', { class: 'tile-host' }, s.host || '')),
+ el('span', { class: 'tile-go' }, 'open ↗'));
+}
+```
+
+- [ ] **Step 3: Band view**
+
+```js
+// public/views/health_band.js
+import { el, mount } from '../dom.js';
+import { api } from '../api.js';
+import { littleblueAvatar } from '../components/littleblue_avatar.js';
+import { serviceTile } from '../components/service_tile.js';
+
+const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
+let host, timer;
+async function load() {
+ if (!host) return;
+ try {
+ const groups = await api.get('/api/health/services');
+ const sections = groups.map(g =>
+ el('div', { class: 'lb-section' },
+ el('div', { class: 'lb-group' },
+ el('span', { class: 'gname' }, TITLE[g.category] || g.category),
+ el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
+ el('span', { class: 'line' })),
+ el('div', { class: 'tiles' }, g.services.map(serviceTile))));
+ mount(host,
+ el('div', { class: 'lbwrap' }, littleblueAvatar(),
+ el('div', {}, el('div', { class: 'lb-name' }, 'Little Blue'),
+ el('div', { class: 'lb-sub' }, 'Health & Uptime of the lab'))),
+ sections);
+ } catch { mount(host, el('span', { class: 'muted' }, 'Health band unavailable')); }
+}
+export function renderHealthBand(el_) { host = el_; load(); timer = setInterval(load, 60000); }
+export function stopHealthBand() { clearInterval(timer); host = null; }
+```
+
+- [ ] **Step 4: Integrate** — in `sacred_valley.js`, import and call after cards; track for cleanup:
+
+```js
+import { renderHealthBand, stopHealthBand } from './health_band.js';
+// at top of render(), in the cleanup line, also: stopHealthBand();
+// at the end of render():
+renderHealthBand(document.getElementById('sv-health'));
+```
+
+- [ ] **Step 5: Styles** — append to `public/style.css`:
+
+```css
+#sv-health { margin-top: 28px; }
+.lbwrap { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
+.lb-av { width: 46px; height: 46px; filter: drop-shadow(0 0 10px rgba(125,211,216,.4)); animation: lb-bob 4s ease-in-out infinite; }
+@keyframes lb-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
+.lb-name { font-family: var(--font-display); letter-spacing: .08em; font-size: 15px; color: var(--text); }
+.lb-sub { font-family: var(--font-body); font-style: italic; color: var(--lb); font-size: 14px; }
+.lb-group { margin: 14px 0 8px; display: flex; align-items: center; gap: 10px; }
+.lb-group .gname { font-family: var(--font-display); text-transform: uppercase; letter-spacing: .14em; font-size: 11px; color: var(--muted); }
+.lb-group .gcount { font-family: var(--font-mono); font-size: 10px; color: var(--lb); }
+.lb-group .line { flex: 1; height: 1px; background: linear-gradient(90deg, rgba(125,211,216,.25), transparent); }
+.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
+.tile { display: flex; align-items: center; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px;
+ background: linear-gradient(160deg, #14141c, #101017); text-decoration: none; color: var(--text); transition: border-color .25s, transform .25s; }
+.tile:hover { transform: translateY(-2px); border-color: #37404a; }
+.tile-icon, .tile-icon-fb { width: 26px; height: 26px; border-radius: 6px; flex: none; }
+.tile-icon-fb { display: grid; place-items: center; background: #20202a; color: var(--muted); font-family: var(--font-mono); font-size: 13px; }
+.tile-main { flex: 1; min-width: 0; }
+.tile-nm { font-family: var(--font-mono); font-size: 12px; display: flex; align-items: center; gap: 7px; }
+.tile-host { font-family: var(--font-mono); font-size: 10px; color: var(--muted); margin-top: 4px; }
+.tile .dot { width: 8px; height: 8px; border-radius: 50%; }
+.tile.status-ok .dot { background: var(--ok); box-shadow: 0 0 7px var(--ok); }
+.tile.status-warn .dot { background: var(--warn); box-shadow: 0 0 7px var(--warn); }
+.tile.status-down .dot { background: var(--bad); box-shadow: 0 0 7px var(--bad); }
+.tile.status-unknown .dot { background: var(--muted); }
+.tile-go { color: var(--lb); font-size: 11px; opacity: 0; transition: opacity .25s; }
+.tile:hover .tile-go { opacity: 1; }
+```
+
+- [ ] **Step 6: Manual browser verification** — `#/sacred-valley` shows the two bands; tiles pull icons from `/api/icons/*` (check Network tab hits localhost, not jsDelivr, on the 2nd load); status dots reflect cached health; "open ↗" links work.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add public/components/littleblue_avatar.js public/components/service_tile.js public/views/health_band.js public/views/sacred_valley.js public/style.css
+git commit -m "feat(health): Little Blue health band — avatar, grouped service tiles, local icons"
+```
+
+---
+
+### Task 23: Release — version bump, CHANGELOG, deploy notes
+
+**Files:** Modify `server.js:11`, `package.json`, `CHANGELOG.md`, `deploy/README.md`
+
+- [ ] **Step 1:** Bump `VERSION` in `server.js:11` to `'2.0.0-alpha.8'` and `package.json` `version` to `2.0.0-alpha.8`.
+
+- [ ] **Step 2:** Prepend a CHANGELOG entry:
+
+```markdown
+## 2.0.0-alpha.8 — Sacred Valley (Plan 6)
+- Two-band #/sacred-valley dashboard: draggable data cards (clock, weather, host-perf, speedtest, jobs, inbox, search) with server-persisted layout.
+- Little Blue Health band: config service registry, 60s pg-boss health checks, grouped status tiles, locally-cached service icons (no CDN leak).
+- New endpoints: /api/dashboard/layout, /api/weather, /api/host, /api/speedtest/*, /api/health/*, /api/icons/:slug.png.
+- Migrations 012 (dashboard_layout), 013 (speedtest_results), 014 (service_status).
+```
+
+- [ ] **Step 3:** In `deploy/README.md` add a Plan 6 note: install `speedtest-cli` on CT 311 (`pip install --break-system-packages speedtest-cli`); ensure `ICON_CACHE=/var/lib/void/icons` dir exists and is owned by `void` (server auto-creates, but pre-create for clarity); migrations 012–014 run via `npm run migrate`.
+
+- [ ] **Step 4:** Run the **full suite** — expect all green.
+
+Run: `npx vitest run`
+Expected: PASS (existing suite + the new dashboard/weather/host/speedtest/health/icons/registry/reorder/registry tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server.js package.json CHANGELOG.md deploy/README.md
+git commit -m "chore: version 2.0.0-alpha.8 — Sacred Valley (Plan 6)"
+```
+
+---
+
+## Deploy (after the plan, standard recipe)
+1. `pct snapshot 310 pre_alpha8_deploy_` and same for 311 (standing rule — see [[feedback-backup-before-major-updates]]).
+2. `cd /project/src/void-v2 && ./deploy/push.sh` (TARGET defaults `root@192.168.1.216`).
+3. `ssh root@192.168.1.216 'cd /opt/void-server && npm run migrate'` (applies 012–014).
+4. `ssh root@192.168.1.216 'pip install --break-system-packages speedtest-cli'` (or Ookla CLI).
+5. `systemctl restart void-server` on .216.
+6. Verify `curl http://192.168.1.216:3000/health` → `version: 2.0.0-alpha.8`; open `#/sacred-valley`, confirm both bands + a health-check pass populated the tiles.
+
+---
+
+## Self-Review notes (author)
+- **Spec coverage:** two bands (T4/T22), 7 cards (T5/7/9/11/12/13/16), layout persistence (T1/T2/T10), weather/host/speedtest backends (T6/T8/T14-15), registry (T17), health engine (T19), `/api/health/*` (T20), icon cache local (T21), avatar placeholder (T22), theme tokens (T3), release (T23). All spec sections map to a task.
+- **Deviation:** config is **JSON** not YAML (avoids adding `js-yaml`) — noted in header + T17. Flag to user if YAML is preferred.
+- **Resolved against repo:** `validate` writes `req.body` (T2 ✓), `api.js` lacks `put` so T10 adds it (✓), `requireOwner` exported from `cap.js` (✓).
+- **Remaining verify-before-implement flag:** the real jobs/search/pending-changes response shapes (T11–T13) — each task says to read the existing full view + route first and match fields. These reuse endpoints already exist; only the field names need confirming.