diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 00b87f1..94adfc0 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -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 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 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 = ''; 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 = - ''; - 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) })(); diff --git a/frontend/assets/document.js b/frontend/assets/document.js index f47c2a8..b997cdc 100644 --- a/frontend/assets/document.js +++ b/frontend/assets/document.js @@ -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 { diff --git a/frontend/assets/header.js b/frontend/assets/header.js index 921ce4e..f62fce8 100644 --- a/frontend/assets/header.js +++ b/frontend/assets/header.js @@ -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= + * (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 = + ''; + 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) +} diff --git a/frontend/assets/login.js b/frontend/assets/login.js index ac8923b..ec2bcff 100644 --- a/frontend/assets/login.js +++ b/frontend/assets/login.js @@ -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(); })(); diff --git a/frontend/assets/sources.js b/frontend/assets/sources.js index ec3d9ea..164127b 100644 --- a/frontend/assets/sources.js +++ b/frontend/assets/sources.js @@ -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. })(); diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index a31bcc8..edf4c28 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -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
(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
— 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; } diff --git a/frontend/assets/tuning.js b/frontend/assets/tuning.js index 2461ed4..2baf388 100644 --- a/frontend/assets/tuning.js +++ b/frontend/assets/tuning.js @@ -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. */ diff --git a/frontend/document.html b/frontend/document.html index 592aa3f..e9db56c 100644 --- a/frontend/document.html +++ b/frontend/document.html @@ -11,28 +11,73 @@ +
-
- - - Sources - -
-

Loading…

-
-
- -
+
+
+ + + Brain of Reese + + + + + + + +
+ + Sources + +
+

Loading…

+
+
+
+
+ + +

diff --git a/frontend/index.html b/frontend/index.html index b623ba6..1f49081 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,32 +19,55 @@ - + - + + + - +
+ + +

- + diff --git a/frontend/sources.html b/frontend/sources.html index b55f761..d55c7d1 100644 --- a/frontend/sources.html +++ b/frontend/sources.html @@ -19,38 +19,59 @@ - - - + + + + +
- - + + - + + + + + - +