diff --git a/public/api.js b/public/api.js
new file mode 100644
index 0000000..1e7ac4a
--- /dev/null
+++ b/public/api.js
@@ -0,0 +1,70 @@
+// Thin fetch wrapper. Owner token in localStorage.void_token. On 401 we
+// prompt for a token via a modal in modal-root. The wrapper is intentionally
+// minimal — no retries, no auto-refresh; the owner-only auth model doesn't
+// need them.
+
+import { el, mount, clear } from './dom.js';
+
+const TOKEN_KEY = 'void_token';
+
+function token() { return localStorage.getItem(TOKEN_KEY) || ''; }
+
+async function call(method, path, body) {
+ const headers = { 'Authorization': 'Bearer ' + token() };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ const res = await fetch(path, {
+ method,
+ headers,
+ body: body === undefined ? undefined : JSON.stringify(body)
+ });
+ if (res.status === 401) { await promptForToken(); return call(method, path, body); }
+ if (res.status === 204) return null;
+ const ct = res.headers.get('content-type') || '';
+ const data = ct.includes('application/json') ? await res.json() : await res.text();
+ if (!res.ok) {
+ const err = new Error(data?.error?.message || res.statusText);
+ err.status = res.status;
+ err.body = data;
+ throw err;
+ }
+ return data;
+}
+
+function promptForToken() {
+ return new Promise((resolve) => {
+ const root = document.getElementById('modal-root');
+ const input = el('input', { type: 'password', style: { width: '100%' }, autofocus: true });
+ const save = () => {
+ const v = input.value.trim();
+ if (!v) return;
+ localStorage.setItem(TOKEN_KEY, v);
+ clear(root);
+ resolve();
+ };
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') save(); });
+ mount(root,
+ el('div', { class: 'modal-backdrop' },
+ el('div', { class: 'modal' },
+ el('h2', {}, 'Owner Token'),
+ el('p', { class: 'muted' },
+ 'Paste the OWNER_TOKEN configured for the server. It will be stored in localStorage on this device only.'
+ ),
+ input,
+ el('div', { class: 'actions' },
+ el('button', { class: 'primary', onclick: save }, 'Save')
+ )
+ )
+ )
+ );
+ input.focus();
+ });
+}
+
+export const api = {
+ get: (p) => call('GET', p),
+ post: (p, body) => call('POST', p, body ?? {}),
+ patch: (p, body) => call('PATCH', p, body ?? {}),
+ del: (p) => call('DELETE', p),
+ setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
+ hasToken: () => !!token()
+};
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..5f7d11f
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,64 @@
+// Void 2.0 SPA bootstrap. Mounts chrome (sidebar/topbar/rightrail) and
+// delegates the #main panel to a view module based on the current route.
+// Views are loaded dynamically so a missing view never breaks the shell.
+
+import { api } from './api.js';
+import { route, current, navigate } from './router.js';
+import { renderSidebar } from './components/sidebar.js';
+import { renderTopbar } from './components/topbar.js';
+import { renderRightrail } from './components/rightrail.js';
+import { emit } from './state.js';
+import { el, mount } from './dom.js';
+
+const VIEWS = {
+ home: () => import('./views/home.js'),
+ space: () => import('./views/space.js'),
+ project: () => import('./views/project.js'),
+ page: () => import('./views/page.js'),
+ ref: () => import('./views/reference.js'),
+ resource: () => import('./views/resource.js'),
+ search: () => import('./views/search.js'),
+ inbox: () => import('./views/inbox.js'),
+ 'sacred-valley': () => import('./views/sacred_valley.js')
+};
+
+async function renderView(ctx) {
+ const main = document.getElementById('main');
+ const loader = VIEWS[ctx.name] || VIEWS.home;
+ try {
+ const mod = await loader();
+ await mod.render(main, ctx);
+ } catch (e) {
+ console.error('view render failed', e);
+ mount(main,
+ el('h1', { class: 'view-h1' }, 'Something went wrong'),
+ el('p', { class: 'view-sub' }, 'View failed to load. Check console for details.'),
+ el('pre', {}, String(e && e.stack || e))
+ );
+ }
+}
+
+async function pollPending() {
+ try {
+ const rows = await api.get('/api/pending-changes');
+ emit('pending-count', rows.length);
+ } catch (e) {
+ if (e.status === 403) emit('pending-count', 0);
+ }
+}
+
+async function init() {
+ if (!api.hasToken()) {
+ try { await api.get('/api/spaces'); }
+ catch { /* api wrapper opens the modal on 401 */ }
+ }
+ renderTopbar(document.getElementById('topbar'));
+ renderSidebar(document.getElementById('sidebar'));
+ renderRightrail(document.getElementById('rightrail'));
+ route(renderView);
+ pollPending();
+ setInterval(pollPending, 15000);
+}
+
+window.addEventListener('DOMContentLoaded', init);
+window.voidNav = navigate; // dev convenience: window.voidNav('/space/abc')
diff --git a/public/components/rightrail.js b/public/components/rightrail.js
new file mode 100644
index 0000000..a665aa3
--- /dev/null
+++ b/public/components/rightrail.js
@@ -0,0 +1,25 @@
+// T17 stub — collapsible rail with localStorage persistence.
+// Chat lands in Plan 5.
+import { el, mount } from '../dom.js';
+
+const KEY = 'void_rail_collapsed';
+
+export function renderRightrail(root) {
+ const shell = document.getElementById('shell');
+ let collapsed = localStorage.getItem(KEY) === 'true';
+ if (collapsed) shell.classList.add('rail-collapsed');
+
+ function toggle() {
+ collapsed = !collapsed;
+ localStorage.setItem(KEY, String(collapsed));
+ shell.classList.toggle('rail-collapsed', collapsed);
+ }
+
+ mount(root,
+ el('div', { class: 'rail-toggle', onclick: toggle, title: 'Toggle right rail' }, 'CRADLE'),
+ el('div', { class: 'rail-body' },
+ el('h3', { style: { marginTop: 0 } }, 'Companion'),
+ el('p', { class: 'muted' }, 'Chat lands in Plan 5. The rail is here so the layout is honest about the empty space it will take.')
+ )
+ );
+}
diff --git a/public/components/sidebar.js b/public/components/sidebar.js
new file mode 100644
index 0000000..b128f56
--- /dev/null
+++ b/public/components/sidebar.js
@@ -0,0 +1,12 @@
+// T17 stub — full implementation lands in T18.
+// For now: brand only, so the shell renders without errors.
+import { el, mount } from '../dom.js';
+
+export function renderSidebar(root) {
+ mount(root,
+ el('div', { class: 'sb-section' },
+ el('div', { class: 'sb-title' }, 'Void'),
+ el('div', { class: 'sb-item muted' }, 'Sidebar loads in T18')
+ )
+ );
+}
diff --git a/public/components/topbar.js b/public/components/topbar.js
new file mode 100644
index 0000000..f26207d
--- /dev/null
+++ b/public/components/topbar.js
@@ -0,0 +1,20 @@
+// T17 stub — full implementation lands in T18.
+import { el, mount } from '../dom.js';
+import { navigate } from '../router.js';
+
+export function renderTopbar(root) {
+ const search = el('input', {
+ type: 'text',
+ placeholder: 'Search …',
+ onkeydown: (e) => {
+ if (e.key === 'Enter' && e.target.value.trim()) {
+ navigate('/search?q=' + encodeURIComponent(e.target.value.trim()));
+ }
+ }
+ });
+ mount(root,
+ el('div', { class: 'brand' }, 'VOID'),
+ el('div', { class: 'topbar-search' }, search),
+ el('div', { class: 'topbar-spacer' })
+ );
+}
diff --git a/public/dom.js b/public/dom.js
new file mode 100644
index 0000000..7ee3aa1
--- /dev/null
+++ b/public/dom.js
@@ -0,0 +1,48 @@
+// Safe DOM builder used by all components / views. Never assigns
+// innerHTML from API data — all text is set via textContent (auto-escaped
+// by the browser), all structure via createElement.
+//
+// Usage:
+// el('div', { class: 'card', onclick: fn }, 'title', el('span', {}, 'sub'))
+// Children may be strings (becomes text node), Nodes, arrays of either,
+// or null/undefined (ignored).
+
+export function el(tag, attrs = {}, ...children) {
+ const node = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs || {})) {
+ if (v === null || v === undefined || v === false) continue;
+ if (k === 'class') node.className = v;
+ else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
+ else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
+ else if (k === 'dataset' && typeof v === 'object') Object.assign(node.dataset, v);
+ else if (k === 'html') {
+ // Explicit, scary-named opt-in. ONLY use with strings produced by
+ // marked() or other vetted sanitizers — never with raw API data.
+ node.innerHTML = v;
+ } else if (k in node && typeof node[k] !== 'function') {
+ try { node[k] = v; } catch { node.setAttribute(k, v); }
+ } else {
+ node.setAttribute(k, v);
+ }
+ }
+ appendAll(node, children);
+ return node;
+}
+
+function appendAll(parent, children) {
+ for (const c of children) {
+ if (c === null || c === undefined || c === false) continue;
+ if (Array.isArray(c)) appendAll(parent, c);
+ else if (c instanceof Node) parent.appendChild(c);
+ else parent.appendChild(document.createTextNode(String(c)));
+ }
+}
+
+export function clear(node) {
+ while (node.firstChild) node.removeChild(node.firstChild);
+}
+
+export function mount(node, ...children) {
+ clear(node);
+ appendAll(node, children);
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..3725dea
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ Void
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/router.js b/public/router.js
new file mode 100644
index 0000000..73840ae
--- /dev/null
+++ b/public/router.js
@@ -0,0 +1,47 @@
+// Hash-based router. Routes:
+// #/ home
+// #/space/:id space view
+// #/project/:id project view
+// #/page/:id page view
+// #/ref/:id reference detail
+// #/resource/:id resource detail
+// #/search?q= search results
+// #/inbox pending changes
+// #/sacred-valley dashboard placeholder
+// Anything unrecognized falls through to the home handler.
+
+const ROUTES = [
+ { name: 'space', re: /^\/space\/([^/]+)$/, keys: ['id'] },
+ { name: 'project', re: /^\/project\/([^/]+)$/, keys: ['id'] },
+ { name: 'page', re: /^\/page\/([^/]+)$/, keys: ['id'] },
+ { name: 'ref', re: /^\/ref\/([^/]+)$/, keys: ['id'] },
+ { name: 'resource', re: /^\/resource\/([^/]+)$/, keys: ['id'] },
+ { name: 'search', re: /^\/search$/, keys: [] },
+ { name: 'inbox', re: /^\/inbox$/, keys: [] },
+ { name: 'sacred-valley', re: /^\/sacred-valley$/, keys: [] },
+ { name: 'home', re: /^\/?$/, keys: [] }
+];
+
+export function current() {
+ const raw = (location.hash || '#/').slice(1);
+ const [path, queryString = ''] = raw.split('?');
+ const query = Object.fromEntries(new URLSearchParams(queryString));
+ for (const r of ROUTES) {
+ const m = path.match(r.re);
+ if (m) {
+ const params = {};
+ r.keys.forEach((k, i) => { params[k] = m[i + 1]; });
+ return { name: r.name, params, query, hash: raw };
+ }
+ }
+ return { name: 'home', params: {}, query: {}, hash: raw };
+}
+
+export function navigate(hash) {
+ location.hash = hash.startsWith('#') ? hash : '#' + hash;
+}
+
+export function route(handler) {
+ window.addEventListener('hashchange', () => handler(current()));
+ handler(current());
+}
diff --git a/public/state.js b/public/state.js
new file mode 100644
index 0000000..7825b90
--- /dev/null
+++ b/public/state.js
@@ -0,0 +1,20 @@
+// Tiny event bus for cross-component state (pending count, agent toggle, etc.).
+// No reactive framework — just publish/subscribe with last-value semantics.
+
+const subs = new Map(); // event → Set
+const last = new Map(); // event → last value
+
+export function on(event, fn) {
+ if (!subs.has(event)) subs.set(event, new Set());
+ subs.get(event).add(fn);
+ if (last.has(event)) fn(last.get(event));
+ return () => subs.get(event).delete(fn);
+}
+
+export function emit(event, value) {
+ last.set(event, value);
+ const set = subs.get(event);
+ if (set) for (const fn of set) fn(value);
+}
+
+export function get(event) { return last.get(event); }
diff --git a/public/style.css b/public/style.css
new file mode 100644
index 0000000..22692d1
--- /dev/null
+++ b/public/style.css
@@ -0,0 +1,141 @@
+/* Void 2.0 — Cradle / blackflame palette. Three-column grid shell. */
+:root {
+ --bg: #0a0a0e;
+ --panel: #14141c;
+ --panel-2: #1c1c26;
+ --border: #2a2a36;
+ --text: #e8e6ed;
+ --muted: #888094;
+ --accent: #ff4f2e; /* blackflame */
+ --accent-dim:#7a2716;
+ --accent-soft:#3a1610;
+ --ok: #6fa86a;
+ --warn: #d4a04a;
+ --bad: #c45a4a;
+
+ --topbar-h: 48px;
+ --sidebar-w: 280px;
+ --rail-w: 360px;
+ --rail-w-min: 40px;
+
+ --font-display: 'Cinzel', 'Cormorant Garamond', serif;
+ --font-body: 'Cormorant Garamond', Georgia, serif;
+ --font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+ --font-mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text); font-family: var(--font-ui); font-size: 14px; }
+
+#shell {
+ display: grid;
+ grid-template-columns: var(--sidebar-w) 1fr var(--rail-w);
+ grid-template-rows: var(--topbar-h) 1fr;
+ grid-template-areas:
+ "topbar topbar topbar"
+ "sidebar main rail";
+ height: 100vh;
+ width: 100vw;
+}
+#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
+
+#topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--panel); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
+#sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--panel); overflow-y: auto; padding: 12px 0; }
+#main { grid-area: main; overflow-y: auto; padding: 24px 32px; }
+#rightrail{ grid-area: rail; border-left: 1px solid var(--border); background: var(--panel); overflow: hidden; display: flex; flex-direction: column; }
+
+/* topbar */
+.brand { font-family: var(--font-display); font-weight: 700; letter-spacing: 0.18em; font-size: 13px; color: var(--accent); text-transform: uppercase; padding: 0 6px; }
+.topbar-search { flex: 1; max-width: 520px; }
+.topbar-search input {
+ width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text);
+ padding: 7px 10px; border-radius: 4px; font-family: var(--font-ui); font-size: 13px; outline: none;
+}
+.topbar-search input:focus { border-color: var(--accent-dim); }
+.topbar-spacer { flex: 1; }
+.icon-btn { background: transparent; border: 1px solid var(--border); color: var(--text); padding: 5px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
+.icon-btn:hover { border-color: var(--accent-dim); color: var(--accent); }
+.badge {
+ display: inline-block; min-width: 16px; padding: 1px 5px; border-radius: 8px;
+ background: var(--accent); color: var(--bg); font-size: 10px; font-weight: 700; text-align: center; margin-left: 4px;
+}
+
+/* sidebar */
+.sb-section { padding: 8px 12px; }
+.sb-title { font-family: var(--font-display); font-size: 10px; letter-spacing: 0.16em; color: var(--muted); text-transform: uppercase; padding: 6px 4px; }
+.sb-item {
+ display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 3px; cursor: pointer;
+ color: var(--text); text-decoration: none; font-size: 13px;
+}
+.sb-item:hover { background: var(--panel-2); }
+.sb-item.active { background: var(--accent-soft); color: var(--accent); }
+.sb-item .caret { color: var(--muted); width: 10px; text-align: center; font-size: 10px; }
+.sb-children { padding-left: 18px; }
+
+/* rightrail */
+.rail-toggle {
+ height: var(--topbar-h); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: center;
+ cursor: pointer; color: var(--muted); font-size: 11px;
+}
+.rail-body { flex: 1; padding: 14px 14px; overflow-y: auto; font-size: 13px; color: var(--muted); }
+#shell.rail-collapsed .rail-body { display: none; }
+
+/* main / views */
+.view-h1 { font-family: var(--font-display); font-size: 26px; font-weight: 500; letter-spacing: 0.04em; margin: 0 0 4px; color: var(--text); }
+.view-sub { color: var(--muted); margin-bottom: 24px; font-family: var(--font-body); font-size: 15px; }
+.card {
+ background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 16px 18px; margin-bottom: 12px;
+}
+.card h3 { font-family: var(--font-display); font-size: 14px; letter-spacing: 0.08em; text-transform: uppercase; margin: 0 0 10px; color: var(--accent); font-weight: 500; }
+.row { display: flex; gap: 16px; }
+.row > * { flex: 1; min-width: 0; }
+.muted { color: var(--muted); }
+.kbd { font-family: var(--font-mono); background: var(--panel-2); padding: 1px 6px; border: 1px solid var(--border); border-radius: 3px; font-size: 11px; }
+pre, code { font-family: var(--font-mono); font-size: 12px; }
+pre { background: var(--panel-2); border: 1px solid var(--border); padding: 10px 12px; border-radius: 4px; overflow-x: auto; }
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+hr { border: none; border-top: 1px solid var(--border); margin: 18px 0; }
+
+/* form */
+input[type=text], input[type=password], textarea, select {
+ background: var(--bg); border: 1px solid var(--border); color: var(--text);
+ padding: 6px 8px; border-radius: 3px; font-family: var(--font-ui); font-size: 13px; outline: none;
+}
+input:focus, textarea:focus, select:focus { border-color: var(--accent-dim); }
+button.primary {
+ background: var(--accent-dim); color: var(--text); border: 1px solid var(--accent); padding: 6px 14px;
+ border-radius: 3px; cursor: pointer; font-family: var(--font-ui); font-size: 13px;
+}
+button.primary:hover { background: var(--accent); color: var(--bg); }
+button.ghost {
+ background: transparent; color: var(--muted); border: 1px solid var(--border); padding: 6px 12px;
+ border-radius: 3px; cursor: pointer; font-size: 13px;
+}
+button.ghost:hover { color: var(--text); border-color: var(--accent-dim); }
+
+/* modal */
+.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
+.modal {
+ background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 22px 24px; min-width: 380px; max-width: 520px;
+}
+.modal h2 { font-family: var(--font-display); font-size: 18px; margin: 0 0 12px; color: var(--accent); letter-spacing: 0.06em; }
+.modal .actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
+
+/* lists */
+ul.plain { list-style: none; padding: 0; margin: 0; }
+ul.plain li { padding: 6px 0; border-bottom: 1px solid var(--border); }
+ul.plain li:last-child { border-bottom: none; }
+
+/* badges (status) */
+.status { display: inline-block; padding: 1px 6px; border-radius: 8px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; border: 1px solid var(--border); color: var(--muted); }
+.status.ok { color: var(--ok); border-color: var(--ok); }
+.status.warn { color: var(--warn); border-color: var(--warn); }
+.status.bad { color: var(--bad); border-color: var(--bad); }
+.status.idle { color: var(--muted); }
+
+/* search results */
+.search-group { margin-bottom: 22px; }
+.search-group .group-h { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.18em; color: var(--accent); text-transform: uppercase; margin-bottom: 6px; }
+.search-hit { padding: 8px 10px; border: 1px solid var(--border); border-radius: 3px; margin-bottom: 6px; background: var(--panel); }
+.search-hit .hit-meta { color: var(--muted); font-size: 11px; }
diff --git a/public/views/home.js b/public/views/home.js
new file mode 100644
index 0000000..390648d
--- /dev/null
+++ b/public/views/home.js
@@ -0,0 +1,15 @@
+// T17 stub — recent activity lands in T19.
+import { el, mount } from '../dom.js';
+
+export async function render(main) {
+ mount(main,
+ el('h1', { class: 'view-h1' }, 'The Void'),
+ el('p', { class: 'view-sub' }, 'Step into the abyss. Everything else loads as you navigate.'),
+ el('div', { class: 'card' },
+ el('h3', {}, 'Welcome'),
+ el('p', { class: 'muted' },
+ 'Pick a space from the sidebar to start. The API is alive; the views populate as Phase E lands.'
+ )
+ )
+ );
+}
diff --git a/public/views/inbox.js b/public/views/inbox.js
new file mode 100644
index 0000000..a82b556
--- /dev/null
+++ b/public/views/inbox.js
@@ -0,0 +1,9 @@
+// T17 stub — full implementation lands in T21.
+import { el, mount } from '../dom.js';
+export async function render(main) {
+ mount(main,
+ el('h1', { class: 'view-h1' }, 'Inbox'),
+ el('p', { class: 'view-sub muted' }, 'Pending agent suggestions.'),
+ el('div', { class: 'card muted' }, 'Inbox view ships in T21.')
+ );
+}
diff --git a/public/views/page.js b/public/views/page.js
new file mode 100644
index 0000000..649580a
--- /dev/null
+++ b/public/views/page.js
@@ -0,0 +1,9 @@
+// T17 stub — full implementation lands in T20.
+import { el, mount } from '../dom.js';
+export async function render(main, ctx) {
+ mount(main,
+ el('h1', { class: 'view-h1' }, 'Page'),
+ el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')),
+ el('div', { class: 'card muted' }, 'Page editor ships in T20.')
+ );
+}
diff --git a/public/views/project.js b/public/views/project.js
new file mode 100644
index 0000000..be13833
--- /dev/null
+++ b/public/views/project.js
@@ -0,0 +1,9 @@
+// T17 stub — full implementation lands in T19.
+import { el, mount } from '../dom.js';
+export async function render(main, ctx) {
+ mount(main,
+ el('h1', { class: 'view-h1' }, 'Project'),
+ el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')),
+ el('div', { class: 'card muted' }, 'Project view ships in T19.')
+ );
+}
diff --git a/public/views/reference.js b/public/views/reference.js
new file mode 100644
index 0000000..218a0e1
--- /dev/null
+++ b/public/views/reference.js
@@ -0,0 +1,9 @@
+// T17 stub — full implementation lands in T20.
+import { el, mount } from '../dom.js';
+export async function render(main, ctx) {
+ mount(main,
+ el('h1', { class: 'view-h1' }, 'Reference'),
+ el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')),
+ el('div', { class: 'card muted' }, 'Reference detail ships in T20.')
+ );
+}
diff --git a/public/views/resource.js b/public/views/resource.js
new file mode 100644
index 0000000..e05cd5d
--- /dev/null
+++ b/public/views/resource.js
@@ -0,0 +1,9 @@
+// T17 stub — full implementation lands in T21.
+import { el, mount } from '../dom.js';
+export async function render(main, ctx) {
+ mount(main,
+ el('h1', { class: 'view-h1' }, 'Resource'),
+ el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')),
+ el('div', { class: 'card muted' }, 'Resource detail ships in T21.')
+ );
+}
diff --git a/public/views/sacred_valley.js b/public/views/sacred_valley.js
new file mode 100644
index 0000000..15fd893
--- /dev/null
+++ b/public/views/sacred_valley.js
@@ -0,0 +1,15 @@
+// T17 stub — placeholder card per plan (full widgets in Plan 6).
+import { el, mount } from '../dom.js';
+export async function render(main) {
+ mount(main,
+ el('h1', { class: 'view-h1' }, 'Sacred Valley'),
+ el('p', { class: 'view-sub' }, 'The homelab dashboard. Widgets port over in Plan 6.'),
+ el('div', { class: 'card' },
+ el('h3', {}, 'Coming home'),
+ el('p', { class: 'muted' },
+ 'Weather, speedtest, host-perf, media cards — all ride in from Void 1.x in Plan 6. ' +
+ 'For now this card holds the route open so the sidebar link works.'
+ )
+ )
+ );
+}
diff --git a/public/views/search.js b/public/views/search.js
new file mode 100644
index 0000000..5feed34
--- /dev/null
+++ b/public/views/search.js
@@ -0,0 +1,9 @@
+// T17 stub — full implementation lands in T22.
+import { el, mount } from '../dom.js';
+export async function render(main, ctx) {
+ mount(main,
+ el('h1', { class: 'view-h1' }, 'Search'),
+ el('p', { class: 'view-sub muted' }, 'q: ' + (ctx.query.q || '—')),
+ el('div', { class: 'card muted' }, 'Search view ships in T22.')
+ );
+}
diff --git a/public/views/space.js b/public/views/space.js
new file mode 100644
index 0000000..0c6c390
--- /dev/null
+++ b/public/views/space.js
@@ -0,0 +1,9 @@
+// T17 stub — full implementation lands in T19.
+import { el, mount } from '../dom.js';
+export async function render(main, ctx) {
+ mount(main,
+ el('h1', { class: 'view-h1' }, 'Space'),
+ el('p', { class: 'view-sub muted' }, 'id: ' + (ctx.params.id || '—')),
+ el('div', { class: 'card muted' }, 'Space view ships in T19.')
+ );
+}
diff --git a/server.js b/server.js
index d3d3e5c..e745b2e 100644
--- a/server.js
+++ b/server.js
@@ -9,6 +9,7 @@ const VERSION = '2.0.0-alpha.1';
export function createApp() {
const app = express();
app.use(express.json({ limit: '10mb' }));
+ app.use(express.static('public'));
app.get('/health', async (_req, res) => {
let db_ok = false;
diff --git a/tests/server.test.js b/tests/server.test.js
index 934bd75..17de34f 100644
--- a/tests/server.test.js
+++ b/tests/server.test.js
@@ -43,4 +43,11 @@ describe('server', () => {
const res = await request(app).get('/missing');
expect(res.status).toBe(404);
});
+
+ it('GET / serves the SPA shell (text/html)', async () => {
+ const res = await request(app).get('/');
+ expect(res.status).toBe(200);
+ expect(res.headers['content-type']).toMatch(/text\/html/);
+ expect(res.text).toMatch(//);
+ });
});