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

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