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