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:
70
public/api.js
Normal file
70
public/api.js
Normal 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
64
public/app.js
Normal 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')
|
||||||
25
public/components/rightrail.js
Normal file
25
public/components/rightrail.js
Normal 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.')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
12
public/components/sidebar.js
Normal file
12
public/components/sidebar.js
Normal 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')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
20
public/components/topbar.js
Normal file
20
public/components/topbar.js
Normal 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
48
public/dom.js
Normal 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
27
public/index.html
Normal 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
47
public/router.js
Normal 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
20
public/state.js
Normal 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
141
public/style.css
Normal 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
15
public/views/home.js
Normal 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
9
public/views/inbox.js
Normal 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
9
public/views/page.js
Normal 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
9
public/views/project.js
Normal 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.')
|
||||||
|
);
|
||||||
|
}
|
||||||
9
public/views/reference.js
Normal file
9
public/views/reference.js
Normal 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
9
public/views/resource.js
Normal 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.')
|
||||||
|
);
|
||||||
|
}
|
||||||
15
public/views/sacred_valley.js
Normal file
15
public/views/sacred_valley.js
Normal 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
9
public/views/search.js
Normal 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
9
public/views/space.js
Normal 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.')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user