feat(ui): one consistent navbar on every page (TODO.md L3)

This commit is contained in:
2026-08-26 15:39:42 -04:00
parent 0a46f07fa8
commit b2d8696741
19 changed files with 2122 additions and 678 deletions
+50 -224
View File
@@ -7,9 +7,15 @@
*
* Phase 19: the page joins the shared header (assets/header.js) — the
* whoami gate below runs on the module's cached promise (one request per
* page, shared with the header toggling), and the header gains the New
* chat button: on a non-chat page "new chat" means going to the chat,
* fresh (clear the phase-14 conversation key, then navigate to "/").
* page, shared with the header toggling).
*
* Phase 34 task 02: the header's functional controls are module-owned
* (assets/header.js): the Sync sources state machine (#sync-btn's
* §7.4 lifecycle — the page only renders #sync-result +
* #sync-error-banner off the module's "bor:sync-status" event) and the
* New chat binding (on a non-chat page "new chat" means going to the
* chat, fresh — the module clears the phase-14 conversation key and
* navigates to "/").
*
* Phase 26: the table's path links open the document in the
* almost-fullscreen modal overlay (assets/document-modal.js) on the
@@ -21,7 +27,7 @@
* tag; esbuild inlines it into the page bundle).
*/
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
import { fetchIsAdmin, fmtSyncResult, initSharedHeader } from "./header.js";
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
const tbody = document.querySelector("#docs-tbody");
@@ -43,17 +49,6 @@ function isAdmin() {
return fetchIsAdmin();
}
/* Phase 19: New chat on a non-chat page means "go to the chat, fresh":
* clear the phase-14 conversation key, then land on the chat page — its
* empty state, since the conversation is gone from storage. */
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
clearChatStorage();
window.location.href = "/";
});
}
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
@@ -146,46 +141,33 @@ function showEmpty() {
if (tableWrap) tableWrap.hidden = true;
}
/* ---------- Phase 32: the admin "Sync sources" button (§7.4) ----------
* The "never stale" lifecycle for a long background job:
/* ---------- Phase 32 (state machine module-owned from phase 34
* task 02): the #sync-result line + #sync-error-banner ----------
*
* idle → click → POST /api/sync
* 202 → "Syncing…" (disabled, aria-busy, spinning icon) + a
* 2 s poll of GET /api/sync/status — the feedback loop;
* 409 adopts the in-flight run the same way (one poll
* loop at a time, never two);
* success → "Synced HH:MM" + last-result counts in #sync-result
* (aria-live — announced to screen readers) + the
* catalog re-fetches live (never a stale table);
* failed → "Sync sources" (retry-ready) + the role="alert"
* banner naming the error.
* The #sync-btn state machine itself (click → POST /api/sync, the 2 s
* GET /api/sync/status poll, the running / success / failed button
* states, the admin-only load re-attach) lives in the shared header
* module (assets/header.js) — so the SAME button markup on ANY page
* behaves identically. This page keeps only the page-specific
* rendering: the aria-live last-result line and the role="alert" error
* banner, driven by the module's "bor:sync-status" event (detail = the
* GET /api/sync/status object):
*
* NO client-side hard timeout (phase locked decision): a sync can
* legitimately run for minutes (clone + embed), so the 2 s poll is the
* feedback loop and the server state is authoritative — the button is
* disabled until the run reaches a terminal state, so it can never sit
* stale OR stuck. On load (admin only) the page re-attaches: a running
* run re-enters the running state (reload mid-sync), a terminal run
* renders its last result. A 403 anywhere hides the button (defense in
* depth — header.js's whoami reveal is the primary gate).
* running → clear the result line, hide the banner (a new run starts
* clean — the module emits the frame immediately on
* click/boot, no 2 s poll lag);
* success → the last-result counts in #sync-result (fmtSyncResult —
* "added" always shown, zero terms omitted) + the catalog
* re-fetches live (the KB just changed — never a stale
* table) + the banner hidden;
* failed → #sync-error-banner with the error text, result cleared;
* idle → hide the banner, clear the result (a run vanishing with
* a server restart, or the post-403 cleanup).
*/
const syncBtn = document.querySelector("#sync-btn");
const syncLabel = document.querySelector("#sync-label");
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
const syncResult = document.querySelector("#sync-result");
const syncErrorBanner = document.querySelector("#sync-error-banner");
const syncErrorText = document.querySelector("#sync-error-text");
const SYNC_POLL_MS = 2000; // the 2 s status poll (task 02)
let syncPollTimer = null; // at most ONE live poll loop
function stopSyncPolling() {
if (syncPollTimer !== null) {
clearTimeout(syncPollTimer);
syncPollTimer = null;
}
}
function showSyncError(detail) {
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
if (syncErrorBanner) syncErrorBanner.hidden = false;
@@ -196,185 +178,28 @@ function hideSyncError() {
if (syncErrorBanner) syncErrorBanner.hidden = true;
}
/* The local HH:MM of finished_at — 24-hour, locale-independent, so the
* "Synced 14:32" last-result label is deterministic. */
function fmtSyncTime(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/* The last-result line for #sync-result (aria-live). "added" is ALWAYS
* announced (the run's headline term); "updated" / "pruned" only when
* they happened (zero terms omitted); "unchanged" whenever it is
* non-zero — or whenever nothing was added or updated, so a no-op
* re-sync reads "0 added · 1 unchanged" instead of an empty live
* region (the story gate's idempotency check). */
function fmtSyncResult(detail) {
const d = detail || {};
const added = d.added || 0;
const updated = d.updated || 0;
const parts = [`${added} added`];
if (updated > 0) parts.push(`${updated} updated`);
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
parts.push(`${d.unchanged || 0} unchanged`);
}
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
return parts.join(" · ");
}
function enterRunningState() {
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
if (syncIcon) syncIcon.classList.add("is-spinning");
syncLabel.textContent = "Syncing…";
if (syncResult) syncResult.textContent = "";
hideSyncError();
}
/* Settle the button back to clickable + un-spun with the given label. */
function settleSyncButton(label) {
syncBtn.disabled = false;
syncBtn.removeAttribute("aria-busy");
if (syncIcon) syncIcon.classList.remove("is-spinning");
syncLabel.textContent = label;
}
function applySyncSuccess(status) {
const time = fmtSyncTime(status.finished_at);
settleSyncButton(time ? `Synced ${time}` : "Synced");
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
hideSyncError();
// The KB just changed — refresh the catalog live so the table, stats,
// and empty state never sit stale under the "Synced" label (the sync is
// the page's own action; a reload should not be needed to see it).
loadDocs();
}
function applySyncFailure(status) {
settleSyncButton("Sync sources"); // retry-ready
if (syncResult) syncResult.textContent = "";
showSyncError(status.error);
}
/* A run can only vanish with a server restart mid-sync (status resets
* to idle — the phase-accepted behavior): re-enable retry-ready with no
* banner (there is no error to name; the next click re-syncs).
* Idempotent — also the post-403 cleanup. */
function applySyncIdle() {
settleSyncButton("Sync sources");
if (syncResult) syncResult.textContent = "";
hideSyncError();
}
/* The 2 s poll loop — the ONLY feedback timer (no client-side hard
* timeout, phase locked decision). One tick at a time (re-scheduled
* only while the run is still live, so an in-flight fetch can never
* overlap the next tick), and startSyncPolling refuses to run a second
* loop (a 409 adoption or a reload never doubles the polling). */
function startSyncPolling() {
if (syncPollTimer !== null) return;
const tick = async () => {
let status = null;
let notAdmin = false;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) notAdmin = true;
else if (r.ok) status = await r.json();
} catch {
/* network blip — the next tick retries (no client timeout to trip) */
}
if (notAdmin) {
// Session lost mid-sync: defense in depth — hide the button.
stopSyncPolling();
syncBtn.hidden = true;
applySyncIdle();
return;
}
if (status && status.state === "success") {
stopSyncPolling();
applySyncSuccess(status);
return;
}
if (status && status.state === "failed") {
stopSyncPolling();
applySyncFailure(status);
return;
}
if (status && status.state === "idle") {
// The run died with a server restart — retry-ready, no banner.
stopSyncPolling();
applySyncIdle();
return;
}
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
};
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
}
/* Click → POST /api/sync. 202 starts the run; 409 adopts the in-flight
* one (started elsewhere — e.g. a second tab); 403 hides the button
* (defense in depth); anything else names the failure in the banner and
* leaves the button retry-ready (the never-stale contract). */
async function startSync() {
let r;
try {
r = await fetch("/api/sync", { method: "POST" });
} catch {
showSyncError("Could not reach the server to start the sync — try again.");
return;
}
if (r.status === 403) {
stopSyncPolling();
syncBtn.hidden = true;
applySyncIdle();
return;
}
if (r.status === 202 || r.status === 409) {
enterRunningState();
startSyncPolling();
return;
}
let detail = "";
try {
detail = (await r.json()).detail || "";
} catch {
/* non-JSON error body */
}
showSyncError(detail || `The server refused to start the sync (${r.status}).`);
}
/* Load-time re-attach (admin only — the IIFE runs this after the
* whoami gate): a running run re-enters the running state (the user may
* have reloaded mid-sync), a terminal run renders its last result, idle
* renders nothing. */
async function initSyncButton() {
if (!syncBtn) return;
let status;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) {
syncBtn.hidden = true; // defense in depth
return;
}
if (!r.ok) return;
status = await r.json();
} catch {
return; // network blip — the button stays idle and clickable
}
window.addEventListener("bor:sync-status", (e) => {
const status = e.detail || {};
if (status.state === "running") {
enterRunningState();
startSyncPolling();
if (syncResult) syncResult.textContent = "";
hideSyncError();
} else if (status.state === "success") {
applySyncSuccess(status);
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
hideSyncError();
// The KB just changed — refresh the catalog live so the table,
// stats, and empty state never sit stale under the "Synced" label
// (the sync is the page's own action; a reload should not be
// needed to see it).
loadDocs();
} else if (status.state === "failed") {
applySyncFailure(status);
if (syncResult) syncResult.textContent = "";
showSyncError(status.error);
} else {
// idle
if (syncResult) syncResult.textContent = "";
hideSyncError();
}
/* idle → nothing to render */
}
if (syncBtn) syncBtn.addEventListener("click", startSync);
});
(async () => {
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar
@@ -388,5 +213,6 @@ if (syncBtn) syncBtn.addEventListener("click", startSync);
}
if (gateEl) gateEl.hidden = true;
loadDocs();
initSyncButton(); // phase 32: re-attach to a running / last sync run
// Phase 34 task 02: the sync re-attach is module-owned (header.js
// boots it on the same cached whoami) — nothing to start here.
})();