From 7040d4f078525ebfcd3a138b0e78b37831ef0825 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 12 Oct 2026 13:18:59 +1100 Subject: [PATCH] Admin: tool registry, grouped nav, and an /admin/ hub --- .claude-context/00-overview.md | 1 + .claude-context/01-architecture.md | 23 +++ .../sessions/2026-10-12-admin-navigation.md | 105 ++++++++++++++ docs/PAGES.md | 1 + public/admin/admin.js | 132 +++++++++++++++++- public/admin/calibrate.html | 6 + public/admin/index.html | 90 ++++++++++++ public/admin/testsheet.html | 3 + tools/check-admin-nav.mjs | 58 ++++++++ 9 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 .claude-context/sessions/2026-10-12-admin-navigation.md create mode 100644 public/admin/index.html create mode 100644 tools/check-admin-nav.mjs diff --git a/.claude-context/00-overview.md b/.claude-context/00-overview.md index 5cb71c9..ac56803 100644 --- a/.claude-context/00-overview.md +++ b/.claude-context/00-overview.md @@ -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. diff --git a/.claude-context/01-architecture.md b/.claude-context/01-architecture.md index 412fd72..6ba6656 100644 --- a/.claude-context/01-architecture.md +++ b/.claude-context/01-architecture.md @@ -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 `
`: `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 diff --git a/.claude-context/sessions/2026-10-12-admin-navigation.md b/.claude-context/sessions/2026-10-12-admin-navigation.md new file mode 100644 index 0000000..01ae3b7 --- /dev/null +++ b/.claude-context/sessions/2026-10-12-admin-navigation.md @@ -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 `
`. + + *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 `
`.** `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. diff --git a/docs/PAGES.md b/docs/PAGES.md index d783a9f..2d1b53b 100644 --- a/docs/PAGES.md +++ b/docs/PAGES.md @@ -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 diff --git a/public/admin/admin.js b/public/admin/admin.js index 8c13533..02e4eb6 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -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
so the print rules in print.html and plan.html + keep hiding it. */ export function nav(active) { - return `
-

Newbury Exhibit — Admin

- ${['preview', 'layout', 'plan', 'playlist', 'characters', 'landing', 'print', 'calibrate', 'errors'].map(p => - `${p}`).join('')} - viewer ↗ + const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const groups = GROUPS.map(g => { + const links = TOOLS.filter(t => t.group === g).map(t => + `${esc(t.name)}` + ).join(''); + return links ? `${esc(g)}${links}` : ''; + }).join(''); + + return `
+ Newbury Exhibit Admin + + Viewer ↗
`; } + +/* 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; +} diff --git a/public/admin/calibrate.html b/public/admin/calibrate.html index 5e5bb7a..3957a1f 100644 --- a/public/admin/calibrate.html +++ b/public/admin/calibrate.html @@ -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); diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..7abd071 --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,90 @@ + + +Admin — Newbury Exhibit +
+ + diff --git a/public/admin/testsheet.html b/public/admin/testsheet.html index 47cb206..acfe8cb 100644 --- a/public/admin/testsheet.html +++ b/public/admin/testsheet.html @@ -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 @@
+ ← Admin diff --git a/tools/check-admin-nav.mjs b/tools/check-admin-nav.mjs new file mode 100644 index 0000000..b15358d --- /dev/null +++ b/tools/check-admin-nav.mjs @@ -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(' — 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);