Admin: tool registry, grouped nav, and an /admin/ hub

This commit is contained in:
jessikitty committed 2026-10-12 13:18:59 +11:00
1 parent 7fc2b02e6a
commit 7040d4f078
9 files changed
+412 -7

No files matched your search

+1
View File
@@ -42,5 +42,6 @@ volume, so live state carries over and rollback is one command.
## Session Log
- 2026-10-12 — Admin Navigation Tidy-up — tool registry driving both the nav bar and a new /admin/ hub; unreachable A3 test rig and an unauthenticated calibrate page fixed.
- 2026-10-07 — Corner Precision and ROI Detection — sub-pixel corner refinement (own implementation; `cornerSubPix` is absent from the pinned build), ROI-first detection cropping from the native video, and an anchor-coplanarity check surfaced in the HUD.
- 2026-09-23 — AR Tracking Overhaul: Worker Offload, Camera Model, and Coplanar Pose Ambiguity — moved OpenCV off the main thread, fixed the focal/FOV model for portrait streams, deduped marker ids, gated the mirrored coplanar pose, and built a two-page A3 test rig with a foldable marker tower.
+23
View File
@@ -111,6 +111,29 @@ across the table. A layout re-saved by an editor that does not understand wall
anchors can silently become flat. Reference values: A3 rig with the tower ~3.5cm,
a town layout with one wall crest at 8cm ~2.7cm, any all-tabletop layout 0.0cm.
## Admin chrome
`public/admin/admin.js` holds a `TOOLS` registry — id, display name, group and
a one-line blurb for every admin page. The nav bar (`nav()`) and the hub
(`/admin/index.html`) are both generated from it, so a new page is registered
once and cannot appear in one and not the other. `tools/check-admin-nav.mjs`
checks the other direction: every `.html` in `public/admin` except the hub and
the login screen must be registered.
This replaced a hand-kept array of filenames, under which `testsheet.html`
existed but was listed nowhere, and `calibrate.html` was listed but never
rendered the bar — and never called `requireAuth()`, so in final mode it was
the one admin page reachable without the password.
Two pages cannot host the bar: `calibrate.html` is a full-bleed camera view and
`testsheet.html` must print edge to edge. They carry `chrome: false` and get a
link home instead — a floating pill (`backLink()`, styled by the separately
exported `BACK_CSS`) for calibrate, an inline link in the existing toolbar for
testsheet, where a floating pill would cover the Print button.
`nav()`'s root element stays `<header>`: `print.html` hides `header` on print
and `plan.html` hides its `#hdr` wrapper, and both rules depend on it.
## Camera model
Everything derives from one assumption — `HFOV_DEG` (default 60, override via
@@ -0,0 +1,105 @@
# Admin Navigation Tidy-up
**Date:** 2026-10-12
**Project:** newbury-exhibit-v3
**Status:** written and checked offline; not yet opened on the deployed server
**Tags:** admin, navigation, ux, auth
## Context
The admin section had grown to eleven pages behind a nav bar that was a flat,
hand-maintained array of nine lowercase filenames. Asked to make it easier to
navigate; reading it first turned up three things worse than untidiness.
## What was actually wrong
- **`testsheet.html` was unreachable.** It existed, it worked, it was in no
nav list. The only way in was typing the URL.
- **`calibrate.html` was listed in the nav but never rendered it** — it imports
nothing from `admin.js`, so the link led to a page with no way back.
- **`calibrate.html` never called `requireAuth()`.** Every other admin page
does. In `final` mode it was the one admin page openable without the
password. That is a hole, not a nav problem, and it was only visible because
the nav work meant reading every page's imports.
- **`/admin/` had no `index.html`**, so the bare path 404s and there is no
landing point.
- **Labels were filenames**: `landing`, `plan`, `print`, `preview` say nothing
about what the page does, and the four physical-setup tools sat
interleaved with the content editors.
## Key Decisions
- **One registry, two consumers.** `TOOLS` in `admin.js` carries id, display
name, group and a one-line blurb. `nav()` and the new hub both generate from
it. The old bug class — a page existing in the folder but not in the bar —
needs someone to forget the registry rather than forget one of two lists.
- **`tools/check-admin-nav.mjs` closes the loop the registry cannot.** The
registry guarantees the bar and the hub agree with each other; it cannot
notice a page nobody registered. The check walks `public/admin/*.html` and
fails on anything unregistered, plus asserts `nav()` marks exactly one link
active and still roots at `<header>`.
*Rejected:* a rule that a tool's label must differ from its filename. It
fired on Layout, Characters, Playlist and Calibrate, which are all correctly
named. The labels that were genuinely bad were bad because they did not
describe the page, which no mechanical rule can tell.
- **Four groups: World, Viewer, Setup, Diagnostics.** World is what is in the
exhibit (layout, characters, playlist), Viewer is what visitors get (landing
page, virtual viewport), Setup is the physical work (crests, plan, A3 rig,
calibrate), Diagnostics is the error log. The group label doubles as the
separator in the bar, so the grouping survives wrapping onto a second row on
a phone rather than becoming a run of orphaned pipes.
- **`nav()`'s root stays `<header>`.** `print.html` hides `header` on print and
`plan.html` hides its `#hdr` wrapper; changing the element would have
silently printed the nav bar onto the crest sheets.
- **Additive CSS only.** Existing selectors in `styles` are untouched except
for `flex-wrap` on `header`; ten pages inject `styles` plus their own rules
and several style `h1` indirectly through it. New chrome is namespaced under
`.anav`, `.hub`, `.card`, `.chip`.
- **Two pages cannot host the bar.** `calibrate.html` is a full-bleed camera
view and `testsheet.html` must print edge to edge. Both carry
`chrome: false`. Calibrate gets a floating pill (`backLink()`, styled from
`BACK_CSS`, exported separately so the page need not pull in the body and
background rules that would wreck it). Testsheet gets an inline link in its
existing toolbar — a floating pill would have landed on top of the Print A3
button.
- **Hub status chips never block.** Each endpoint is fetched in parallel and a
failure drops its chip rather than the page. The hub's job is to get you
somewhere else quickly; it must not sit spinning because the error log is
unhappy.
## Technical Details
`public/admin/admin.js` — added `GROUPS`, `TOOLS`, `toolById`, `href`,
`BACK_CSS`, `backLink()`; rewrote `nav()`; extended `styles` additively.
`api()` and `requireAuth()` unchanged.
`public/admin/index.html` — new. Mode banner (dev vs final), then grouped
cards. Chips: crest and spawn counts from `/api/scene`, tracks/residents/
windows from `/api/playlist`, built models from `/api/characters`, roster size
from `/api/ghosts`, error count from `/api/client-errors`.
`public/admin/calibrate.html` — added `requireAuth()` and `backLink()`.
`public/admin/testsheet.html` — added an inline `← Admin` link to `.bar`.
## Open Questions / Next Steps
- **Nothing here has been opened against a running server.** The checks are
static: registry/folder agreement, `nav()` output, HTML tag balance, module
syntax. Worth confirming the hub's chips match reality on first load, since
the exact shape of `/api/characters` and `/api/ghosts` (array vs wrapper
object) is handled defensively rather than known.
- `calibrate.html` gaining `requireAuth()` means it now redirects to the login
screen in final mode. Expected, but it is a behaviour change on a page that
used to open straight to the camera.
- The hub is a plain page; a tablet propped next to the table would want the
virtual viewport, not this. Possibly worth a "kiosk" entry later.
- Still outstanding from 2026-10-07: on-device validation of the sub-pixel and
ROI work, re-tuning the 1€ filter and `MAX_REPROJ_PX` if `reproj` has
dropped, and carrying a raised crest into the real town layout.
+1
View File
@@ -13,6 +13,7 @@ landing page (admin URLs must be typed in full).
| Path | Page | What it does |
|------|------|--------------|
| `/` | Exhibit | Landing screen → camera AR view. The thing visitors use. Tracking HUD appears in dev mode. |
| `/admin/` | Admin hub | Every tool, grouped, with a one-line description and live counts. Generated from `TOOLS` in `admin.js`. |
## Admin
+125 -7
View File
@@ -1,4 +1,5 @@
/* admin.js — shared auth + API helpers for admin pages. */
/* admin.js — shared auth, API helpers, tool registry and chrome for admin pages. */
export async function api(path, method = 'GET', body) {
const res = await fetch(path, {
method,
@@ -22,7 +23,63 @@ export async function requireAuth() {
return info;
}
export const styles = `
/* ---------------------------------------------------------------------------
* TOOL REGISTRY — the single source of truth for what the admin section
* contains. The nav bar and the /admin/ hub are both generated from it, so a
* new page is added in one place and cannot go missing from one and not the
* other. That is how `testsheet` ended up unreachable: the nav was a hand-kept
* array of filenames and nobody remembered to extend it.
*
* `name` is what the thing IS, not what its file is called. `blurb` is one
* line, written for someone who has not opened the page in a month.
* `chrome: false` marks a page whose layout cannot host the nav bar — a
* full-bleed camera view, or a sheet that must print edge to edge. Those get a
* small floating back link instead (see backLink()).
* ------------------------------------------------------------------------- */
export const GROUPS = ['World', 'Viewer', 'Setup', 'Diagnostics'];
export const TOOLS = [
{ id: 'layout', group: 'World', name: 'Layout',
blurb: 'Place crests, buildings, occluders and ghost spawn points on the table in 3D.' },
{ id: 'characters', group: 'World', name: 'Characters',
blurb: 'Build ghosts from parts — model, appearance, textures, part placement.' },
{ id: 'playlist', group: 'World', name: 'Playlist',
blurb: 'Which ghosts appear, when, for how long, and how they move.' },
{ id: 'landing', group: 'Viewer', name: 'Landing page',
blurb: 'What visitors see before the camera starts — artwork, theme, disclaimer.' },
{ id: 'preview', group: 'Viewer', name: 'Virtual viewport',
blurb: 'Watch the live scene play out from a chosen viewpoint, without a phone.' },
{ id: 'print', group: 'Setup', name: 'Print crests',
blurb: 'Printable sheet of the crests this layout uses, at true size.' },
{ id: 'plan', group: 'Setup', name: 'Placement plan',
blurb: 'Printable overhead plan showing where each crest goes on the table.' },
{ id: 'testsheet', group: 'Setup', name: 'A3 test rig', chrome: false,
blurb: 'Self-contained A3 sheet with a raised marker tower, for testing tracking.' },
{ id: 'calibrate', group: 'Setup', name: 'Calibrate', chrome: false,
blurb: 'Point a phone at a crest and read pose, FOV and reprojection error live.' },
{ id: 'errors', group: 'Diagnostics', name: 'Client errors',
blurb: 'Problems reported by viewer devices, newest first.' },
];
export const toolById = id => TOOLS.find(t => t.id === id);
export const href = t => `/admin/${t.id}.html`;
/* Kept separate so a full-bleed tool can inject just this, without pulling in
the body/background rules that would wreck its layout. */
export const BACK_CSS = `
/* Back link for full-bleed tools that cannot host the bar. */
.aback { position:fixed; top:8px; left:8px; z-index:9999; background:rgba(10,13,24,.82);
color:#8fb8ff; text-decoration:none; font:500 .8rem system-ui,sans-serif;
padding:6px 11px; border-radius:999px; border:1px solid rgba(143,184,255,.3);
backdrop-filter:blur(6px); }
.aback:hover { color:#51eaf1; border-color:#51eaf1; }
@media print { .aback { display:none !important; } }
`;
export const styles = BACK_CSS + `
:root { color-scheme: dark; }
body { margin:0; background:#0a0d18; color:#e8ecff; font-family:system-ui,sans-serif; }
header { display:flex; gap:14px; align-items:center; padding:10px 16px; background:#111730; }
@@ -34,13 +91,74 @@ export const styles = `
button.danger { background:#7a2437; }
input, select { background:#141a33; color:#fff; border:1px solid #2a3a6e; border-radius:5px; padding:4px 8px; }
label { font-size:.8rem; opacity:.85; }
/* ---- admin chrome (nav bar) ---------------------------------------- */
header.anav { flex-wrap:wrap; row-gap:8px; padding:9px 16px; border-bottom:1px solid #1e2748; }
.anav .brand { font-weight:600; color:#e8ecff; font-size:.95rem; letter-spacing:.01em; white-space:nowrap; }
.anav .brand b { color:#51eaf1; font-weight:600; }
.anav .grp { display:flex; align-items:center; gap:10px; }
/* Group label doubles as the separator — no pipes to line up, and the
grouping survives wrapping onto a second row on a phone. */
.anav .grp > .lbl { font-size:.62rem; text-transform:uppercase; letter-spacing:.09em;
opacity:.45; white-space:nowrap; }
.anav nav { display:flex; flex-wrap:wrap; align-items:center; gap:10px 16px; flex:1 1 auto; }
.anav nav a { padding:3px 0; border-bottom:2px solid transparent; white-space:nowrap; }
.anav nav a:hover { color:#cfe0ff; }
.anav nav a.active { border-bottom-color:#51eaf1; }
.anav .viewer { white-space:nowrap; opacity:.8; }
@media (max-width:640px) {
.anav { padding:8px 12px; }
.anav nav { gap:8px 12px; }
.anav nav a { font-size:.85rem; }
}
/* ---- hub ------------------------------------------------------------ */
.hub { padding:22px 18px 40px; max-width:1100px; margin:0 auto; }
.hub section { margin:0 0 26px; }
.hub section > h2 { font-size:.7rem; text-transform:uppercase; letter-spacing:.11em;
opacity:.5; margin:0 0 10px; font-weight:600; }
.cards { display:grid; gap:12px; grid-template-columns:repeat(auto-fill,minmax(265px,1fr)); }
.card { display:block; text-decoration:none; color:inherit; background:#111730;
border:1px solid #1e2748; border-radius:10px; padding:13px 15px;
transition:border-color .12s, background .12s; }
.card:hover { border-color:#51eaf1; background:#141c3a; }
.card .t { font-weight:600; font-size:.98rem; margin:0 0 4px; display:flex;
align-items:center; gap:8px; }
.card .b { font-size:.82rem; line-height:1.45; opacity:.68; margin:0; }
.chip { font-size:.68rem; font-weight:600; padding:2px 7px; border-radius:999px;
background:#1c2550; color:#8fb8ff; white-space:nowrap; }
.chip.warn { background:#4a2030; color:#ff9db4; }
.chip.ok { background:#133a3d; color:#51eaf1; }
`;
/* The bar. Root stays <header> so the print rules in print.html and plan.html
keep hiding it. */
export function nav(active) {
return `<header>
<h1>Newbury Exhibit — Admin</h1>
${['preview', 'layout', 'plan', 'playlist', 'characters', 'landing', 'print', 'calibrate', 'errors'].map(p =>
`<a href="/admin/${p}.html" class="${p === active ? 'active' : ''}">${p}</a>`).join('')}
<a href="/">viewer ↗</a>
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const groups = GROUPS.map(g => {
const links = TOOLS.filter(t => t.group === g).map(t =>
`<a href="${href(t)}" class="${t.id === active ? 'active' : ''}"${t.id === active ? ' aria-current="page"' : ''}>${esc(t.name)}</a>`
).join('');
return links ? `<span class="grp"><span class="lbl">${esc(g)}</span>${links}</span>` : '';
}).join('');
return `<header class="anav">
<a class="brand" href="/admin/">Newbury Exhibit <b>Admin</b></a>
<nav aria-label="Admin tools">${groups}</nav>
<a class="viewer" href="/" target="_blank" rel="noopener">Viewer ↗</a>
</header>`;
}
/* For pages whose layout cannot host the bar (full-bleed camera, print sheets).
Call once; it injects its own style so the page need not import `styles`. */
export function backLink(label = 'Admin') {
const el = document.createElement('a');
el.className = 'aback';
el.href = '/admin/';
el.textContent = '← ' + label;
const css = document.createElement('style');
css.textContent = BACK_CSS;
document.head.appendChild(css);
document.body.appendChild(el);
return el;
}
+6
View File
@@ -29,6 +29,12 @@ import * as THREE from 'three';
import { createDetector, detectMarkers, quadArea } from '/js/ar/detect.js';
import { PoseEstimator } from '/js/ar/pose.js';
import { WorldFuser } from '/js/ar/fuse.js';
import { requireAuth, backLink } from './admin.js';
/* This page used to skip requireAuth entirely — in final mode it was the one
admin page you could open without the password. */
await requireAuth();
backLink();
const scene = await (await fetch('/api/scene')).json();
const fuser = new WorldFuser(); fuser.setScene(scene);
+90
View File
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin — Newbury Exhibit</title></head>
<body><div id="app"></div>
<script type="module">
/* The admin home. Everything here is generated from the TOOLS registry in
* admin.js, so adding a page to that array is the whole job — no risk of a
* tool existing but being reachable from nowhere, which is how the A3 test rig
* spent its first weeks.
*
* The status chips are deliberately cheap: one parallel burst of GETs that the
* pages themselves already call. Anything that fails is simply left off rather
* than blocking the page — the point of this screen is to get you somewhere
* else quickly, so it must never sit there spinning. */
import { requireAuth, styles, nav, TOOLS, GROUPS, href } from './admin.js';
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
.mode { display:flex; align-items:center; gap:9px; flex-wrap:wrap;
margin:0 0 20px; font-size:.84rem; opacity:.8; }
.hub .empty { font-size:.8rem; opacity:.5; }
</style>`);
const info = await requireAuth();
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
const n = v => Array.isArray(v) ? v.length : 0;
/* Never let one dead endpoint take the page down. */
const get = async (p) => {
try {
const r = await fetch(p, { headers: { 'x-exhibit-token': localStorage.getItem('exhibitToken') || '' } });
return r.ok ? await r.json() : null;
} catch { return null; }
};
const [scene, playlist, characters, errors, ghosts] = await Promise.all([
get('/api/scene'), get('/api/playlist'), get('/api/characters'),
get('/api/client-errors'), get('/api/ghosts'),
]);
/* id -> [{text, cls}]. Only facts worth knowing before you click. */
const chips = {};
const add = (id, text, cls = '') => { (chips[id] ||= []).push({ text, cls }); };
if (scene) {
add('layout', `${n(scene.anchors)} crest${n(scene.anchors) === 1 ? '' : 's'}`);
if (n(scene.spawns)) add('layout', `${n(scene.spawns)} spawns`);
if (!n(scene.anchors)) add('layout', 'no crests yet', 'warn');
add('print', `${n(scene.anchors)} to print`);
}
if (playlist) {
const tracks = n(playlist.tracks), res = n(playlist.residents);
add('playlist', `${tracks} track${tracks === 1 ? '' : 's'}`);
if (res) add('playlist', `${res} resident${res === 1 ? '' : 's'}`);
if (n(playlist.windows)) add('playlist', `${n(playlist.windows)} time window${n(playlist.windows) === 1 ? '' : 's'}`);
}
if (characters) {
const c = Array.isArray(characters) ? characters.length : n(characters.characters);
if (c) add('characters', `${c} built`);
}
if (ghosts) {
const g = Array.isArray(ghosts) ? ghosts.length : n(ghosts.ghosts);
if (g) add('characters', `${g} in roster`);
}
if (Array.isArray(errors)) {
add('errors', errors.length ? `${errors.length} logged` : 'none', errors.length ? 'warn' : 'ok');
}
const card = t => `<a class="card" href="${href(t)}">
<p class="t">${esc(t.name)}${(chips[t.id] || []).map(c => `<span class="chip ${c.cls}">${esc(c.text)}</span>`).join('')}</p>
<p class="b">${esc(t.blurb)}</p>
</a>`;
const section = g => {
const ts = TOOLS.filter(t => t.group === g);
if (!ts.length) return '';
return `<section><h2>${esc(g)}</h2><div class="cards">${ts.map(card).join('')}</div></section>`;
};
const modeChip = info && info.mode === 'final'
? '<span class="chip ok">final — admin password protected</span>'
: '<span class="chip warn">dev mode — admin is open, no password</span>';
document.getElementById('app').innerHTML = `${nav('')}
<div class="hub">
<div class="mode">${modeChip}<span>Changes save to the live volume and push to viewers immediately.</span></div>
${GROUPS.map(section).join('')}
</div>`;
</script>
</body></html>
+3
View File
@@ -7,6 +7,8 @@
.bar button { padding:9px 16px; border-radius:8px; border:0; cursor:pointer; font-weight:600; }
.bar .go { background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; }
.bar .ghost { background:transparent; color:#e8ecff; border:1px solid rgba(255,255,255,.35); }
.bar .home { color:#8fb8ff; text-decoration:none; font-size:.85rem; margin-right:4px; }
.bar .home:hover { color:#51eaf1; }
.note { padding:0 18px 14px; font-size:.85rem; line-height:1.6; opacity:.85; max-width:70rem; }
.note code { background:rgba(255,255,255,.1); padding:1px 5px; border-radius:4px; }
#status { font-size:.85rem; opacity:.9; }
@@ -27,6 +29,7 @@
<body>
<div class="bar">
<a class="home" href="/admin/">&larr; Admin</a>
<button class="go" onclick="window.print()">Print A3</button>
<button class="ghost" id="load">Load this layout onto the server</button>
<span id="status"></span>
+58
View File
@@ -0,0 +1,58 @@
/* check-admin-nav.mjs — every admin page must be reachable from the nav.
*
* node tools/check-admin-nav.mjs
*
* The A3 test rig shipped unreachable: the nav was a hand-maintained array of
* filenames and adding testsheet.html to the folder did not add it to the bar.
* Now the bar and the hub are both generated from TOOLS in admin.js, and this
* checks the other direction — that the folder and the registry agree.
*/
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { TOOLS, GROUPS, nav, href } from '../public/admin/admin.js';
const adminDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public', 'admin');
/* Pages that are intentionally not tools: the hub itself and the login screen. */
const NOT_TOOLS = new Set(['index.html', 'login.html']);
const pages = readdirSync(adminDir).filter(f => f.endsWith('.html') && !NOT_TOOLS.has(f));
const ids = new Set(TOOLS.map(t => t.id));
let bad = 0;
const fail = m => { console.log(' FAIL ' + m); bad++; };
console.log('admin nav / page registry\n');
for (const f of pages) {
const id = f.replace(/\.html$/, '');
if (!ids.has(id)) fail(`${f} exists but is not in TOOLS — unreachable from the nav and the hub`);
}
for (const t of TOOLS) {
if (!pages.includes(`${t.id}.html`)) fail(`TOOLS lists "${t.id}" but ${t.id}.html does not exist`);
if (!t.name || !t.blurb) fail(`"${t.id}" is missing a name or blurb`);
if (!GROUPS.includes(t.group)) fail(`"${t.id}" is in group "${t.group}", which is not in GROUPS`);
}
/* No check that a label differs from its filename: "Layout" and "Playlist" are
the right names and happen to match. The labels that were actually wrong
("landing", "plan", "print") were wrong because they did not say what the
page does, which no rule here can tell. */
/* The bar must actually contain every tool, and mark exactly one active. */
const bar = nav('playlist');
for (const t of TOOLS) {
if (!bar.includes(href(t))) fail(`nav() output is missing a link to ${href(t)}`);
if (!bar.includes(`>${t.name}<`)) fail(`nav() output is missing the label "${t.name}"`);
}
const actives = (bar.match(/class="active"/g) || []).length;
if (actives !== 1) fail(`nav('playlist') marked ${actives} links active, expected 1`);
if ((nav('').match(/class="active"/g) || []).length !== 0) fail(`nav('') marked a link active`);
if (!bar.startsWith('<header')) fail('nav() root is not <header> — print.html hides "header" on print');
console.log(` ${pages.length} pages, ${TOOLS.length} registered, ${GROUPS.length} groups`);
for (const g of GROUPS) {
console.log(` ${g.padEnd(12)} ${TOOLS.filter(t => t.group === g).map(t => t.name).join(', ')}`);
}
console.log(bad ? `\n${bad} problem(s)` : '\nall pages reachable');
process.exit(bad ? 1 : 0);