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
+42 -107
View File
@@ -37,18 +37,20 @@
* collapsed Thinking block is restored with it; records without it (old
* sessions) restore exactly as before, so no version bump. A10 is
* untouched: the API stays stateless, nothing is stored server-side.
* "New chat" (#new-chat-btn) clears the key + the list back to the empty
* state.
* "New chat" (#new-chat-btn — bound by the shared header module,
* phase 34 task 02) clears the key + the list back to the empty state.
*
* Steering notes (phase 15) let the owner tune how Brain answers: a
* "Tune" button under every completed brain bubble (deflected included)
* opens an inline form → POST /api/steering → the note is stored in
* Postgres and injected into the system prompt of every subsequent turn
* (the <tuning> section). Notes are listed newest-first in the header
* "Tuning" panel (#steering-panel), where each can be deleted. Note text
* is always rendered with textContent (XSS-safe), save/delete are
* announced through a polite live region (#steering-announcer), and the
* panel + count badge update on every change.
* (the <tuning> section). The header "Tuning" panel (#steering-panel)
* — toggle, list, per-note delete, count badge, announcer — is owned by
* the shared header module (assets/header.js, phase 34); this file keeps
* only the chat-specific per-bubble Tune button + inline form, whose
* success path refreshes the panel (refreshSteering()) and announces
* (announceSteering()) through the module. Note text is always rendered
* with textContent (XSS-safe) in both places.
*
* Scroll (phase 18, owner choice 2026-08-23): the page auto-scrolls only
* while the user is pinned to the bottom. NEAR_BOTTOM_PX (200px) covers
@@ -72,7 +74,12 @@
* All DOM ids match frontend/index.html.
*/
import { fetchIsAdmin, initSharedHeader } from "./header.js";
import {
fetchIsAdmin,
initSharedHeader,
refreshSteering,
announceSteering,
} from "./header.js";
import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal
const messagesEl = document.querySelector("#messages");
@@ -164,30 +171,24 @@ export function documentUrl(source, path, back = "/") {
return url;
}
/* ---------- steering notes (phase 15) ----------
/* ---------- steering notes (phase 15; panel module-owned from phase 34) ----------
*
* The owner's tuning notes steer every future answer: they live in
* Postgres (stateless API, A10) and the chat turn reads them into the
* system prompt. UI contract: Tune button → inline form → save →
* confirmation (or inline error, form kept); the header panel lists the
* notes (newest first) with per-note delete.
* confirmation (or inline error, form kept). The header panel — toggle,
* list, per-note delete, count badge, announcer — is owned by the shared
* header module (assets/header.js, phase 34 task 01); this page keeps
* only the chat-specific part: the Tune button under every completed
* brain bubble and its inline form. Save success refreshes the panel
* through refreshSteering() and announces through announceSteering() —
* both imported from header.js.
*/
const steeringToggle = document.querySelector("#steering-toggle");
const steeringCount = document.querySelector("#steering-count");
const steeringPanel = document.querySelector("#steering-panel");
const steeringList = document.querySelector("#steering-list");
const steeringEmpty = document.querySelector("#steering-empty");
const steeringAnnouncer = document.querySelector("#steering-announcer");
const TUNE_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>';
let tuneSeq = 0; // unique ids for one open tune form's inputs
function announceSteering(message) {
if (steeringAnnouncer) steeringAnnouncer.textContent = message;
}
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
sources' .msg-meta row when it exists (role=list → the button joins as
a listitem so ARIA stays valid); otherwise creates a plain meta row.
@@ -279,7 +280,7 @@ function openTuneForm(wrap, toggleBtn) {
saved.textContent = "Saved — future answers will follow this.";
form.replaceWith(saved);
announceSteering("Tuning note saved. Future answers will follow it.");
await loadSteering(); // panel + count badge update
await refreshSteering(); // header.js: panel + count badge update
} catch {
status.textContent = "Could not save the note — is the app reachable?";
status.hidden = false;
@@ -294,76 +295,6 @@ function openTuneForm(wrap, toggleBtn) {
form.querySelector("textarea").focus();
}
/* Panel: newest-first list (textContent — XSS-safe), per-note delete,
empty text, and the header count badge. */
async function loadSteering() {
let notes = [];
try {
const r = await fetch("/api/steering");
if (r.ok) notes = (await r.json()).notes || [];
} catch { /* API unreachable: keep the last rendered list */ }
renderSteeringPanel(notes);
return notes;
}
function renderSteeringPanel(notes) {
if (!steeringList) return;
steeringList.textContent = "";
for (const n of notes) {
const li = document.createElement("li");
li.className = "steering-note";
const text = document.createElement("span");
text.className = "steering-note-text";
text.textContent = n.note; // rendered as text, never as HTML
li.appendChild(text);
const del = document.createElement("button");
del.type = "button";
del.className = "steering-delete";
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
del.innerHTML =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
li.appendChild(del);
steeringList.appendChild(li);
}
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
if (steeringCount) steeringCount.textContent = String(notes.length);
}
async function deleteSteeringNote(id, btn) {
btn.disabled = true;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.status === 404) {
announceSteering("That note was already removed.");
await loadSteering();
return;
}
if (!r.ok) {
announceSteering("Could not delete the note — try again.");
btn.disabled = false;
return;
}
await loadSteering();
announceSteering("Tuning note deleted.");
} catch {
announceSteering("Could not delete the note — is the app reachable?");
btn.disabled = false;
}
}
function setSteeringPanel(open) {
if (!steeringPanel || !steeringToggle) return;
steeringPanel.hidden = !open;
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
}
if (steeringToggle && steeringPanel) {
steeringToggle.addEventListener("click", () => {
setSteeringPanel(steeringPanel.hidden);
if (!steeringPanel.hidden) loadSteering(); // refresh when (re)opened
});
}
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
* Inline SVG as string constants so the message renderer and the typing
* indicator share exactly the same marks. currentColor lets the CSS theme
@@ -819,10 +750,13 @@ function rememberBrainTurn(rawText, meta) {
* reload) all moved to the shared header module (assets/header.js) —
* initSharedHeader() does the header toggling on every page, and
* fetchIsAdmin() is the single cached whoami, so this page still makes
* exactly one request per load. applyAuthState keeps only the
* chat-page-specific work (removing the tuning surface for anonymous
* visitors) — idempotent alongside the header module's own link/button
* toggling.
* exactly one request per load.
*
* Phase 34: removing the tuning surface for anonymous visitors (toggle
* + panel, "absent not hidden") also moved into initSharedHeader() —
* the module owns the controls, so the module gates them. applyAuthState
* keeps only the auth-pair toggling — idempotent alongside the header
* module's own link/button toggling.
*/
const signInLink = document.querySelector("#sign-in-link");
const signOutBtn = document.querySelector("#sign-out-btn");
@@ -831,13 +765,8 @@ let isAdmin = false;
function applyAuthState() {
if (signInLink) signInLink.hidden = isAdmin;
if (signOutBtn) signOutBtn.hidden = !isAdmin;
if (!isAdmin && steeringToggle) {
steeringToggle.remove();
steeringPanel?.remove();
}
}
const newChatBtn = document.querySelector("#new-chat-btn");
function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
conversation = [];
@@ -852,7 +781,11 @@ function startNewChat() {
input.focus();
sendStatus.textContent = "New chat started — previous conversation cleared.";
}
if (newChatBtn) newChatBtn.addEventListener("click", startNewChat);
/* Phase 34 task 02: the #new-chat-btn binding is module-owned
* (header.js, the SINGLE New chat binding) — on the chat page the
* module dispatches window "bor:new-chat" and this page acts through
* its own in-flight-turn guard + list reset. */
window.addEventListener("bor:new-chat", startNewChat);
function showErrorBanner(detail) {
banner.hidden = false;
@@ -1052,13 +985,15 @@ window.addEventListener("pagehide", () => {
Phase 14: the conversation then comes back exactly as left. Phase 19:
the shared header module runs the whoami (cached — exactly one
request per page load) and toggles the Sign in/out pair + the Sources
nav link; applyAuthState() then applies the chat-page-only gating. */
nav link; applyAuthState() then applies the chat-page-only gating.
Phase 34: the steering panel's admin boot refresh (count badge) and
the anonymous removal of the tuning surface both happen inside
initSharedHeader() now. */
(async () => {
await initSharedHeader(); // header.js: whoami + Sign in/out + #nav-sources
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
applyAuthState(); // chat page: the admin-only tuning surface
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
restoreConversation();
loadSuggestions();
loadHealth();
if (isAdmin) loadSteering(); // phase 15: panel + count badge (admin only)
})();
+18 -18
View File
@@ -29,9 +29,10 @@
*
* Phase 19: the viewer joins the shared header (assets/header.js) — the
* whoami fetch is the module's cached promise (one request per page,
* shared with initSharedHeader's toggling), and the bar 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 "/").
* shared with initSharedHeader's toggling). Phase 34 task 02: the New
* chat binding is module-owned (assets/header.js, the SINGLE one) — on
* this non-chat page it clears the phase-14 conversation key and
* navigates to the chat's empty state.
*
* Phase 26 (import safety): the modal module imports renderDocument
* from THIS file on the chat/sources pages, so everything
@@ -41,7 +42,7 @@
* fetch, no New Chat binding.
*/
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
import { fetchIsAdmin, initSharedHeader } from "./header.js";
function fmtDate(iso) {
try {
@@ -71,6 +72,9 @@ function metaBadge(cls, text) {
* document-derived string is a text node. */
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
titleEl.textContent = doc.title;
// Phase 34 task 04: the titlebar title ellipsizes — the full title
// stays reachable on hover via the `title` attribute.
titleEl.setAttribute("title", doc.title);
const pathCode = document.createElement("code");
pathCode.className = "doc-path";
@@ -149,16 +153,19 @@ if (document.querySelector("#doc-title")) {
function showNotFound() {
titleEl.textContent = "Document not found";
titleEl.removeAttribute("title"); // no stale full-title tooltip
document.title = "Document not found · Brain of Reese";
metaEl.replaceChildren();
contentEl.replaceChildren();
notFoundEl.hidden = false;
}
/* Phase 19: the shared header controls (Sign in / Sign out — exactly
* one visible) are toggled here; the viewer has no nav, so there is
* no #nav-sources for the module to touch. Independent of the doc
* fetch (its own IIFE — load() below never waits on it).
/* Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the
* viewer now carries the SAME standard bar as every other page —
* nav incl. the admin-only links, Tuning toggle, Sync, New chat, the
* auth pair — all toggled here on the module's cached whoami.
* Independent of the doc fetch (its own IIFE — load() below never
* waits on it).
* (fetchIsAdmin is imported for parity with the other header
* consumers — the module's cached promise is the single whoami per
* page either way.) */
@@ -166,16 +173,9 @@ if (document.querySelector("#doc-title")) {
await initSharedHeader();
})();
/* 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 = "/";
});
}
/* The New chat binding is module-owned (assets/header.js, phase 34
* task 02 — the SINGLE binding): on this non-chat page it clears the
* phase-14 conversation key and navigates to the chat's empty state. */
async function load() {
try {
+503 -20
View File
@@ -6,28 +6,60 @@
*
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
* decided by /api/whoami at load);
* • the admin-only nav links — "Sources" (#nav-sources) and "Tuning"
* (#nav-tuning, on the Global Tuning page, phase 27) — phase 19 UX
* revision (owner permission 2026-08-23): hidden for anonymous on
* every page that has a nav (chat, sources, tuning, login),
* revealed for admin. The links SHIP hidden in the HTML
* • the admin-only nav links — "Sources" (#nav-sources, phase 19)
* and "Tuning" (#nav-tuning, phase 29) — phase 19 UX revision
* (owner permission 2026-08-23): hidden for anonymous on EVERY
* page, revealed for admin. Phase 34 task 03 (owner confirmation
* 2026-08-26): the SAME nav ships on all five pages (chat,
* sources, document viewer, tuning, login) — the viewer's
* "no nav" bar is gone. The links SHIP hidden in the HTML
* (anonymous-safe default — the phase-16 "absent, not hidden"
* spirit), so no anonymous user ever sees one for a frame; and the
* Sources page's "Sync sources" button (#sync-btn, phase 32) —
* the same ship-hidden / reveal-for-admin contract on the SAME
* cached whoami (one fetch, no extra request);
* spirit), so no anonymous user ever sees one for a frame; and
* the "Sync sources" button (#sync-btn, phase 32 — every page
* from phase 34 task 03) — the same ship-hidden / reveal-for-admin
* contract on the SAME cached whoami (one fetch, no extra request);
* • the sign-out click binding (POST /api/logout → reload) — moved
* here from app.js so there is exactly one implementation;
* • the steering-notes controls (phase 15, moved here from app.js in
* phase 34) — the #steering-toggle open/close + the #steering-panel
* list (newest-first, textContent-rendered, per-note delete, count
* badge, the #steering-announcer live region) — so the toggle can
* sit in every page's header with zero page-script duplication.
* refreshSteering() / announceSteering() are exported for the chat
* page's per-bubble Tune form (which stays in app.js); anonymous
* visitors get the phase-16 "absent, not hidden" treatment (toggle
* + panel removed from the DOM, /api/steering never fetched);
* • the Sync sources state machine (phase 32, moved here from
* sources.js in phase 34 task 02) — the §7.4 never-stale lifecycle
* for #sync-btn (idle → running → success | failed): admin-only
* boot re-attach on the SAME cached whoami (non-admins never poll),
* POST /api/sync (202 start / 409 adopt), the 2 s
* GET /api/sync/status poll (one live timer, NO client-side hard
* timeout — the server state is authoritative). Every state change
* dispatches window "bor:sync-status" (detail = the status object)
* so the Sources page renders its #sync-result line +
* #sync-error-banner off the event; on non-Sources pages the
* failed state is visible in the button's title + aria-label;
* • the SINGLE New chat binding (phase 34 task 02 — it was
* duplicated across app.js / sources.js / tuning.js / document.js):
* on the chat page (#messages exists) the module dispatches
* window "bor:new-chat" and app.js acts (it owns the in-flight-turn
* guard + the list reset); on every other page it means "go to the
* chat, fresh" — clearChatStorage() + navigate to "/";
* • the sign-in ?next= rewrite (phase 34 task 02) — initSharedHeader
* points #sign-in-link at /login.html?next=<current pathname>
* (default "/"), so the admin lands back on the page they signed in
* from; the page markup keeps its own href as the no-JS fallback;
* • clearChatStorage() — the phase-14 conversation key, for the
* New Chat buttons on the NON-CHAT pages (sources / document
* viewer / tuning): a new chat means going to the chat, fresh.
* New chat action on the NON-CHAT pages (sources / document viewer /
* tuning / login): a new chat means going to the chat, fresh.
*
* Every page loads this module (type="module", before its page script)
* and its page script calls initSharedHeader() once at boot. init…
* toggles ONLY the controls that exist on the page — a missing element
* is a no-op, which is how the login page reuses the module without
* gaining chat controls (no #new-chat-btn / #sign-in-link /
* #sign-out-btn in its markup → none appear).
* is a no-op. Phase 34 task 03 ships the SAME full header block on all
* five pages (the login page included), so every control resolves on
* every page; a page that lacks one simply skips it.
*
* whoami is fetched at most ONCE per page load: the promise is cached in
* the module-level `adminPromise`, so app.js's tuning gate, the sources
@@ -61,20 +93,42 @@ export function fetchIsAdmin() {
cached promise makes both awaits the same single request). */
export async function initSharedHeader() {
const admin = await fetchIsAdmin();
// The Sign in link: hidden for the admin, visible otherwise — and its
// href is rewritten to return the admin to THIS page after login
// (phase 34 task 02: "return to where you were"). The markup keeps its
// own static ?next= as the no-JS fallback. location.pathname is always
// a query-safe "/…" string (never "//"; ? # and spaces stay
// percent-encoded in it), so it rides in next= as-is — the same shape
// the static fallbacks use (login.js safeNext re-validates it).
const signIn = document.querySelector("#sign-in-link");
if (signIn) signIn.hidden = admin;
if (signIn) {
signIn.hidden = admin;
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
}
const signOut = document.querySelector("#sign-out-btn");
if (signOut) signOut.hidden = !admin;
const navSources = document.querySelector("#nav-sources");
if (navSources) navSources.hidden = !admin;
// Phase 27: the Global Tuning page's own nav link — admin-only, the
// same ship-hidden / reveal-for-admin contract as the Sources link.
// Phase 29: the Global Tuning nav link (every page from phase 34
// task 03) — admin-only, the same ship-hidden / reveal-for-admin
// contract as the Sources link.
const navTuning = document.querySelector("#nav-tuning");
if (navTuning) navTuning.hidden = !admin;
// Phase 32: the Sources page's "Sync sources" button — admin-only,
// revealed on this same cached whoami (anonymous users never see it).
const syncBtn = document.querySelector("#sync-btn");
// Phase 32: the "Sync sources" button — admin-only, revealed on this
// same cached whoami (anonymous users never see it).
if (syncBtn) syncBtn.hidden = !admin;
// Phase 34: the steering controls (phase 15) are module-owned. Admin:
// refresh the list so the count badge is right before the panel is
// ever opened (fire-and-forget, as the chat page did before the move).
// Anonymous: the toggle + panel are REMOVED from the DOM entirely —
// the phase-16 contract says "absent", not just hidden — and
// /api/steering is never fetched.
if (admin) {
if (steeringPanel) refreshSteering();
} else {
steeringToggle?.remove();
steeringPanel?.remove();
}
return admin;
}
@@ -106,3 +160,432 @@ if (signOutBtn) {
window.location.reload();
});
}
/* ---------- steering notes (phase 15; module-owned from phase 34) ----------
*
* The owner's tuning notes steer every future answer: they live in
* Postgres (stateless API, A10) and the chat turn reads them into the
* system prompt. The header panel — toggle, list, per-note delete, count
* badge, announcer — is owned by THIS module: every page that ships the
* panel markup gets exactly this behavior, with zero page-script
* duplication. The chat page keeps only its per-bubble Tune form
* (app.js), which refreshes the panel through refreshSteering() and
* announces through announceSteering().
*
* All elements are looked up null-safe (querySelector + guard): a page
* that lacks the panel markup is a no-op — the same contract as
* initSharedHeader().
*/
const steeringToggle = document.querySelector("#steering-toggle");
const steeringCount = document.querySelector("#steering-count");
const steeringPanel = document.querySelector("#steering-panel");
const steeringList = document.querySelector("#steering-list");
const steeringEmpty = document.querySelector("#steering-empty");
const steeringAnnouncer = document.querySelector("#steering-announcer");
/* Announce a steering change through the polite live region
(role="status", aria-live="polite") — exported so the chat page's
per-bubble Tune form (app.js) announces on the exact same channel. */
export function announceSteering(message) {
if (steeringAnnouncer) steeringAnnouncer.textContent = message;
}
/* Fetch + render the note list (exported — the chat page's per-bubble
Tune form calls it on save, so the panel + count badge update without
owning the fetch itself). Non-2xx (the anonymous 403) or an
unreachable API render the empty state: count badge 0, the "no notes
yet" text visible — the safe fallback in either case. */
export async function refreshSteering() {
let notes = [];
try {
const r = await fetch("/api/steering");
if (r.ok) notes = (await r.json()).notes || [];
} catch {
/* API unreachable: the empty list state is the safe fallback */
}
renderSteeringPanel(notes);
return notes;
}
/* Newest-first list — the note is ALWAYS rendered with textContent
(XSS-safe, never innerHTML), a per-note Remove button with a labeled
aria-label, the empty text toggled on notes.length, and the header
count badge. */
function renderSteeringPanel(notes) {
if (!steeringList) return;
steeringList.textContent = "";
for (const n of notes) {
const li = document.createElement("li");
li.className = "steering-note";
const text = document.createElement("span");
text.className = "steering-note-text";
text.textContent = n.note; // rendered as text, never as HTML
li.appendChild(text);
const del = document.createElement("button");
del.type = "button";
del.className = "steering-delete";
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
del.innerHTML =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
li.appendChild(del);
steeringList.appendChild(li);
}
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
if (steeringCount) steeringCount.textContent = String(notes.length);
}
/* Per-note delete: disable the row button (no double-fire), DELETE
/api/steering/{id}, re-load the list, announce through
#steering-announcer. A 404 means the note was already gone — say so
and still refresh; any other failure re-enables the button so the
user can retry. */
async function deleteSteeringNote(id, btn) {
btn.disabled = true;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.status === 404) {
announceSteering("That note was already removed.");
await refreshSteering();
return;
}
if (!r.ok) {
announceSteering("Could not delete the note — try again.");
btn.disabled = false;
return;
}
await refreshSteering();
announceSteering("Tuning note deleted.");
} catch {
announceSteering("Could not delete the note — is the app reachable?");
btn.disabled = false;
}
}
/* Open/close the panel, keeping the toggle's aria-expanded in sync —
the exact phase-15 chat-page contract (open on click, close on click;
the panel itself is a plain region — no Esc / outside-click close in
the original, so none here). Re-opening refreshes the list, so notes
changed elsewhere (the Tuning page, another tab) show up. */
function setSteeringPanel(open) {
if (!steeringPanel || !steeringToggle) return;
steeringPanel.hidden = !open;
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
}
/* Toggle binding (module-owned, like the sign-out binding): runs at
module import, so a page with the toggle markup gets exactly one
implementation. */
if (steeringToggle && steeringPanel) {
steeringToggle.addEventListener("click", () => {
setSteeringPanel(steeringPanel.hidden);
if (!steeringPanel.hidden) refreshSteering(); // refresh when (re)opened
});
}
/* ---------- New chat (the SINGLE binding — module-owned from phase 34
* task 02) ----------
*
* The binding used to be duplicated across app.js / sources.js /
* tuning.js / document.js with the same page-kind branch. It lives here
* exactly once (module import, like the sign-out binding): on the chat
* page (#messages exists) the module dispatches window "bor:new-chat"
* and app.js acts — the chat script owns the in-flight-turn guard and
* the rendered-list reset; on every other page "new chat" means go to
* the chat, fresh: clear the phase-14 conversation key, then navigate
* to "/" (its empty state, since the conversation is gone from storage).
*/
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
if (document.querySelector("#messages")) {
window.dispatchEvent(new CustomEvent("bor:new-chat"));
return;
}
clearChatStorage();
window.location.href = "/";
});
}
/* ---------- sync sources (phase 32; module-owned from phase 34 task 02) ----------
*
* The "never stale" lifecycle for the long background sync job, moved
* here from sources.js so the SAME #sync-btn markup on ANY page (phase
* 34 task 03) behaves identically. The button is the module's; the
* Sources page's #sync-result line + #sync-error-banner render off the
* "bor:sync-status" event this machine dispatches (sources.js
* subscribes):
*
* idle → click → POST /api/sync
* 202 → running (disabled, aria-busy, spinning icon, "Syncing…")
* + a 2 s poll of GET /api/sync/status;
* 409 → the in-flight run is ADOPTED the same way (one sync
* at a time, one poll loop at a time);
* success → "Synced HH:MM"; failed → retry-ready "Sync sources"
* + the sanitized error in the button's title +
* aria-label (on non-Sources pages that IS where the
* failure is visible; the Sources banner is the event).
*
* Boot (admin only — non-admins never poll, the status endpoint is
* admin-only): one GET /api/sync/status on the SAME cached whoami —
* running re-enters the running state (reload mid-sync), a terminal
* state renders its last result. NO client-side hard timeout (phase 32
* locked decision): a sync can legitimately outlive the page, so the
* 2 s poll is the feedback loop and the server state is authoritative.
*
* All elements are looked up null-safe: a page that doesn't (yet) carry
* the #sync-btn markup is a complete no-op, exactly like the rest of
* this module.
*/
const syncBtn = document.querySelector("#sync-btn");
const syncLabel = document.querySelector("#sync-label");
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
const SYNC_POLL_MS = 2000; // the 2 s status poll (phase 32 contract)
let syncPollTimer = null; // at most ONE live poll loop
let lastSyncState = null; // the last state emitted on bor:sync-status
/* The module → page channel: detail is the GET /api/sync/status object
(or the synthetic { state: "running" } frame the click path emits
before the first poll tick — the Sources handlers only need the
state, the next real object carries the full fields). */
function emitSyncStatus(status) {
lastSyncState = status.state;
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
}
function stopSyncPolling() {
if (syncPollTimer !== null) {
clearTimeout(syncPollTimer);
syncPollTimer = null;
}
}
/* 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 the Sources page's #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). Exported so the
* Sources page renders the counts from ONE implementation. */
export 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(" · ");
}
/* The failed-state affordance text for the button's title + aria-label
* (non-Sources pages: that is where the failure is visible). The server
* already masks any embedded credentials (sync.py _sanitize_error);
* here the string is collapsed to a single line and capped so a chatty
* git stderr can't bloat the attributes. */
function sanitizeSyncError(message) {
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
/* §7.4 running state: disabled + aria-busy + spinning icon + the
* "Syncing…" label — and a fresh run starts clean: the previous
* failure's affordances (title / aria-label / .is-error) come off NOW,
* not when the run settles. The button only — the Sources page's
* result line / banner clear off the matching "running" event (no
* 2 s lag). */
function enterSyncRunningState() {
if (!syncBtn) return;
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
syncBtn.removeAttribute("title");
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.add("is-spinning");
if (syncLabel) syncLabel.textContent = "Syncing…";
}
/* Settle the button back to clickable + un-spun with the given label,
* dropping the failed-state affordances (a fresh run starts clean). */
function settleSyncButton(label) {
if (!syncBtn) return;
syncBtn.disabled = false;
syncBtn.removeAttribute("aria-busy");
syncBtn.removeAttribute("title");
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.remove("is-spinning");
if (syncLabel) syncLabel.textContent = label;
}
function applySyncSuccess(status) {
const time = fmtSyncTime(status.finished_at);
settleSyncButton(time ? `Synced ${time}` : "Synced");
emitSyncStatus(status);
}
function applySyncFailure(status) {
const error = sanitizeSyncError(status.error);
settleSyncButton("Sync sources"); // retry-ready
if (syncBtn) {
// The failed look: error text in title + aria-label (and the
// .is-error class for the non-Sources pages' visible error state).
syncBtn.title = error;
syncBtn.setAttribute("aria-label", error);
syncBtn.classList.add("is-error");
}
emitSyncStatus(status);
}
/* A run can only vanish with a server restart mid-sync (status resets
* to idle — the phase-accepted behavior): retry-ready, no error to
* name. Also the post-403 cleanup. */
function applySyncIdle(status) {
settleSyncButton("Sync sources");
emitSyncStatus(status || { state: "idle" });
}
/* The 2 s poll loop — the ONLY feedback timer (no client-side hard
* timeout, phase 32 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();
if (syncBtn) syncBtn.hidden = true;
applySyncIdle();
return;
}
if (!status) {
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
return;
}
if (status.state === "success") {
stopSyncPolling();
applySyncSuccess(status);
return;
}
if (status.state === "failed") {
stopSyncPolling();
applySyncFailure(status);
return;
}
if (status.state === "idle") {
// The run died with a server restart — retry-ready, no banner.
stopSyncPolling();
applySyncIdle(status);
return;
}
// Still running: keep the button state honest (idempotent) and
// re-schedule. No event — the running frame was already emitted
// when the state entered (click / boot), and the Sources handlers
// are no-ops for repeated running frames anyway.
enterSyncRunningState();
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 (banner on
* Sources via the event, button affordance everywhere). */
async function startSync() {
let r;
try {
r = await fetch("/api/sync", { method: "POST" });
} catch {
applySyncFailure({
state: "failed",
error: "Could not reach the server to start the sync — try again.",
});
return;
}
if (r.status === 403) {
stopSyncPolling();
if (syncBtn) syncBtn.hidden = true;
applySyncIdle();
return;
}
if (r.status === 202 || r.status === 409) {
enterSyncRunningState();
// The synthetic running frame clears the Sources result line /
// banner IMMEDIATELY (before the first poll tick, 2 s away) — the
// exact sources.js enterRunningState behavior, now event-driven.
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
startSyncPolling();
return;
}
let detail = "";
try {
detail = (await r.json()).detail || "";
} catch {
/* non-JSON error body */
}
applySyncFailure({
state: "failed",
error: detail || `The server refused to start the sync (${r.status}).`,
});
}
/* Load-time re-attach (ADMIN ONLY — non-admins never poll, the status
* endpoint is admin-only): a running run re-enters the running state
* (the user may have reloaded mid-sync), a terminal run renders its
* last result, idle settles nothing visible. Awaits the SAME cached
* whoami promise — exactly one /api/whoami per page load, unchanged. */
async function initSyncButton() {
if (!syncBtn) return;
if (!(await fetchIsAdmin())) return; // anonymous: the button stays hidden
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
}
if (status.state === "running") {
enterSyncRunningState();
emitSyncStatus(status);
startSyncPolling();
} else if (status.state === "success") {
applySyncSuccess(status);
} else if (status.state === "failed") {
applySyncFailure(status);
} else {
applySyncIdle(status); // idle: settle + the idle frame
}
}
if (syncBtn) {
syncBtn.addEventListener("click", startSync);
initSyncButton(); // re-attach to a running / last sync run (admin only)
}
+17 -11
View File
@@ -7,11 +7,12 @@
* role=alert error region. On load, /api/whoami already says admin →
* straight to `next`, no form.
*
* Phase 19: the whoami check runs on the shared header module's cached
* promise (assets/header.js) — one request per page, and the module's
* initSharedHeader() toggles the (admin-only) Sources nav link. The
* login page carries no chat controls, so the module's missing-element
* no-op keeps this page control-free.
* Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the
* whoami check runs on the shared header module's cached promise
* (assets/header.js) — one request per page, and the module's
* initSharedHeader() settles the login page's FULL shared header —
* the SAME bar as every other page (nav incl. the admin-only links,
* Tuning toggle, Sync, New chat, the auth pair).
*
* No CDN, no state in this file: the signed cookie is the whole session.
* All DOM ids match frontend/login.html.
@@ -76,15 +77,20 @@ form.addEventListener("submit", async (e) => {
}
});
/* Already the admin? Skip the form and go straight to the target.
* (For anonymous visitors, initSharedHeader toggles the Sources nav
* link — the only shared control this page carries; for the signed-in
* case the redirect above makes the toggle moot.) */
/* The bar settles in BOTH branches (phase 34 task 05 — the login page
* carries the FULL shared header, so a signed-in admin who lands here
* gets the settled admin bar for the frame before the redirect, not
* the ship-hidden state): the whoami promise is already settled by
* this point, so initSharedHeader adds no request and no delay, and
* the phase-16 redirect itself is unchanged. For anonymous visitors it
* settles the reduced bar: Sign in visible, the admin-only controls
* stay hidden, the steering toggle + panel removed. */
(async () => {
if (await alreadySignedIn()) {
const admin = await alreadySignedIn();
await initSharedHeader();
if (admin) {
window.location.replace(safeNext());
return;
}
await initSharedHeader(); // phase 19: Sources link toggle (no chat controls here)
passwordInput.focus();
})();
+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.
})();
+109 -40
View File
@@ -203,7 +203,10 @@ html::after {
flex-shrink: 0;
}
/* 2px brand→cyan gradient hairline under the sticky header (phase 08;
shared by the app header and the document-viewer header, phase 10). */
shared by the app header and the document-viewer header, phase 10).
In the viewer's two-row header (phase 34) this lands at the BOTTOM
edge of the whole header — row 1's own copy is suppressed there (see
the .doc-header rules below). */
.app-header::after,
.doc-header::after {
content: "";
@@ -235,12 +238,23 @@ html::after {
font-size: 1.125rem;
color: var(--ink);
text-decoration: none;
/* Phase 34 task 04 (visual pass): the bar must fit the FULL admin
control set at every width — the wordmark is the designated
squeeze target (min-width:0 lets flex shrink it; overflow:hidden
clips it clean instead of letting it overlap the nav). */
min-width: 0;
overflow: hidden;
}
/* Mono wordmark with letter-spacing — the "technical" touch (phase 08). */
/* Mono wordmark with letter-spacing — the "technical" touch (phase 08);
ellipsizes as the squeeze target (phase 34 task 04). */
.brand-text {
font-family: var(--mono);
font-size: 0.95rem;
letter-spacing: 0.08em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-mark { width: 22px; height: 22px; flex: 0 0 auto; display: block; }
.brand-text strong { color: var(--brand-ink); font-weight: 700; }
@@ -339,6 +353,18 @@ html::after {
}
.sync-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
.sync-btn:disabled { opacity: 0.6; cursor: wait; }
/* Phase 34: the failed-sync look — the button now lives on EVERY page
(the non-Sources pages have no error banner, so the button itself is
the visible failure state; the sanitized error text rides in title /
aria-label, set by the header module). Phase-08 error pair: --err-ink
on --err-bg ≈9.1:1, --err-line border (the amber --accent-line is
deflection-only — never on errors). */
.sync-btn.is-error {
background: var(--err-bg);
color: var(--err-ink);
border-color: var(--err-line);
}
.sync-btn.is-error:hover { background: var(--err-bg); color: var(--err-ink); }
.sync-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; }
/* Running state: the refresh icon spins (reuses the shared spin
keyframes) — the visible half of "Syncing…" while the 2 s poll waits. */
@@ -755,6 +781,16 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.steering-delete:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.steering-delete:disabled { opacity: 0.5; cursor: wait; }
.steering-empty { margin: 0.65rem 0 0; color: var(--ink-soft); font-size: 0.88rem; }
/* Phase 34 task 04 (visual pass): on the non-chat pages the panel is a
direct child of <main> (not inside a .container) — pin it to the same
72rem content column as everything else so it never renders as a
full-viewport band. The chat page's panel sits inside .chat-shell's
container, so this selector only hits the direct-child placement. */
.app-main > .steering-panel {
width: calc(100% - 2.5rem);
max-width: 69.5rem;
margin-inline: auto;
}
/* ---------- Global tuning page (phase 27) ---------- */
/* /tuning.html: create / edit / delete steering notes without a chat
@@ -1239,26 +1275,30 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.docs-table tbody tr:hover { background: var(--bg); }
.docs-table tbody tr:last-child td { border-bottom: 0; }
/* ---------- Document viewer (phase 10) ---------- */
/* Phase 12: the same fixed-height bar as .app-header (--header-h, 64px /
58px mobile) — the header must never change size between chat,
sources, and the document viewer (owner report 2026-08-22). */
/* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
/* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
rows in one sticky <header> — row 1 reuses the standard .app-header /
.header-inner rules VERBATIM (row 1 IS the standard bar, so the
phase-12/19 pinned heights 64px / 58px apply), row 2 is the
.doc-titlebar (back link + title + meta). The header element itself
is content-sized (row 1 + row 2) and sticky, so BOTH rows stay
pinned while the document scrolls. */
.doc-header {
position: sticky;
top: 0;
z-index: 20;
background: var(--surface);
height: var(--header-h);
/* Phase 12: same guard as .app-header — the bar never shrinks. */
/* Phase 34: no fixed height — the header sizes to row 1 (--header-h)
+ the .doc-titlebar; the phase-12 shrink guard stays. */
flex-shrink: 0;
}
.doc-header-inner {
height: 100%;
display: flex;
align-items: center;
gap: 0.9rem;
}
/* Back link: pill with an SVG arrow + "Sources" (>=44px touch target). */
/* Row 1's own gradient hairline would land BETWEEN the rows; the
separator there is the quiet --line border-top on the titlebar, and
the signature hairline belongs at the bottom edge of the whole
header (the .doc-header::after in the shared rule above). */
.doc-header > .app-header::after { content: none; }
/* Back link: pill with an SVG arrow + "Sources" (>=44px touch target;
:focus-visible via the global 3px outline rule, phase 08). */
.doc-back {
display: inline-flex;
align-items: center;
@@ -1276,31 +1316,37 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
.doc-back:hover { background: #2a345f; }
.doc-back svg { width: 16px; height: 16px; display: block; }
.doc-title-block { min-width: 0; }
/* Phase 19: the shared header controls reach the viewer bar (New chat
+ Sign in / Sign out) — margin-left:auto pushes them to the right;
the title block keeps clipping (min-width: 0 above) so the two pills
fit while the bar still measures exactly --header-h. The reused
.new-chat-btn / .auth-link classes already carry the ≤640px icon-only
rules, so at 360px the bar is back pill + clipping title + two icon
pills (no overflow — test_responsive_polish pins scrollWidth). */
.doc-header-actions {
margin-left: auto;
display: flex;
gap: 0.5rem;
align-items: center;
/* Row 2: the titlebar — a .container-width row with the back link +
the title block, its own content-sized height (title line + meta
line), the same surface as row 1, separated from it by the quiet
hairline (var(--line) — the existing 1px border color). */
.doc-titlebar {
border-top: 1px solid var(--line);
padding-block: 0.6rem;
}
.doc-titlebar .container {
display: flex;
align-items: center;
gap: 0.9rem;
}
/* The title block keeps clipping (min-width: 0) so #doc-title /
.doc-meta ellipsize inside the flex row instead of overflowing. */
.doc-title-block { min-width: 0; }
#doc-title {
margin: 0;
font-size: 1.3rem;
line-height: 1.3;
/* Phase 34: ellipsis + the `title` attribute (set by document.js) —
the row has no pills to fit, so the old clipping-for-pills rule is
gone; the ellipsis is the only fit the title needs. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Meta row: source badge · format badge · mono path · indexed · chunks.
Phase 12: it may clip, but it must NEVER wrap — a wrapped meta row
would grow the header past the shared --header-h bar. */
Phase 12/34: it may clip, but it must NEVER wrap — the meta stays on
one line in the titlebar row (the row is content-sized, so a wrap
would grow it). */
.doc-meta {
display: flex;
flex-wrap: nowrap;
@@ -1606,6 +1652,20 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-size: 0.85rem;
}
/* ---------- Responsive (tablet squeeze — phase 34 task 04 visual pass) ----------
The full admin bar (brand + nav [Chat, Sources, Tuning] + Tuning
toggle + Sync sources + New chat + Sign out) outgrows a 768px bar in
the desktop styles. Squeeze the pills/gaps moderately — the 44px
touch floor is held by min-height and the 64px height by --header-h
— and let the brand wordmark (base ellipsis) absorb any remainder.
The ≤640 block below stays tighter and wins at phone widths. */
@media (max-width: 900px) {
.header-inner { gap: 0.65rem; }
.nav-link { padding: 0.4rem 0.6rem; font-size: 0.9rem; }
.app-nav { gap: 0.2rem; }
.new-chat-btn, .auth-link, .sync-btn, .steering-toggle { padding: 0.45rem 0.6rem; }
}
/* ---------- Responsive (mobile-first adjustments) ---------- */
@media (max-width: 640px) {
:root { --header-h: 58px; }
@@ -1619,9 +1679,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
the nav pills + gaps instead: tighter pill padding/font and a
tighter inner gap. The 44px touch floor is held by min-height, the
58px bar height is pinned by --header-h, and the bar now fits both
auth states at 375px (and 360px) without horizontal overflow. */
.header-inner { gap: 0.5rem; }
.brand { min-width: 0; }
auth states at 375px (and 360px) without horizontal overflow.
Phase 34 task 04 (visual pass): the full admin bar — nav [Chat,
Sources, Tuning] + Tuning toggle + Sync + New chat + Sign out —
now ships on EVERY page, so the squeeze tightens once more
(0.3rem inner gap, 0.78rem nav pills, 0.35rem pill padding,
18px brand mark) to hold 375px with the brand mark intact and
360px with the brand clipped clean (overflow:hidden — the mark
never overlaps the nav). */
.header-inner { gap: 0.3rem; }
.brand { min-width: 0; overflow: hidden; }
.brand-mark { width: 18px; height: 18px; }
.brand-text {
font-size: 0.8rem;
letter-spacing: 0.04em;
@@ -1630,20 +1698,20 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-link { padding: 0.4rem 0.4rem; font-size: 0.84rem; }
.app-nav { gap: 0.15rem; }
.new-chat-btn { padding: 0.4rem 0.55rem; }
.nav-link { padding: 0.35rem 0.3rem; font-size: 0.78rem; }
.app-nav { gap: 0.1rem; }
.new-chat-btn { padding: 0.4rem 0.35rem; }
.new-chat-label { display: none; }
.new-chat-btn svg { display: block; }
/* Phase 16: the auth pill goes icon-only like New chat — brand text
ellipsizes as the designated squeeze target, no bar overflow. */
.auth-link { padding: 0.4rem 0.55rem; }
.auth-link { padding: 0.4rem 0.35rem; }
.auth-label { display: none; }
.auth-link svg { display: block; }
/* Phase 32: the sync pill goes icon-only like the other pills (the
aria-label keeps the accessible name); the spinning icon is the
visible running state on a touch screen. */
.sync-btn { padding: 0.4rem 0.55rem; }
.sync-btn { padding: 0.4rem 0.35rem; }
.sync-label { display: none; }
/* The last-result counts stay ANNOUNCED (aria-live is untouched) but
go visually hidden — the 58px bar has no room for the text; the
@@ -1657,7 +1725,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
white-space: nowrap;
border: 0;
}
.steering-toggle { padding: 0.4rem 0.55rem; }
.steering-toggle { padding: 0.4rem 0.35rem; }
/* Visually hidden, NOT display:none — the accessible name keeps the
word "Tuning" next to the count badge. */
.steering-label {
@@ -1670,6 +1738,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
border: 0;
}
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
.tune-btn { min-height: 44px; }
/* Phase 27: the tuning page squeezes like the other cards — the row
padding tightens; the icon+label actions keep their 44px floor and
@@ -1682,7 +1751,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.suggestions { flex-wrap: nowrap; overflow-x: auto; justify-content: flex-start;
padding-bottom: 0.4rem; -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
.suggestion-chip { flex: 0 0 auto; }
.doc-header-inner { flex-wrap: nowrap; gap: 0.5rem; } /* phase 12: fixed-height bar — no wrap, no extra padding */
.doc-titlebar .container { gap: 0.5rem; } /* phase 34: row 2 squeezes like row 1 at mobile widths */
#doc-title { font-size: 1.1rem; }
.doc-path { max-width: 16rem; }
.doc-md { padding: 1.1rem 1rem; }
+12 -20
View File
@@ -29,10 +29,10 @@
* the admin-only Sources link, and this page's own admin-only
* "Tuning" nav link (#nav-tuning), all decided by the module's
* cached whoami promise (exactly one /api/whoami request per
* page); plus the non-chat New chat binding — "new chat" means
* going to the chat, fresh (clear the phase-14 conversation key,
* then navigate to "/"), the same contract as sources.js /
* document.js.
* page). Phase 34 task 02: the New chat binding is module-owned
* (assets/header.js, the SINGLE one) — on a non-chat page "new
* chat" means going to the chat, fresh (the module clears the
* phase-14 conversation key and navigates to "/").
*
* Anonymous-safe (task 03): the header already hides the "Tuning" nav
* link for anonymous visitors; a DIRECT anonymous URL still gets a safe
@@ -48,7 +48,7 @@
* inlines it into the page bundle in the image build).
*/
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
import { fetchIsAdmin, initSharedHeader } from "./header.js";
/* ---------- page elements (tuning.html, task 02) ---------- */
const tuneForm = document.querySelector("#tune-form");
@@ -324,22 +324,14 @@ async function deleteNote(id, btn, li) {
}
}
/* ---------- non-chat New chat + header boot (task 02) ---------- */
/* ---------- header boot (task 02) ---------- */
/* Phase 19 contract: 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 (the same contract as sources.js / document.js). */
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
clearChatStorage();
window.location.href = "/";
});
}
/* Boot: the shared header FIRST (Sign in/out + the admin-only nav
links — one cached whoami), then the note list — admin data only
/* The New chat binding is module-owned (assets/header.js, phase 34
* task 02 — the SINGLE binding): on this non-chat page it clears the
* phase-14 conversation key and navigates to the chat's empty state.
*
* Boot: the shared header FIRST (Sign in/out + the admin-only nav
* links — one cached whoami), then the note list — admin data only
(the Sources page gate pattern): an anonymous visitor gets the page
frame with the empty state, and the create form 403s gracefully on
submit if one tries. */