feat(ui): static shell + router + api wrapper

Three-column grid (sidebar / main / right rail) with Cradle aesthetic:
blackflame accent on Cinzel display headings + Cormorant Garamond body
in cards, system UI for chrome. Hash-based router covers all entity
routes plus search, inbox, sacred-valley. api.js stores OWNER_TOKEN in
localStorage and prompts via a modal on 401. dom.js provides safe el()
+ mount() builders so no component ever assigns innerHTML from API data
(the only exception is an explicit, scary-named html: opt-in for
sanitizer output, used later by the markdown editor).

state.js is a tiny event bus for shared chrome state (pending count).
Components and views are loaded as ES modules — sidebar / topbar /
rightrail + 9 view stubs that the later Phase E tasks fill in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-01 02:12:18 +10:00
parent 69e26ada98
commit 59ad86425d
21 changed files with 575 additions and 0 deletions

70
public/api.js Normal file
View File

@@ -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()
};

64
public/app.js Normal file
View File

@@ -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')

View File

@@ -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.')
)
);
}

View File

@@ -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')
)
);
}

View File

@@ -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' })
);
}

48
public/dom.js Normal file
View File

@@ -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);
}

27
public/index.html Normal file
View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Void</title>
<!--
Cradle aesthetic: Cinzel for marquee headings (Sacred Valley, view titles),
Cormorant Garamond for body display in cards. System UI for chrome.
-->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&family=Cormorant+Garamond:wght@400;500;600&display=swap" />
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div id="shell">
<header id="topbar"></header>
<aside id="sidebar"></aside>
<main id="main"></main>
<aside id="rightrail"></aside>
</div>
<div id="modal-root"></div>
<script type="module" src="/app.js"></script>
</body>
</html>

47
public/router.js Normal file
View File

@@ -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());
}

20
public/state.js Normal file
View File

@@ -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<fn>
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); }

141
public/style.css Normal file
View File

@@ -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; }

15
public/views/home.js Normal file
View File

@@ -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.'
)
)
);
}

9
public/views/inbox.js Normal file
View File

@@ -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.')
);
}

9
public/views/page.js Normal file
View File

@@ -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.')
);
}

9
public/views/project.js Normal file
View File

@@ -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.')
);
}

View File

@@ -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.')
);
}

9
public/views/resource.js Normal file
View File

@@ -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.')
);
}

View File

@@ -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.'
)
)
);
}

9
public/views/search.js Normal file
View File

@@ -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.')
);
}

9
public/views/space.js Normal file
View File

@@ -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.')
);
}

View File

@@ -9,6 +9,7 @@ const VERSION = '2.0.0-alpha.1';
export function createApp() { export function createApp() {
const app = express(); const app = express();
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
app.use(express.static('public'));
app.get('/health', async (_req, res) => { app.get('/health', async (_req, res) => {
let db_ok = false; let db_ok = false;

View File

@@ -43,4 +43,11 @@ describe('server', () => {
const res = await request(app).get('/missing'); const res = await request(app).get('/missing');
expect(res.status).toBe(404); 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(/<div id="shell">/);
});
}); });