feat(ui): one consistent navbar on every page (TODO.md L3)
This commit is contained in:
+42
-107
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
})();
|
||||
|
||||
+44
-218
@@ -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…";
|
||||
window.addEventListener("bor:sync-status", (e) => {
|
||||
const status = e.detail || {};
|
||||
if (status.state === "running") {
|
||||
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");
|
||||
} else if (status.state === "success") {
|
||||
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).
|
||||
// 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
|
||||
} else if (status.state === "failed") {
|
||||
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");
|
||||
} else {
|
||||
// idle
|
||||
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
|
||||
}
|
||||
if (status.state === "running") {
|
||||
enterRunningState();
|
||||
startSyncPolling();
|
||||
} else if (status.state === "success") {
|
||||
applySyncSuccess(status);
|
||||
} else if (status.state === "failed") {
|
||||
applySyncFailure(status);
|
||||
}
|
||||
/* 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
@@ -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
@@ -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. */
|
||||
|
||||
+92
-17
@@ -11,28 +11,73 @@
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<!-- Phase 34 (owner confirmation 2026-08-26): the viewer carries the
|
||||
SAME standard bar as every other page — row 1 is the shared
|
||||
.app-header / .header-inner block (byte-identical controls), and
|
||||
the back link + title + meta survive in a second .doc-titlebar
|
||||
row inside the same <header> (the phase-19 single-row viewer bar
|
||||
is superseded — this phase records the revision, PLAN.md §7.1
|
||||
unchanged). No nav link is "current" here: a document is a
|
||||
detail view reachable from chat or Sources, and the phase-13
|
||||
back link carries the return affordance. -->
|
||||
<header class="doc-header">
|
||||
<div class="container doc-header-inner">
|
||||
<a class="doc-back" id="doc-back" href="/sources.html">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
||||
<span>Sources</span>
|
||||
</a>
|
||||
<div class="doc-title-block">
|
||||
<h1 id="doc-title">Loading…</h1>
|
||||
<div id="doc-meta" class="doc-meta"></div>
|
||||
</div>
|
||||
<!-- Phase 19: the shared header controls reach the viewer bar —
|
||||
same markup, ids, and aria as the chat header (one consistent
|
||||
bar on every page). The viewer has no nav, so no Sources link
|
||||
here. header.js (assets/header.js) reveals exactly one of Sign in /
|
||||
Sign out after whoami; New chat here means "go to the chat,
|
||||
fresh" (document.js). The title block clips while the two
|
||||
pills fit (.doc-header-actions, styles.css). -->
|
||||
<div class="doc-header-actions">
|
||||
<div class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<nav class="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
</nav>
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): open the tuning-notes panel (stored in
|
||||
Postgres, read into every system prompt). The behavior is
|
||||
owned by the shared header module (assets/header.js); the
|
||||
#steering-panel section ships in every page's <main>. -->
|
||||
<button type="button" class="steering-toggle" id="steering-toggle"
|
||||
aria-expanded="false" aria-controls="steering-panel">
|
||||
<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>
|
||||
<span class="steering-label">Tuning</span>
|
||||
<span class="steering-count" id="steering-count">0</span>
|
||||
</button>
|
||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
||||
hidden (anonymous-safe), header.js reveals it for the admin
|
||||
on the SAME cached whoami that reveals #nav-sources /
|
||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
||||
module-owned (assets/header.js); the Sources page's
|
||||
#sync-result line + #sync-error-banner render off the
|
||||
module's "bor:sync-status" event. -->
|
||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
||||
</button>
|
||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
||||
2026-08-26; module-owned since phase 34 task 02): on the
|
||||
chat page "New chat" resets the local (localStorage)
|
||||
conversation; on every other page it means "go to the
|
||||
chat, fresh" (the module clears the key + navigates). -->
|
||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="new-chat-label">New chat</span>
|
||||
</button>
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/document.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
@@ -43,9 +88,39 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: the viewer titlebar — the phase-10 back link + title +
|
||||
meta row, markup otherwise unchanged (document.js addresses
|
||||
them by id). -->
|
||||
<div class="doc-titlebar">
|
||||
<div class="container">
|
||||
<a class="doc-back" id="doc-back" href="/sources.html">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
||||
<span>Sources</span>
|
||||
</a>
|
||||
<div class="doc-title-block">
|
||||
<h1 id="doc-title">Loading…</h1>
|
||||
<div id="doc-meta" class="doc-meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel (stored notes, newest
|
||||
first) — rendered + driven by assets/header.js (shared), not
|
||||
the page script. First child of <main> on the non-chat pages;
|
||||
the chat page keeps it after #kb-banner. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<!-- aria-live wraps the load → content swap so screen readers hear the
|
||||
document land (phase 10 a11y contract). -->
|
||||
<div class="container doc-shell" aria-live="polite">
|
||||
|
||||
+41
-14
@@ -19,32 +19,55 @@
|
||||
</span>
|
||||
<nav class="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link is-active" aria-current="page">Chat</a>
|
||||
<!-- Phase 19: the Sources link is admin-only (owner permission
|
||||
2026-08-23) — hidden by default, header.js reveals it once
|
||||
whoami says admin. The soft-gated page itself is unchanged. -->
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 29: the Global Tuning link is admin-only (owner permission
|
||||
2026-08-25) — hidden by default, header.js reveals it once
|
||||
whoami says admin, exactly like the Sources link above. Points at
|
||||
the standalone /tuning.html manager (phase 27). -->
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
</nav>
|
||||
<!-- Phase 15: open the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — chat page only. -->
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): open the tuning-notes panel (stored in
|
||||
Postgres, read into every system prompt). The behavior is
|
||||
owned by the shared header module (assets/header.js); the
|
||||
#steering-panel section ships in every page's <main>. -->
|
||||
<button type="button" class="steering-toggle" id="steering-toggle"
|
||||
aria-expanded="false" aria-controls="steering-panel">
|
||||
<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>
|
||||
<span class="steering-label">Tuning</span>
|
||||
<span class="steering-count" id="steering-count">0</span>
|
||||
</button>
|
||||
<!-- Phase 14: reset the local (localStorage) conversation — chat page only. -->
|
||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
||||
hidden (anonymous-safe), header.js reveals it for the admin
|
||||
on the SAME cached whoami that reveals #nav-sources /
|
||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
||||
module-owned (assets/header.js); the Sources page's
|
||||
#sync-result line + #sync-error-banner render off the
|
||||
module's "bor:sync-status" event. -->
|
||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
||||
</button>
|
||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
||||
2026-08-26; module-owned since phase 34 task 02): on the
|
||||
chat page "New chat" resets the local (localStorage)
|
||||
conversation; on every other page it means "go to the
|
||||
chat, fresh" (the module clears the key + navigates). -->
|
||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="new-chat-label">New chat</span>
|
||||
</button>
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign out
|
||||
is visible; /api/whoami decides at load (app.js). Icon-only
|
||||
below 640px (aria-labels keep the accessible names). -->
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
@@ -63,7 +86,11 @@
|
||||
<span id="kb-banner-text"></span>
|
||||
</div>
|
||||
|
||||
<!-- Phase 15: tuning-notes panel (stored notes, newest first). -->
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel (stored notes, newest
|
||||
first) — rendered + driven by assets/header.js (shared), not
|
||||
the page script. First child of <main> on the non-chat
|
||||
pages; the chat page keeps it after #kb-banner. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
|
||||
+77
-9
@@ -20,16 +20,82 @@
|
||||
</span>
|
||||
<nav class="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19: the Sources link is admin-only (owner permission
|
||||
2026-08-23) — hidden by default, header.js reveals it once
|
||||
whoami says admin. The login page deliberately carries NO
|
||||
chat controls, so header.js only toggles this link here. -->
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
</nav>
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): open the tuning-notes panel (stored in
|
||||
Postgres, read into every system prompt). The behavior is
|
||||
owned by the shared header module (assets/header.js); the
|
||||
#steering-panel section ships in every page's <main>. -->
|
||||
<button type="button" class="steering-toggle" id="steering-toggle"
|
||||
aria-expanded="false" aria-controls="steering-panel">
|
||||
<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>
|
||||
<span class="steering-label">Tuning</span>
|
||||
<span class="steering-count" id="steering-count">0</span>
|
||||
</button>
|
||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
||||
hidden (anonymous-safe), header.js reveals it for the admin
|
||||
on the SAME cached whoami that reveals #nav-sources /
|
||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
||||
module-owned (assets/header.js); the Sources page's
|
||||
#sync-result line + #sync-error-banner render off the
|
||||
module's "bor:sync-status" event. -->
|
||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
||||
</button>
|
||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
||||
2026-08-26; module-owned since phase 34 task 02): on the
|
||||
chat page "New chat" resets the local (localStorage)
|
||||
conversation; on every other page it means "go to the
|
||||
chat, fresh" (the module clears the key + navigates). -->
|
||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="new-chat-label">New chat</span>
|
||||
</button>
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/login.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel (stored notes, newest
|
||||
first) — rendered + driven by assets/header.js (shared), not
|
||||
the page script. First child of <main> on the non-chat pages;
|
||||
the chat page keeps it after #kb-banner. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<div class="container login-shell">
|
||||
<!-- Centered sign-in card (phase 16): one admin, one password. -->
|
||||
<section class="login-card" aria-labelledby="login-title">
|
||||
@@ -61,11 +127,13 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Phase 19: shared header module — the login page reuses it for the
|
||||
Sources-link toggle only (no chat controls in this markup, so
|
||||
none appear). It loads through the page script's own
|
||||
`import "./header.js"` (hoisted, evaluated before the page script
|
||||
body calls initSharedHeader() at boot). -->
|
||||
<!-- Phase 19 (full header from phase 34 — owner confirmation
|
||||
2026-08-26): the login page carries the SAME shared header as
|
||||
every other page (nav incl. the admin-only Tuning link, Tuning
|
||||
toggle, Sync, New chat, the auth pair) + the #steering-panel in
|
||||
<main>. The module loads through the page script's own
|
||||
`import "./header.js"` (hoisted, evaluated before the page
|
||||
script body calls initSharedHeader() at boot). -->
|
||||
<script type="module" src="/assets/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+59
-23
@@ -19,38 +19,59 @@
|
||||
</span>
|
||||
<nav class="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19: the Sources link is admin-only (owner permission
|
||||
2026-08-23) — hidden by default, header.js reveals it once
|
||||
whoami says admin. The soft-gated page itself is unchanged. -->
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 29: the Global Tuning link is admin-only (owner permission
|
||||
2026-08-25) — hidden by default, header.js reveals it once
|
||||
whoami says admin, exactly like the Sources link above. -->
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
</nav>
|
||||
<!-- Phase 19: the shared header controls reach the Sources page —
|
||||
same markup, ids, and aria as the chat header (one consistent
|
||||
bar on every page). header.js (assets/header.js) reveals
|
||||
exactly one of Sign in / Sign out after whoami; the New chat
|
||||
button here means "go to the chat, fresh" (sources.js). -->
|
||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="new-chat-label">New chat</span>
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): open the tuning-notes panel (stored in
|
||||
Postgres, read into every system prompt). The behavior is
|
||||
owned by the shared header module (assets/header.js); the
|
||||
#steering-panel section ships in every page's <main>. -->
|
||||
<button type="button" class="steering-toggle" id="steering-toggle"
|
||||
aria-expanded="false" aria-controls="steering-panel">
|
||||
<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>
|
||||
<span class="steering-label">Tuning</span>
|
||||
<span class="steering-count" id="steering-count">0</span>
|
||||
</button>
|
||||
<!-- Phase 32: the admin-only "Sync sources" button (TODO.md:5) —
|
||||
SHIPS hidden (anonymous-safe), header.js reveals it for the
|
||||
admin on the SAME cached whoami that reveals #nav-sources /
|
||||
#nav-tuning (one fetch, no extra whoami call). sources.js
|
||||
drives the §7.4 "never stale" lifecycle: idle → "Syncing…"
|
||||
(disabled + spinning icon + 2 s GET /api/sync/status poll) →
|
||||
last result ("Synced HH:MM" + counts in #sync-result) or the
|
||||
role="alert" error banner. #sync-result is the aria-live
|
||||
announcer for the last result. -->
|
||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
||||
hidden (anonymous-safe), header.js reveals it for the admin
|
||||
on the SAME cached whoami that reveals #nav-sources /
|
||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
||||
module-owned (assets/header.js); the Sources page's
|
||||
#sync-result line + #sync-error-banner render off the
|
||||
module's "bor:sync-status" event. -->
|
||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
||||
</button>
|
||||
<!-- #sync-result is the aria-live announcer for the last sync
|
||||
result ("N added · …"), sources-page-specific — it renders
|
||||
off the module's "bor:sync-status" event (sources.js). -->
|
||||
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
|
||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
||||
2026-08-26; module-owned since phase 34 task 02): on the
|
||||
chat page "New chat" resets the local (localStorage)
|
||||
conversation; on every other page it means "go to the
|
||||
chat, fresh" (the module clears the key + navigates). -->
|
||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="new-chat-label">New chat</span>
|
||||
</button>
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/sources.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
@@ -63,6 +84,21 @@
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel (stored notes, newest
|
||||
first) — rendered + driven by assets/header.js (shared), not
|
||||
the page script. First child of <main> on the non-chat pages;
|
||||
the chat page keeps it after #kb-banner. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<div class="container sources-shell">
|
||||
<!-- Phase 32: the sync failure banner — the chat error-banner
|
||||
markup style (kb-banner + is-error), role="alert" so a failed
|
||||
|
||||
+58
-17
@@ -19,29 +19,55 @@
|
||||
</span>
|
||||
<nav class="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19: the Sources link is admin-only (owner permission
|
||||
2026-08-23) — hidden by default, header.js reveals it once
|
||||
whoami says admin. The soft-gated page itself is unchanged. -->
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 27: this page's own nav link. Admin-only, exactly like
|
||||
the Sources link: it SHIPS hidden (anonymous-safe default —
|
||||
the phase-16 "absent, not hidden" spirit) and header.js
|
||||
reveals it once whoami says admin, so no anonymous user ever
|
||||
sees it for a frame. -->
|
||||
<a href="/tuning.html" class="nav-link is-active" id="nav-tuning" aria-current="page" hidden>Tuning</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/tuning.html" class="nav-link is-active" aria-current="page" id="nav-tuning" hidden>Tuning</a>
|
||||
</nav>
|
||||
<!-- Phase 19: the shared header controls reach the tuning page —
|
||||
same markup, ids, and aria as the other pages (one consistent
|
||||
bar on every page). header.js (assets/header.js) reveals
|
||||
exactly one of Sign in / Sign out after whoami; the New chat
|
||||
button here means "go to the chat, fresh" (tuning.js). -->
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): open the tuning-notes panel (stored in
|
||||
Postgres, read into every system prompt). The behavior is
|
||||
owned by the shared header module (assets/header.js); the
|
||||
#steering-panel section ships in every page's <main>. -->
|
||||
<button type="button" class="steering-toggle" id="steering-toggle"
|
||||
aria-expanded="false" aria-controls="steering-panel">
|
||||
<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>
|
||||
<span class="steering-label">Tuning</span>
|
||||
<span class="steering-count" id="steering-count">0</span>
|
||||
</button>
|
||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
||||
hidden (anonymous-safe), header.js reveals it for the admin
|
||||
on the SAME cached whoami that reveals #nav-sources /
|
||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
||||
module-owned (assets/header.js); the Sources page's
|
||||
#sync-result line + #sync-error-banner render off the
|
||||
module's "bor:sync-status" event. -->
|
||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
||||
</button>
|
||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
||||
2026-08-26; module-owned since phase 34 task 02): on the
|
||||
chat page "New chat" resets the local (localStorage)
|
||||
conversation; on every other page it means "go to the
|
||||
chat, fresh" (the module clears the key + navigates). -->
|
||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="new-chat-label">New chat</span>
|
||||
</button>
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign out
|
||||
is visible; /api/whoami decides at load (header.js). Icon-only
|
||||
below 640px (aria-labels keep the accessible names). -->
|
||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/tuning.html" class="auth-link" id="sign-in-link" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
@@ -54,6 +80,21 @@
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel (stored notes, newest
|
||||
first) — rendered + driven by assets/header.js (shared), not
|
||||
the page script. First child of <main> on the non-chat pages;
|
||||
the chat page keeps it after #kb-banner. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<div class="container tuning-shell">
|
||||
<div class="page-head">
|
||||
<h1>Global Tuning</h1>
|
||||
|
||||
@@ -101,9 +101,11 @@ def test_anonymous_chat_without_tuning(
|
||||
|
||||
# Header: Sign in offered, Sign out not.
|
||||
expect(page.locator("#sign-in-link")).to_be_visible()
|
||||
expect(page.locator("#sign-in-link")).to_have_attribute(
|
||||
"href", "/login.html?next=/sources.html"
|
||||
)
|
||||
# Phase 34 task 02: the shared header module rewrites the static
|
||||
# ?next= fallback to the CURRENT pathname ("return to where you
|
||||
# were") — on the chat page that is "/" (the markup keeps
|
||||
# ?next=/sources.html as the no-JS fallback only).
|
||||
expect(page.locator("#sign-in-link")).to_have_attribute("href", "/login.html?next=/")
|
||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||
|
||||
# Chat still streams a grounded answer (with source chips) for
|
||||
|
||||
@@ -18,6 +18,14 @@ Phase 16 adaptation: the auth control (Sign in / Sign out) joins the chat
|
||||
header's ``.header-inner`` — the desktop test verifies its presence in
|
||||
both auth states without the bar's height moving (height assertions
|
||||
unchanged).
|
||||
|
||||
Phase 34 adaptation (two-row viewer header, owner confirmation
|
||||
2026-08-26): the viewer's ``<header>`` is now TWO rows — row 1 is the
|
||||
standard shared bar (``.doc-header .app-header``, the phase-12/19
|
||||
``--header-h`` contract) and row 2 is the ``.doc-titlebar`` (back +
|
||||
title + meta, content-sized). The height assertions are pointed at ROW
|
||||
1 — the standard bar — which must equal the chat/sources bars exactly;
|
||||
the titlebar row is asserted present (height > 0), not height-pinned.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -95,7 +103,9 @@ def _header_heights(page: Page, app_url: str) -> dict[str, float]:
|
||||
|
||||
page.goto(app_url + VIEWER_URL)
|
||||
expect(page.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster", timeout=15_000)
|
||||
heights["document"] = _box_height(page, ".doc-header")
|
||||
# Phase 34: the viewer header is two rows — measure ROW 1 (the
|
||||
# standard bar), which must equal the other pages' bars exactly.
|
||||
heights["document"] = _box_height(page, ".doc-header .app-header")
|
||||
return heights
|
||||
|
||||
|
||||
@@ -158,6 +168,9 @@ def test_header_height_identical_across_pages_mobile(
|
||||
def test_viewer_header_content_still_fits(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
"""Phase 34: the phase-10 viewer content (title, badges, back link)
|
||||
survives in the titlebar ROW, and row 1 stays the pinned standard
|
||||
bar — single-line on both desktop and mobile."""
|
||||
_seed_db(mock_llm)
|
||||
|
||||
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)):
|
||||
@@ -176,10 +189,14 @@ def test_viewer_header_content_still_fits(
|
||||
expect(page.locator(".format-badge", has_text="md")).to_be_visible()
|
||||
expect(page.locator(".doc-path", has_text="homelab/kubernetes.md")).to_be_visible()
|
||||
|
||||
# Back link still there, ≥44px touch target, in the shared bar.
|
||||
# Back link still there, ≥44px touch target, in the titlebar row.
|
||||
back = page.locator("#doc-back")
|
||||
expect(back).to_be_visible()
|
||||
assert _box_height(page, "#doc-back") >= 44
|
||||
expect(page.locator(".doc-header")).to_have_css(
|
||||
|
||||
# Phase 34 two-row contract: ROW 1 is the standard bar (the
|
||||
# pinned --header-h), and the titlebar row is present below it.
|
||||
expect(page.locator(".doc-header .app-header")).to_have_css(
|
||||
"height", f"{expected_h}px"
|
||||
)
|
||||
assert _box_height(page, ".doc-titlebar") > 0, ("the titlebar row must render")
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
"""Phase 34 story E2E (Playwright): ONE navbar on every page.
|
||||
|
||||
Story: ``.agent/user_stories/nav-consistency.md``
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_nav_consistency.py -v --no-cov
|
||||
|
||||
TODO.md L3 (owner 2026-08-26): "I want the navbar to be consistent
|
||||
between every page. I don't want buttons to pop in and out of existance.
|
||||
Just keep all those buttons active across all tabs."
|
||||
|
||||
Contract under test — the header is IDENTICAL on all five pages (chat,
|
||||
sources, document viewer, global tuning, login): one shared markup block
|
||||
(phase 34 task 03), one owner of all control behavior (header.js, tasks
|
||||
01/02), the viewer's back + title preserved in a second titlebar row
|
||||
(task 04), and the phase-12/19 height contract (64px desktop / 58px at
|
||||
≤640px) on the standard row everywhere.
|
||||
|
||||
Per role, the VISIBLE inventory:
|
||||
|
||||
* admin: brand + nav [Chat, #nav-sources, #nav-tuning] + #steering-toggle
|
||||
+ #sync-btn + #new-chat-btn + #sign-out-btn (with #sign-in-link
|
||||
hidden) — on all five pages, same id+class inventory, same DOM order;
|
||||
* anonymous: brand + nav [Chat] (#nav-sources / #nav-tuning hidden —
|
||||
locked A10 UI revision) + #new-chat-btn + #sign-in-link (with
|
||||
#sync-btn hidden, #sign-out-btn hidden) on all five pages — and the
|
||||
steering toggle + panel are ABSENT from the DOM (phase 16 "absent,
|
||||
not hidden" treatment, carried into phase 34 task 01; test_admin_auth
|
||||
pins it).
|
||||
|
||||
Normalization for the inventory comparison: the current-page ``is-active``
|
||||
nav marker and the sign-in ``?next=`` value legitimately differ per page,
|
||||
so both are stripped (the href is compared by pathname only).
|
||||
|
||||
Viewer specifics: row 1 (the standard bar) is exactly as tall as the chat
|
||||
page's bar (64px / 58px) and row 2 (``.doc-titlebar``) is present with
|
||||
#doc-back + #doc-title + #doc-meta badges; #doc-back target resolution
|
||||
(phase 13) is honored — ``back=`` accepted for same-origin relative
|
||||
URLs, rejected (→ /sources.html) otherwise.
|
||||
|
||||
Steering works off-chat: on /tuning.html (admin, zero notes) the toggle
|
||||
opens/closes #steering-panel with the empty state and a 0 count badge —
|
||||
no chat needed. Sync is present, not triggered: #sync-btn is visible on
|
||||
/tuning.html but is never clicked here (a real sync clones real repos —
|
||||
the full state machine is test_sync_button.py's job).
|
||||
|
||||
Determinism note: every assertion is settled-state — each page visit
|
||||
first waits for the whoami toggle to land (exactly one of Sign in /
|
||||
Sign out visible; the anonymous removal of the steering toggle happens
|
||||
in the SAME initSharedHeader pass) and, on the viewer, for the document
|
||||
title to render. The seed truncates steering_notes, so the count badge is
|
||||
0 on every admin page. No chat turn is ever submitted; #sync-btn is
|
||||
never clicked.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_admin_inventory_identical_on_all_five_pages``
|
||||
2. ``test_anonymous_inventory_identical_on_all_five_pages``
|
||||
3. ``test_viewer_row1_height_matches_chat_and_titlebar_present``
|
||||
4. ``test_viewer_back_link_honors_back_param``
|
||||
5. ``test_steering_panel_works_off_chat_on_tuning_page``
|
||||
6. ``test_sync_button_present_on_tuning_page_without_triggering``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
from e2e.auth_helpers import login
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||
|
||||
#: The five pages of the app (acceptance criterion 2 of the story).
|
||||
CHAT_URL = "/"
|
||||
SOURCES_URL = "/sources.html"
|
||||
TUNING_URL = "/tuning.html"
|
||||
LOGIN_URL = "/login.html"
|
||||
#: A seeded fixture doc (source=docs), URL-encoded — the same document
|
||||
#: every viewer suite uses (title "Kubernetes Homelab Cluster").
|
||||
VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
|
||||
DOC_TITLE = "Kubernetes Homelab Cluster"
|
||||
|
||||
#: The phase-12/19 pinned bar heights (frontend/assets/styles.css
|
||||
#: --header-h, desktop and ≤640px).
|
||||
DESKTOP_HEADER_H = 64
|
||||
MOBILE_HEADER_H = 58
|
||||
|
||||
|
||||
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||||
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
|
||||
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
return await import_sources([FIXTURES], LLMClient(settings))
|
||||
|
||||
|
||||
def _run_in_thread(coro: Any) -> Any:
|
||||
"""Run a coroutine on a worker thread (Playwright owns the test loop)."""
|
||||
box: dict[str, Any] = {}
|
||||
|
||||
def runner() -> None:
|
||||
try:
|
||||
box["value"] = asyncio.run(coro)
|
||||
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||||
box["error"] = e
|
||||
|
||||
t = Thread(target=runner)
|
||||
t.start()
|
||||
t.join()
|
||||
if "error" in box:
|
||||
raise box["error"]
|
||||
return box["value"]
|
||||
|
||||
|
||||
def _seed_db(mock_port: int) -> None:
|
||||
"""Fresh KB + ZERO steering notes (deterministic count badge on
|
||||
every admin page) + the fixture docs for the viewer URL."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
||||
db.commit()
|
||||
_run_in_thread(_import_fixtures(mock_port))
|
||||
|
||||
|
||||
def _box_height(page: Page, selector: str) -> float:
|
||||
box = page.locator(selector).bounding_box()
|
||||
assert box is not None, f"{selector} not rendered"
|
||||
return box["height"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The heart of the suite: the normalized header-control inventory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The header controls, in their shipped DOM order. The inventory is
|
||||
#: normalized per the task: the current-page ``is-active`` nav marker is
|
||||
#: stripped from the class list, and anchor hrefs are compared by
|
||||
#: pathname only (the sign-in ``?next=`` value legitimately differs per
|
||||
#: page — it is rewritten to the current page by header.js).
|
||||
_INVENTORY_JS = """() => {
|
||||
const inner = document.querySelector("header .header-inner");
|
||||
if (!inner) return null;
|
||||
const sel = [
|
||||
".brand",
|
||||
".app-nav > a.nav-link",
|
||||
"#steering-toggle",
|
||||
"#sync-btn",
|
||||
"#new-chat-btn",
|
||||
"#sign-in-link",
|
||||
"#sign-out-btn",
|
||||
].join(",");
|
||||
return [...inner.querySelectorAll(sel)].map((el) => {
|
||||
const classes = [...el.classList].filter((c) => c !== "is-active");
|
||||
const id = el.id ? "#" + el.id : "";
|
||||
const href = el.tagName === "A" ? (el.getAttribute("href") || "").split("?")[0] : "";
|
||||
const text = (el.textContent || "").replace(/\\s+/g, " ").trim();
|
||||
return el.tagName.toLowerCase() + id + "." + classes.join(".") + "::" + text + "::" + href;
|
||||
});
|
||||
}"""
|
||||
|
||||
|
||||
def _header_inventory(page: Page) -> list[str]:
|
||||
"""The ordered id+class inventory of the header controls on the page
|
||||
``page`` is showing (normalized — see _INVENTORY_JS)."""
|
||||
inv = page.evaluate(_INVENTORY_JS)
|
||||
assert inv is not None, "no `header .header-inner` on this page"
|
||||
assert len(inv) >= 8, f"header control inventory unexpectedly short: {inv}"
|
||||
return inv
|
||||
|
||||
|
||||
def _wait_settled(page: Page, admin: bool) -> None:
|
||||
"""Wait for initSharedHeader's whoami toggle to land: exactly one of
|
||||
Sign in / Sign out is visible (both ship hidden in the HTML). For
|
||||
anonymous visitors the steering toggle + panel removal happens in
|
||||
the SAME pass, so they are already gone when this returns."""
|
||||
if admin:
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||
else:
|
||||
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||
|
||||
|
||||
def _assert_landmarks(page: Page, label: str) -> None:
|
||||
"""UI Structure Check (AGENTS.md rule 5): the page's landmarks — a
|
||||
<header>, the labeled <nav>, and <main> — survive on every page."""
|
||||
assert page.locator("header").count() >= 1, f"{label}: no <header> landmark"
|
||||
assert page.locator('nav[aria-label="Primary"]').count() == 1, (
|
||||
f"{label}: no labeled <nav aria-label> landmark"
|
||||
)
|
||||
assert page.locator("main").count() >= 1, f"{label}: no <main> landmark"
|
||||
|
||||
|
||||
def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[str]:
|
||||
"""Goto a page, wait for the settled header state (+ the document on
|
||||
the viewer), check the per-role visible inventory, and return the
|
||||
normalized control inventory."""
|
||||
page.goto(app_url + url)
|
||||
_wait_settled(page, admin=admin)
|
||||
if name == "viewer":
|
||||
# The document itself has settled (rendered, not Loading…/
|
||||
# not-found) so the bar is measured on the real page.
|
||||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||||
|
||||
# The per-role VISIBLE inventory (the story: no button pops in or
|
||||
# out because of which page you are on).
|
||||
expect(page.locator(".app-nav a[href='/']")).to_be_visible() # Chat
|
||||
if admin:
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||
expect(page.locator("#steering-toggle")).to_be_visible()
|
||||
expect(page.locator("#sync-btn")).to_be_visible()
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||
else:
|
||||
# Locked A10 UI revision: admin-only links ship hidden, never
|
||||
# revealed for anonymous…
|
||||
expect(page.locator("#nav-sources")).to_be_hidden()
|
||||
expect(page.locator("#nav-tuning")).to_be_hidden()
|
||||
expect(page.locator("#sync-btn")).to_be_hidden()
|
||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||
expect(page.locator("#sign-in-link")).to_be_visible()
|
||||
# …and the steering surface is ABSENT (phase 16 "absent, not
|
||||
# hidden", carried into the shared module by phase 34 task 01
|
||||
# — test_admin_auth pins the same contract).
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
f"{name}: the steering toggle must be absent for anonymous"
|
||||
)
|
||||
assert page.locator("#steering-panel").count() == 0, (
|
||||
f"{name}: the steering panel must be absent for anonymous"
|
||||
)
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
|
||||
_assert_landmarks(page, name)
|
||||
return _header_inventory(page)
|
||||
|
||||
|
||||
LOGIN_JS_ROUTE = re.compile(r"/assets/login\.js(\?.*)?$")
|
||||
|
||||
|
||||
def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
|
||||
"""The login page's header IN THE ADMIN STATE. A signed-in admin is
|
||||
redirected off the login form by login.js (``location.replace`` →
|
||||
the default next, /sources.html — phase 16, pinned by
|
||||
test_admin_auth), so this ONE visit serves the page script with the
|
||||
redirect lines suppressed (a test-local route on the login.js
|
||||
script; the page's header — settled by the same initSharedHeader
|
||||
pass — is what gets measured, and the page stays put).
|
||||
|
||||
The browser cache is cleared first: phase 33 caches ``/assets/*``
|
||||
``immutable`` for a year, and the earlier form login already fetched
|
||||
the (unmodified) login.js — a cache hit would bypass the route.
|
||||
"""
|
||||
login_js = (REPO / "frontend" / "assets" / "login.js").read_text(encoding="utf-8")
|
||||
assert "window.location.replace(safeNext())" in login_js
|
||||
suppressed = login_js.replace(
|
||||
"window.location.replace(safeNext())",
|
||||
"window.__e2e_redirectSuppressed = true; // test: observe the header",
|
||||
)
|
||||
page.route(
|
||||
LOGIN_JS_ROUTE,
|
||||
lambda route: route.fulfill(
|
||||
status=200, content_type="text/javascript", body=suppressed
|
||||
),
|
||||
)
|
||||
try:
|
||||
cdp = page.context.new_cdp_session(page)
|
||||
try:
|
||||
cdp.send("Network.clearBrowserCache")
|
||||
finally:
|
||||
cdp.detach()
|
||||
page.goto(app_url + LOGIN_URL)
|
||||
expect(page).to_have_url(app_url + LOGIN_URL, timeout=15_000)
|
||||
_wait_settled(page, admin=True)
|
||||
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||
expect(page.locator("#steering-toggle")).to_be_visible()
|
||||
expect(page.locator("#sync-btn")).to_be_visible()
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
_assert_landmarks(page, "login")
|
||||
return _header_inventory(page)
|
||||
finally:
|
||||
page.unroute(LOGIN_JS_ROUTE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Admin: the same visible controls, same inventory, same DOM order,
|
||||
# on all five pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_inventory_identical_on_all_five_pages(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm)
|
||||
|
||||
login(page, app_url, next=CHAT_URL)
|
||||
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
|
||||
|
||||
inventories: dict[str, list[str]] = {}
|
||||
for name, url in (
|
||||
("chat", CHAT_URL),
|
||||
("sources", SOURCES_URL),
|
||||
("viewer", VIEWER_URL),
|
||||
("tuning", TUNING_URL),
|
||||
):
|
||||
inventories[name] = _visit(page, app_url, name, url, admin=True)
|
||||
# The login page redirects a signed-in admin away — measure it with
|
||||
# the redirect aborted (see the helper).
|
||||
inventories["login"] = _admin_login_page_inventory(page, app_url)
|
||||
|
||||
reference = inventories["chat"]
|
||||
for name, inv in inventories.items():
|
||||
assert inv == reference, (
|
||||
f"admin header control inventory differs on {name}:\n"
|
||||
f" chat: {reference}\n {name}: {inv}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Anonymous: the reduced bar — identically — on all five pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_inventory_identical_on_all_five_pages(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm)
|
||||
|
||||
# No login: a fresh context is anonymous by construction.
|
||||
inventories: dict[str, list[str]] = {}
|
||||
for name, url in (
|
||||
("chat", CHAT_URL),
|
||||
("sources", SOURCES_URL),
|
||||
("viewer", VIEWER_URL),
|
||||
("tuning", TUNING_URL),
|
||||
("login", LOGIN_URL),
|
||||
):
|
||||
inventories[name] = _visit(page, app_url, name, url, admin=False)
|
||||
|
||||
reference = inventories["chat"]
|
||||
for name, inv in inventories.items():
|
||||
assert inv == reference, (
|
||||
f"anonymous header control inventory differs on {name}:\n"
|
||||
f" chat: {reference}\n {name}: {inv}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Viewer: row 1 is exactly the chat bar's height (64px / 58px) and
|
||||
# the titlebar row (back + title + meta) is present
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_viewer_row1_height_matches_chat_and_titlebar_present(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_seed_db(mock_llm)
|
||||
|
||||
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)):
|
||||
page.set_viewport_size({"width": width, "height": 800})
|
||||
|
||||
page.goto(app_url + CHAT_URL)
|
||||
chat_h = _box_height(page, ".app-header")
|
||||
|
||||
page.goto(app_url + VIEWER_URL)
|
||||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||||
# Row 1 IS the standard bar — the same --header-h as chat…
|
||||
row1 = _box_height(page, ".doc-header .app-header")
|
||||
assert row1 == expected_h, f"viewer row 1 is {row1}px at {width}px"
|
||||
assert chat_h == expected_h, f"chat bar is {chat_h}px at {width}px"
|
||||
assert row1 == chat_h, "viewer row 1 must match the chat bar exactly"
|
||||
# …and the titlebar row exists below it (content-sized, > 0)…
|
||||
titlebar = _box_height(page, ".doc-titlebar")
|
||||
assert titlebar > 0, "the .doc-titlebar row is not rendered"
|
||||
# …with the back link + the rendered title + the meta badges.
|
||||
expect(page.locator("#doc-back")).to_be_visible()
|
||||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE)
|
||||
expect(page.locator("#doc-meta .doc-source-badge", has_text="docs")).to_be_visible()
|
||||
expect(page.locator("#doc-meta .format-badge", has_text="md")).to_be_visible()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Viewer: #doc-back target resolution (phase 13) — one positive, one
|
||||
# rejection case, both by clicking the link
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_viewer_back_link_honors_back_param(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm)
|
||||
|
||||
# Positive: back=/ (same-origin relative) is honored — href "/",
|
||||
# label "Chat", and the click returns to the chat page.
|
||||
page.goto(app_url + VIEWER_URL + "&back=%2F")
|
||||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||||
back = page.locator("#doc-back")
|
||||
expect(back).to_have_attribute("href", "/")
|
||||
expect(back.locator("span")).to_have_text("Chat")
|
||||
back.click()
|
||||
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
|
||||
|
||||
# Rejection: an absolute URL is NOT same-origin-relative — the
|
||||
# target falls back to the Sources page (label "Sources") and the
|
||||
# click goes there.
|
||||
page.goto(app_url + VIEWER_URL + "&back=https%3A%2F%2Fevil.example")
|
||||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||||
back = page.locator("#doc-back")
|
||||
expect(back).to_have_attribute("href", "/sources.html")
|
||||
expect(back.locator("span")).to_have_text("Sources")
|
||||
back.click()
|
||||
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Steering works off-chat: on /tuning.html (admin, zero notes) the
|
||||
# header toggle drives the panel — open/close cycle, empty state,
|
||||
# count badge 0. No chat turn is needed.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_steering_panel_works_off_chat_on_tuning_page(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm) # truncates steering_notes → zero notes
|
||||
|
||||
login(page, app_url, next=TUNING_URL)
|
||||
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
|
||||
# Settled admin state: the toggle is on the bar, the panel ships
|
||||
# hidden, and the count badge reads 0 (zero seeded notes).
|
||||
expect(page.locator("#steering-toggle")).to_be_visible()
|
||||
expect(page.locator("#steering-panel")).to_be_hidden()
|
||||
expect(page.locator("#steering-count")).to_have_text("0")
|
||||
|
||||
# Open: the panel shows, the toggle's aria-expanded follows, and
|
||||
# the empty state is visible (the re-open refresh fetched 0 notes).
|
||||
page.click("#steering-toggle")
|
||||
expect(page.locator("#steering-panel")).to_be_visible()
|
||||
expect(page.locator("#steering-toggle")).to_have_attribute("aria-expanded", "true")
|
||||
expect(page.locator("#steering-empty")).to_be_visible()
|
||||
expect(page.locator("#steering-list .steering-note")).to_have_count(0)
|
||||
expect(page.locator("#steering-count")).to_have_text("0")
|
||||
|
||||
# Close: the cycle completes, the count badge still reads 0.
|
||||
page.click("#steering-toggle")
|
||||
expect(page.locator("#steering-panel")).to_be_hidden()
|
||||
expect(page.locator("#steering-toggle")).to_have_attribute("aria-expanded", "false")
|
||||
expect(page.locator("#steering-count")).to_have_text("0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Sync is present (admin) on a non-Sources page — and is NOT
|
||||
# triggered: a real sync clones real repos; the full state machine
|
||||
# is test_sync_button.py's job.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sync_button_present_on_tuning_page_without_triggering(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm)
|
||||
|
||||
login(page, app_url, next=TUNING_URL)
|
||||
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_visible(timeout=15_000)
|
||||
# Idle, retry-ready state — the boot re-attach (GET /api/sync/status,
|
||||
# idle on the fresh app) must not have left it busy or labeled as a
|
||||
# finished run.
|
||||
expect(btn).to_be_enabled()
|
||||
assert btn.get_attribute("aria-busy") is None, "a fresh idle sync must not be busy"
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||||
# Deliberately NOT clicked.
|
||||
@@ -8,16 +8,20 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
Contract under test (owner report 2026-08-23, phase 19) — ONE bar per
|
||||
page, the same controls everywhere:
|
||||
|
||||
* chat / sources: brand + nav [Chat, Sources — admin only] + New Chat
|
||||
+ Sign in / Sign out;
|
||||
* document viewer: back + title + New Chat + Sign in / Sign out (the
|
||||
viewer has no nav, so no Sources link at all);
|
||||
* chat / sources / viewer: brand + nav [Chat, Sources — admin only] +
|
||||
New Chat + Sign in / Sign out;
|
||||
* document viewer: the standard bar (row 1) + back + title + meta in a
|
||||
second titlebar row (phase 34, owner confirmation 2026-08-26 — the
|
||||
viewer's old "no nav" single-row bar is superseded; it now carries
|
||||
the SAME nav contract as every other page);
|
||||
* the "Sources" nav link (``#nav-sources``) is HIDDEN for anonymous
|
||||
users on every page that has a nav and shown for admin (phase-16 UX
|
||||
revision with owner permission; the soft-gate page and the A10 API
|
||||
split are untouched);
|
||||
users on every page and shown for admin (phase-16 UX revision with
|
||||
owner permission; the soft-gate page and the A10 API split are
|
||||
untouched) — now on the viewer as well (phase 34);
|
||||
* the bar height never moves: 64px desktop / 58px at ≤640px (phase-12
|
||||
``--header-h`` contract, bounding-box measurement convention).
|
||||
``--header-h`` contract, bounding-box measurement convention) — on
|
||||
the viewer this is ROW 1 (``.doc-header .app-header``); the
|
||||
titlebar row is content-sized.
|
||||
|
||||
Determinism note: every assertion is settled-state — ``assert_shared_bar``
|
||||
first waits for the whoami toggle to land (exactly one of Sign in /
|
||||
@@ -109,7 +113,10 @@ def _expected_h(page: Page) -> int:
|
||||
|
||||
|
||||
def _bar_selector(page_kind: str) -> str:
|
||||
return ".doc-header" if page_kind == "viewer" else ".app-header"
|
||||
"""The STANDARD bar element on each page kind. Phase 34: the
|
||||
viewer's header is two rows — the height contract applies to row 1
|
||||
(the standard bar), not the whole two-row <header>."""
|
||||
return ".doc-header .app-header" if page_kind == "viewer" else ".app-header"
|
||||
|
||||
|
||||
def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
|
||||
@@ -134,24 +141,26 @@ def assert_shared_bar(page: Page, admin: bool, page_kind: str) -> None:
|
||||
# New Chat is on the bar on every page kind (the owner's ask).
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
|
||||
if page_kind == "viewer":
|
||||
# The viewer has no nav — no Sources link in the DOM at all.
|
||||
assert page.locator("#nav-sources").count() == 0, (
|
||||
"the viewer bar must not carry a Sources nav link"
|
||||
)
|
||||
# The document itself has settled (rendered, not Loading…/not-found)
|
||||
# so the bar is being measured on the real page.
|
||||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||||
else:
|
||||
# The Sources nav link: admin-only (phase-16 revision, owner
|
||||
# permission 2026-08-23) — hidden for anonymous, shown for admin.
|
||||
nav = page.locator("#nav-sources")
|
||||
assert nav.count() == 1, f"one #nav-sources expected on the {page_kind} page"
|
||||
# Phase 34: EVERY page kind — viewer included — carries the SAME
|
||||
# nav contract: the Chat link always visible; the admin-only
|
||||
# #nav-sources / #nav-tuning links (phase 19 / phase 29) ship
|
||||
# hidden and are revealed for admin (phase-16 UX revision, owner
|
||||
# permission 2026-08-23; the soft-gate page and the A10 API split
|
||||
# are untouched).
|
||||
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
|
||||
for link_id in ("#nav-sources", "#nav-tuning"):
|
||||
nav = page.locator(link_id)
|
||||
assert nav.count() == 1, f"one {link_id} expected on the {page_kind} page"
|
||||
if admin:
|
||||
expect(nav).to_be_visible()
|
||||
else:
|
||||
expect(nav).to_be_hidden()
|
||||
|
||||
if page_kind == "viewer":
|
||||
# The document itself has settled (rendered, not Loading…/not-found)
|
||||
# so the bar is being measured on the real page.
|
||||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||||
|
||||
# The bar height never moves: 64px desktop / 58px ≤640px (phase 12),
|
||||
# bounding-box measurement — the new pills must fit inside it.
|
||||
box = page.locator(_bar_selector(page_kind)).bounding_box()
|
||||
@@ -238,8 +247,9 @@ def test_sources_nav_hidden_for_anonymous_everywhere(
|
||||
assert nav.count() == 1
|
||||
expect(nav).to_be_hidden()
|
||||
|
||||
# The login page has no chat controls — header.js only toggles the
|
||||
# nav link there; for anonymous it stays hidden (it ships hidden).
|
||||
# The login page carries the full shared header too (phase 34);
|
||||
# for anonymous the admin-only nav links stay hidden (they ship
|
||||
# hidden and are revealed only for the admin).
|
||||
page.goto(app_url + "/login.html")
|
||||
page.wait_for_load_state("networkidle") # the whoami round-trip has settled
|
||||
nav = page.locator("#nav-sources")
|
||||
|
||||
@@ -59,6 +59,8 @@ def _make_git_repo(base: Path) -> tuple[Path, str]:
|
||||
"user.name=test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"commit.gpgsign=false", # the fixture commit never signs (env gpg)
|
||||
"commit",
|
||||
"-q",
|
||||
"-m",
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
The browser behavior is E2E-covered (tests/e2e/test_shared_header.py);
|
||||
here we pin the source-level wiring — the header.js exports, the cached
|
||||
whoami promise, the per-page HTML ids (anonymous-safe hidden-by-default
|
||||
controls), the sign-out binding move out of app.js, the non-chat New
|
||||
Chat bindings, and the viewer-bar CSS — so a silent regression is caught
|
||||
without a browser.
|
||||
controls), the sign-out binding move out of app.js, the SINGLE
|
||||
module-owned New chat binding (phase 34 task 02) + the sign-in
|
||||
?next= rewrite, and the viewer-bar CSS — so a silent regression is
|
||||
catched without a browser.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -109,17 +110,75 @@ def test_sign_out_binding_lives_in_the_shared_module() -> None:
|
||||
|
||||
|
||||
def test_nav_sources_ships_hidden_on_every_nav_page() -> None:
|
||||
"""Phase 19 UX revision (owner permission 2026-08-23): the Sources
|
||||
nav link is hidden for anonymous — so it SHIPS with the hidden
|
||||
attribute (anonymous-safe default) on every page that has a nav
|
||||
(chat, sources, login)."""
|
||||
for html in (INDEX_HTML, SOURCES_HTML, LOGIN_HTML, TUNING_HTML):
|
||||
"""Phase 19 UX revision (owner permission 2026-08-23), completed on
|
||||
all five pages by phase 34 task 03 (owner confirmation 2026-08-26):
|
||||
the Sources nav link is hidden for anonymous — so it SHIPS with the
|
||||
hidden attribute (anonymous-safe default) on every page (they all
|
||||
carry the nav now, viewer included)."""
|
||||
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML):
|
||||
text = _text(html)
|
||||
assert re.search(r'id="nav-sources"[^>]*\bhidden\b', text), (
|
||||
f"{html.name}: #nav-sources must ship hidden"
|
||||
)
|
||||
|
||||
|
||||
def test_all_five_pages_share_the_header_control_order() -> None:
|
||||
"""Phase 34 task 03 (owner confirmation 2026-08-26): every page
|
||||
ships the IDENTICAL header control inventory in the IDENTICAL
|
||||
order — brand, nav [Chat, Sources, Tuning], #steering-toggle,
|
||||
#sync-btn, #new-chat-btn, Sign in, Sign out — inside the shared
|
||||
.header-inner row (the document viewer's row 1). Only the
|
||||
current-page is-active nav marker and the static ?next= fallback
|
||||
may differ per page (task 05's story E2E pins the rendered
|
||||
result)."""
|
||||
markers = (
|
||||
'class="brand"',
|
||||
'<nav class="app-nav"',
|
||||
'href="/"',
|
||||
'id="nav-sources"',
|
||||
'id="nav-tuning"',
|
||||
'id="steering-toggle"',
|
||||
'id="sync-btn"',
|
||||
'id="new-chat-btn"',
|
||||
'id="sign-in-link"',
|
||||
'id="sign-out-btn"',
|
||||
)
|
||||
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, TUNING_HTML, LOGIN_HTML):
|
||||
text = _text(html)
|
||||
start = text.find('<div class="container header-inner">')
|
||||
assert start != -1, f"{html.name}: missing the shared .header-inner row"
|
||||
region = text[start : text.find("</header>", start)]
|
||||
missing = [m for m in markers if m not in region]
|
||||
assert not missing, f"{html.name}: header controls missing {missing}"
|
||||
for m in markers:
|
||||
assert region.count(m) == 1, f"{html.name}: {m} must appear exactly once"
|
||||
# Same order on every page: each control follows the previous one.
|
||||
pos = -1
|
||||
for m in markers:
|
||||
idx = region.find(m, pos + 1)
|
||||
assert idx > pos, f"{html.name}: {m} out of order in the shared bar"
|
||||
pos = idx
|
||||
|
||||
|
||||
def test_all_five_pages_carry_the_steering_panel() -> None:
|
||||
"""Phase 34 task 03: the #steering-panel section (+ the
|
||||
#steering-announcer live region) ships on every page — after
|
||||
#kb-banner in the chat shell, first child of <main> on the other
|
||||
four pages — ship hidden, driven by assets/header.js."""
|
||||
for html in (INDEX_HTML, SOURCES_HTML, DOCUMENT_HTML, TUNING_HTML, LOGIN_HTML):
|
||||
text = _text(html)
|
||||
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', text)
|
||||
assert tag, f"{html.name}: missing the #steering-panel section"
|
||||
assert re.search(r'\bhidden\b', tag.group(0)), "the panel ships hidden"
|
||||
assert 'id="steering-list"' in text
|
||||
assert 'id="steering-empty"' in text
|
||||
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', text), (
|
||||
f"{html.name}: missing the #steering-announcer live region"
|
||||
)
|
||||
# The announcer follows the panel (the copied index.html block).
|
||||
assert text.find('id="steering-panel"') < text.find('id="steering-announcer"')
|
||||
|
||||
|
||||
def test_nav_tuning_ships_hidden_on_the_tuning_page() -> None:
|
||||
"""Phase 27: the Global Tuning page reuses the shared header — the
|
||||
"Tuning" nav link is admin-only, so it SHIPS hidden (revealed by
|
||||
@@ -139,10 +198,38 @@ def test_nav_tuning_ships_hidden_on_the_tuning_page() -> None:
|
||||
assert [s for s in srcs if "tuning.js" in s]
|
||||
|
||||
|
||||
def test_nav_sources_is_absent_from_the_viewer() -> None:
|
||||
"""The document viewer has no nav — no #nav-sources element there (the
|
||||
module's missing-element no-op keeps it out)."""
|
||||
assert 'id="nav-sources"' not in _text(DOCUMENT_HTML)
|
||||
def test_viewer_carries_the_standard_nav() -> None:
|
||||
"""Phase 34 task 03 (owner confirmation 2026-08-26): the document
|
||||
viewer carries the SAME standard bar as every other page — row 1 is
|
||||
the shared .header-inner block, so the nav (Chat + the admin-only
|
||||
#nav-sources / #nav-tuning links, ship hidden) is there too. No nav
|
||||
link is "current" on the viewer: a document is a detail view
|
||||
reachable from chat or Sources, and the phase-13 back link (row 2)
|
||||
carries the return affordance. The phase-19 single-row bar
|
||||
(.doc-header-actions) is superseded by the two-row layout —
|
||||
.doc-titlebar keeps #doc-back / #doc-title / #doc-meta, so
|
||||
document.js needs no render change."""
|
||||
text = _text(DOCUMENT_HTML)
|
||||
chat = re.search(r'<a[^>]*href="/"[^>]*>Chat</a>', text)
|
||||
assert chat, "the viewer bar carries the standard nav"
|
||||
assert "is-active" not in chat.group(0), "no nav link is current on the viewer"
|
||||
for link in ("nav-sources", "nav-tuning"):
|
||||
tag = re.search(rf'<a[^>]*id="{link}"[^>]*>', text)
|
||||
assert tag, f"the viewer bar must carry #{link}"
|
||||
assert "hidden" in tag.group(0), f"#{link} must ship hidden (admin-only)"
|
||||
assert "is-active" not in tag.group(0)
|
||||
# Row 1 is the standard bar inside the two-row viewer header.
|
||||
assert re.search(r'<header[^>]*class="doc-header"', text), "the header keeps .doc-header"
|
||||
assert 'class="app-header"' in text, "row 1 reuses the .app-header bar"
|
||||
assert 'doc-header-inner' not in text, "the old single-row wrapper is gone"
|
||||
assert 'doc-header-actions' not in text, "the old actions wrapper is gone"
|
||||
# Row 2: the .doc-titlebar keeps the back link + title + meta.
|
||||
assert 'class="doc-titlebar"' in text, "row 2 must be the .doc-titlebar"
|
||||
assert 'id="doc-back"' in text
|
||||
assert 'id="doc-title"' in text
|
||||
assert 'id="doc-meta"' in text
|
||||
back = re.search(r'<a[^>]*id="doc-back"[^>]*href="/sources.html"', text)
|
||||
assert back, "the back link keeps its /sources.html no-JS fallback"
|
||||
|
||||
|
||||
def test_sources_and_viewer_carry_the_shared_controls() -> None:
|
||||
@@ -197,14 +284,31 @@ def test_header_module_loads_before_the_page_script() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_login_page_carries_no_chat_controls() -> None:
|
||||
"""Noted boundary (owner-confirmed): the login page is the auth page,
|
||||
not an app page — no New Chat / Sign in / Sign out controls there;
|
||||
header.js only toggles the Sources link."""
|
||||
def test_login_page_carries_the_full_header() -> None:
|
||||
"""Phase 34 task 03 (owner confirmation 2026-08-26): the noted
|
||||
boundary is reversed by owner decision — the login page finally
|
||||
carries the FULL shared header: the nav gains the #nav-tuning link
|
||||
(same ship-hidden markup as the other pages), plus the Tuning
|
||||
toggle, the admin-only Sync button, New chat, and the Sign in /
|
||||
Sign out pair (ship hidden — initSharedHeader reveals exactly one
|
||||
after whoami; the static ?next= fallback is the login page itself).
|
||||
No nav link is "current" on the auth page."""
|
||||
text = _text(LOGIN_HTML)
|
||||
assert "new-chat-btn" not in text
|
||||
assert "sign-in-link" not in text
|
||||
assert "sign-out-btn" not in text
|
||||
for marker in (
|
||||
'id="nav-sources"',
|
||||
'id="nav-tuning"',
|
||||
'id="steering-toggle"',
|
||||
'id="sync-btn"',
|
||||
'id="new-chat-btn"',
|
||||
'id="sign-in-link"',
|
||||
'id="sign-out-btn"',
|
||||
):
|
||||
assert marker in text, f"login.html must carry {marker} (phase 34 full header)"
|
||||
assert 'href="/login.html?next=/login.html"' in text, (
|
||||
"the login page's Sign in returns to the login page (no-JS fallback)"
|
||||
)
|
||||
for tag in re.findall(r'<a[^>]*class="nav-link[^"]*"[^>]*>', text):
|
||||
assert "is-active" not in tag, "no nav link is current on the login page"
|
||||
|
||||
|
||||
# ---------- page-script adaptations ----------
|
||||
@@ -249,40 +353,206 @@ def test_login_js_uses_the_shared_fetch_is_admin() -> None:
|
||||
assert "window.location.replace(safeNext())" in js
|
||||
|
||||
|
||||
def test_non_chat_pages_bind_new_chat_to_the_chat_page() -> None:
|
||||
"""On sources, the viewer, and the tuning page, New Chat means "go to
|
||||
the chat, fresh": the binding clears the phase-14 key
|
||||
(clearChatStorage) and navigates to "/" — and each page runs
|
||||
initSharedHeader() at boot on the shared cached whoami. Phase 23:
|
||||
def test_new_chat_binding_is_single_and_module_owned() -> None:
|
||||
"""Phase 34 task 02: header.js owns the SINGLE #new-chat-btn binding
|
||||
(module import, like the sign-out binding): on the chat page
|
||||
(#messages exists) it dispatches window "bor:new-chat" — app.js acts
|
||||
through its own in-flight-turn guard + list reset; on every other
|
||||
page it means "go to the chat, fresh" (clearChatStorage + navigate
|
||||
to "/"). NO page script binds #new-chat-btn anymore, and each page
|
||||
still runs initSharedHeader() on the shared cached whoami. Phase 23:
|
||||
the import is relative (`./header.js`)."""
|
||||
js = _text(HEADER_JS)
|
||||
assert 'querySelector("#new-chat-btn")' in js
|
||||
assert "newChatBtn.addEventListener" in js
|
||||
assert 'querySelector("#messages")' in js, "the chat-page branch key"
|
||||
assert 'new CustomEvent("bor:new-chat")' in js
|
||||
assert "clearChatStorage();" in js
|
||||
assert 'window.location.href = "/"' in js
|
||||
for js_file in (SOURCES_JS, DOCUMENT_JS, TUNING_JS):
|
||||
js = _text(js_file)
|
||||
assert 'from "./header.js"' in js
|
||||
assert "initSharedHeader()" in js
|
||||
btn_idx = js.find("new-chat-btn")
|
||||
clear_idx = js.find("clearChatStorage();")
|
||||
nav_idx = js.find('window.location.href = "/"')
|
||||
assert -1 < btn_idx < clear_idx < nav_idx, (
|
||||
f"{js_file.name}: #new-chat-btn must clear storage then navigate to '/'"
|
||||
page_js = _text(js_file)
|
||||
assert 'from "./header.js"' in page_js
|
||||
assert "initSharedHeader()" in page_js
|
||||
assert "new-chat-btn" not in page_js, (
|
||||
f"{js_file.name}: no #new-chat-btn binding (the module owns it)"
|
||||
)
|
||||
assert 'fetch("/api/whoami")' not in js, (
|
||||
assert 'fetch("/api/whoami")' not in page_js, (
|
||||
f"{js_file.name}: whoami goes through the shared cached promise"
|
||||
)
|
||||
|
||||
|
||||
# ---------- viewer-bar CSS ----------
|
||||
|
||||
|
||||
def test_viewer_bar_css_pushes_actions_right_and_title_clips() -> None:
|
||||
"""styles.css defines .doc-header-actions (margin-left:auto flex
|
||||
cluster) and the title block keeps min-width: 0 so
|
||||
#doc-title/#doc-meta clip instead of overflowing the --header-h bar."""
|
||||
css = _text(STYLES_CSS)
|
||||
block = re.search(r"\.doc-header-actions\s*\{([^}]*)\}", css)
|
||||
assert block, "styles.css must define .doc-header-actions"
|
||||
body = block.group(1)
|
||||
assert "margin-left: auto" in body
|
||||
assert "display: flex" in body
|
||||
assert re.search(r"\.doc-title-block\s*\{[^}]*min-width:\s*0", css), (
|
||||
"the title block must keep clipping while the pills fit"
|
||||
app_js = _text(APP_JS)
|
||||
assert 'window.addEventListener("bor:new-chat", startNewChat)' in app_js
|
||||
assert "newChatBtn.addEventListener" not in app_js, (
|
||||
"app.js acts off the module's event, not its own binding"
|
||||
)
|
||||
|
||||
|
||||
def test_init_shared_header_rewrites_sign_in_next_to_current_page() -> None:
|
||||
"""Phase 34 task 02: initSharedHeader points #sign-in-link at
|
||||
/login.html?next=<current pathname> (default "/") — the admin lands
|
||||
back on the page they signed in from. The page markup keeps its own
|
||||
static ?next= as the no-JS fallback."""
|
||||
js = _text(HEADER_JS)
|
||||
fn = js.find("function initSharedHeader")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
# raw pathname (always a query-safe "/…" string — never "//", and ?
|
||||
# # / spaces stay percent-encoded inside it; login.js safeNext
|
||||
# re-validates), same shape as the static markup fallbacks
|
||||
assert '"/login.html?next=" + (window.location.pathname || "/")' in body
|
||||
|
||||
|
||||
# ---------- phase 34 task 01: the steering controls move to the module ----------
|
||||
# (task 02 — the sync machine + New chat + sign-in next — is pinned in
|
||||
# test_sync_button.py / above)
|
||||
|
||||
|
||||
def test_header_module_owns_the_steering_panel() -> None:
|
||||
"""header.js owns the steering panel behavior (moved from app.js in
|
||||
phase 34 task 01): the module-level null-safe element refs, the
|
||||
newest-first textContent render (XSS contract), the labeled
|
||||
per-note delete, the count badge, the announcer, and the toggle
|
||||
binding that runs at module import (like the sign-out binding)."""
|
||||
js = _text(HEADER_JS)
|
||||
for selector in (
|
||||
"#steering-toggle",
|
||||
"#steering-count",
|
||||
"#steering-panel",
|
||||
"#steering-list",
|
||||
"#steering-empty",
|
||||
"#steering-announcer",
|
||||
):
|
||||
assert f'querySelector("{selector}")' in js, f"missing {selector} ref"
|
||||
assert "function renderSteeringPanel" in js
|
||||
assert "text.className = \"steering-note-text\"" in js
|
||||
assert "text.textContent = n.note" in js, (
|
||||
"XSS contract: the note renders via textContent, never innerHTML"
|
||||
)
|
||||
assert 'del.setAttribute("aria-label", `Delete tuning note: ${n.note}`)' in js
|
||||
assert "async function deleteSteeringNote" in js
|
||||
assert 'fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" })' in js
|
||||
assert "steeringToggle.addEventListener" in js, "the toggle binding is module-owned"
|
||||
assert 'setAttribute("aria-expanded"' in js
|
||||
assert "refreshSteering()" in js # re-open refreshes the list
|
||||
|
||||
|
||||
def test_header_module_exports_refresh_and_announce_steering() -> None:
|
||||
"""refreshSteering() (fetch + render; non-2xx / unreachable API →
|
||||
the empty list state) and announceSteering() (the polite live
|
||||
region) are exported for the chat page's per-bubble Tune form.
|
||||
"""
|
||||
js = _text(HEADER_JS)
|
||||
assert "export async function refreshSteering" in js
|
||||
fn = js.find("function refreshSteering")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
assert 'fetch("/api/steering")' in body
|
||||
assert "renderSteeringPanel(notes)" in body
|
||||
assert "export function announceSteering" in js
|
||||
ann = js.find("function announceSteering")
|
||||
assert ann != -1
|
||||
ann_body = js[ann : js.find("\n}", ann)]
|
||||
assert "steeringAnnouncer.textContent = message" in ann_body
|
||||
|
||||
|
||||
def test_init_shared_header_gates_the_steering_surface() -> None:
|
||||
"""Inside initSharedHeader: admin → the list refreshes (count badge
|
||||
right before the panel is ever opened; only when the page ships the
|
||||
panel markup); anonymous → the toggle + panel are REMOVED from the
|
||||
DOM (phase-16 'absent, not hidden') and /api/steering is never
|
||||
fetched."""
|
||||
js = _text(HEADER_JS)
|
||||
fn = js.find("function initSharedHeader")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
assert "if (steeringPanel) refreshSteering();" in body
|
||||
assert "steeringToggle?.remove();" in body
|
||||
assert "steeringPanel?.remove();" in body
|
||||
|
||||
|
||||
def test_app_js_no_longer_owns_the_steering_panel() -> None:
|
||||
"""app.js keeps only the chat-specific per-bubble Tune button +
|
||||
inline form: the panel refs + logic are gone (header.js owns them
|
||||
now), and the form's success path awaits the module's
|
||||
refreshSteering() (announcing through the module's
|
||||
announceSteering()). The form's POST /api/steering + error handling
|
||||
stay in app.js, untouched."""
|
||||
js = _text(APP_JS)
|
||||
for gone in (
|
||||
"steeringToggle",
|
||||
"steeringCount",
|
||||
"steeringPanel",
|
||||
"steeringList",
|
||||
"steeringEmpty",
|
||||
"steeringAnnouncer",
|
||||
"loadSteering",
|
||||
"renderSteeringPanel",
|
||||
"deleteSteeringNote",
|
||||
"setSteeringPanel",
|
||||
'querySelector("#steering-toggle")',
|
||||
):
|
||||
assert gone not in js, f"{gone!r} must be gone from app.js (header.js owns it)"
|
||||
# the chat-specific part survives and is wired to the shared module
|
||||
assert "function appendTuneButton" in js
|
||||
assert "function openTuneForm" in js
|
||||
assert "TUNE_ICON" in js
|
||||
assert 'from "./header.js"' in js
|
||||
assert "refreshSteering" in js and "announceSteering" in js
|
||||
assert "await refreshSteering()" in js
|
||||
assert 'fetch("/api/steering", {' in js # the form's POST still lives here
|
||||
|
||||
|
||||
# ---------- viewer two-row-header CSS (phase 34 task 04) ----------
|
||||
|
||||
|
||||
def test_viewer_two_row_header_css() -> None:
|
||||
"""Phase 34 task 04: the viewer header is two rows — row 1 reuses
|
||||
the .app-header / .header-inner rules verbatim (the pinned
|
||||
--header-h height still applies to row 1), row 2 is the
|
||||
.doc-titlebar: a quiet --line border-top separator, a flex
|
||||
.container row, its own content-sized height. The old single-row
|
||||
.doc-header-actions / .doc-header-inner rules are gone, .doc-header
|
||||
no longer pins a fixed height, and .doc-title-block keeps
|
||||
min-width: 0 so #doc-title / #doc-meta ellipsize in the row."""
|
||||
css = _text(STYLES_CSS)
|
||||
block = re.search(r"\.doc-titlebar\s*\{([^}]*)\}", css)
|
||||
assert block, "styles.css must define .doc-titlebar"
|
||||
body = block.group(1)
|
||||
assert "border-top: 1px solid var(--line)" in body, (
|
||||
"the titlebar separates from row 1 with the quiet hairline"
|
||||
)
|
||||
assert "height: var(--header-h)" not in body, (
|
||||
"the titlebar row is content-sized (title line + meta line)"
|
||||
)
|
||||
assert re.search(r"\.doc-titlebar\s*\.container\s*\{[^}]*display:\s*flex", css), (
|
||||
"the titlebar row must be a flex row (back link + title block)"
|
||||
)
|
||||
assert re.search(r"\.doc-header-actions\s*\{", css) is None, (
|
||||
"the old single-row actions cluster rule is gone"
|
||||
)
|
||||
assert re.search(r"\.doc-header-inner\s*\{", css) is None, (
|
||||
"the old single-row wrapper rule is gone"
|
||||
)
|
||||
header_block = re.search(r"\.doc-header\s*\{([^}]*)\}", css)
|
||||
assert header_block, "styles.css must still style the .doc-header header element"
|
||||
assert "height: var(--header-h)" not in header_block.group(1), (
|
||||
"the two-row header is content-sized — row 1 keeps the pinned height"
|
||||
)
|
||||
assert re.search(r"\.doc-title-block\s*\{[^}]*min-width:\s*0", css), (
|
||||
"the title block must keep clipping while the row stays tidy"
|
||||
)
|
||||
|
||||
|
||||
def test_failed_sync_button_carries_the_error_look() -> None:
|
||||
"""Phase 34 task 04: the #sync-btn now lives on every page, and the
|
||||
non-Sources pages have no error banner — the failed state must be
|
||||
visible on the button itself. header.js adds .is-error (with the
|
||||
sanitized error in title / aria-label); styles.css must render it
|
||||
with the phase-08 error pair (--err-ink on --err-bg ≈9.1:1,
|
||||
--err-line border — never the amber deflection accent)."""
|
||||
css = _text(STYLES_CSS)
|
||||
block = re.search(r"\.sync-btn\.is-error\s*\{([^}]*)\}", css)
|
||||
assert block, "styles.css must define the failed .sync-btn look"
|
||||
body = block.group(1)
|
||||
assert "var(--err-bg)" in body
|
||||
assert "var(--err-ink)" in body
|
||||
assert "var(--accent-" not in body, "the amber deflection accent is never on errors"
|
||||
|
||||
+170
-78
@@ -3,10 +3,13 @@
|
||||
The browser behavior is E2E-covered (tests/e2e/test_sync_button.py,
|
||||
task 03); here we pin the source-level wiring — the anonymous-safe
|
||||
ship-hidden button markup, the header.js admin reveal on the SAME
|
||||
cached whoami (no extra fetch), the sources.js sync state machine
|
||||
(2 s poll, 202 start / 409 adoption / 403 hide, terminal labels,
|
||||
the aria-live result, the single-poll-loop guard, no client-side hard
|
||||
timeout), the §7.4 never-stale CSS (spin + reduced-motion opt-out,
|
||||
cached whoami (no extra fetch), the header.js sync state machine
|
||||
(moved here from sources.js in phase 34 task 02: 2 s poll, 202 start
|
||||
/ 409 adoption / 403 hide, terminal labels, the "bor:sync-status"
|
||||
event with the status object as detail, the single-poll-loop guard, no
|
||||
client-side hard timeout), the Sources page's event-driven
|
||||
#sync-result line + #sync-error-banner, and the §7.4 never-stale CSS
|
||||
(spin + reduced-motion opt-out,
|
||||
disabled state, 44px floor, contrast pair) — so a silent regression is
|
||||
caught without a browser.
|
||||
"""
|
||||
@@ -117,103 +120,114 @@ def test_sources_page_stays_cdn_free() -> None:
|
||||
|
||||
def test_header_reveals_sync_btn_on_the_admin_branch() -> None:
|
||||
"""initSharedHeader reveals #sync-btn in the SAME admin branch as
|
||||
#nav-sources (querySelector + hidden = !admin) — one cached whoami,
|
||||
no extra whoami call; anonymous users never leave the hidden
|
||||
default."""
|
||||
#nav-sources (hidden = !admin) — one cached whoami, no extra
|
||||
whoami call; anonymous users never leave the hidden default. The
|
||||
ref is the module-level one — the state machine shares it."""
|
||||
js = _text(HEADER_JS)
|
||||
assert 'querySelector("#sync-btn")' in js, "module-level #sync-btn ref"
|
||||
fn = js.find("function initSharedHeader")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
assert 'querySelector("#sync-btn")' in body, "#sync-btn must join the admin reveal"
|
||||
assert "syncBtn.hidden = !admin" in body
|
||||
assert "syncBtn.hidden = !admin" in body, "#sync-btn must join the admin reveal"
|
||||
# The reveal must not introduce a second whoami call site.
|
||||
assert js.count('fetch("/api/whoami")') == 1
|
||||
|
||||
|
||||
# ---------- sources.js: the sync state machine ----------
|
||||
# ---------- header.js: the sync state machine (moved here from
|
||||
# ---------- sources.js in phase 34 task 02) ----------
|
||||
|
||||
|
||||
def test_sources_js_calls_the_sync_api() -> None:
|
||||
def test_header_js_owns_the_sync_button_elements() -> None:
|
||||
"""The button refs are module-level and null-safe: #sync-btn,
|
||||
#sync-label, the .sync-icon inside the button — a page without the
|
||||
markup is a complete no-op, exactly like the rest of the module."""
|
||||
js = _text(HEADER_JS)
|
||||
assert 'querySelector("#sync-btn")' in js
|
||||
assert 'querySelector("#sync-label")' in js
|
||||
assert 'syncBtn.querySelector(".sync-icon")' in js
|
||||
|
||||
|
||||
def test_header_js_calls_the_sync_api() -> None:
|
||||
"""The click posts to POST /api/sync and the poll loop GETs
|
||||
/api/sync/status — both through the same-origin API (A10)."""
|
||||
js = _text(SOURCES_JS)
|
||||
js = _text(HEADER_JS)
|
||||
assert 'fetch("/api/sync", { method: "POST" })' in js
|
||||
assert 'fetch("/api/sync/status")' in js
|
||||
|
||||
|
||||
def test_sources_js_polls_every_2000ms() -> None:
|
||||
def test_header_js_polls_every_2000ms() -> None:
|
||||
"""The feedback loop is a 2000 ms poll of the status endpoint,
|
||||
re-scheduled one tick at a time (setTimeout, not setInterval — an
|
||||
in-flight fetch can never overlap the next tick)."""
|
||||
js = _text(SOURCES_JS)
|
||||
js = _text(HEADER_JS)
|
||||
assert "SYNC_POLL_MS = 2000" in js
|
||||
assert "setTimeout(tick, SYNC_POLL_MS)" in js
|
||||
assert "setInterval" not in js
|
||||
|
||||
|
||||
def test_sources_js_adopts_409_and_starts_on_202() -> None:
|
||||
def test_header_js_adopts_409_and_starts_on_202() -> None:
|
||||
"""202 (started) and 409 (a run started elsewhere — e.g. a second
|
||||
tab) both enter the running state and start polling: the UI never
|
||||
starts a second run, it adopts the in-flight one."""
|
||||
js = _text(SOURCES_JS)
|
||||
js = _text(HEADER_JS)
|
||||
assert "r.status === 202 || r.status === 409" in js
|
||||
idx = js.find("r.status === 202 || r.status === 409")
|
||||
branch = js[idx : idx + 200]
|
||||
assert "enterRunningState()" in branch
|
||||
branch = js[idx : idx + 400]
|
||||
assert "enterSyncRunningState()" in branch
|
||||
assert "startSyncPolling()" in branch
|
||||
|
||||
|
||||
def test_sources_js_hides_the_button_on_403() -> None:
|
||||
"""A 403 anywhere (POST or poll) is treated as not-admin: the
|
||||
button hides — defense in depth behind header.js's whoami reveal."""
|
||||
js = _text(SOURCES_JS)
|
||||
for occurrence in re.finditer(r"r\.status === 403", js):
|
||||
window = js[occurrence.start() : occurrence.start() + 400]
|
||||
assert "syncBtn.hidden = true" in window, "every 403 branch must hide the button"
|
||||
def test_header_js_hides_the_button_on_403() -> None:
|
||||
"""A 403 anywhere (POST, the status poll, the load re-attach) is
|
||||
treated as not-admin: the button hides — defense in depth behind
|
||||
the whoami reveal (the primary gate)."""
|
||||
js = _text(HEADER_JS)
|
||||
assert len(re.findall(r"r\.status === 403", js)) >= 3, (
|
||||
"POST, the status poll, and the load re-attach must all handle 403"
|
||||
)
|
||||
assert js.count("syncBtn.hidden = true") >= 3, (
|
||||
"every 403 branch must hide the button"
|
||||
)
|
||||
|
||||
|
||||
def test_sources_js_running_state_is_never_stale() -> None:
|
||||
def test_header_js_running_state_is_never_stale() -> None:
|
||||
"""Entering the running state disables the button, sets aria-busy,
|
||||
spins the icon, and swaps the label to 'Syncing…' (the §7.4
|
||||
feedback while the poll waits)."""
|
||||
js = _text(SOURCES_JS)
|
||||
fn = js.find("function enterRunningState")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
feedback while the poll waits) — and a fresh run starts clean: the
|
||||
previous failure's title / aria-label / .is-error come off NOW,
|
||||
not when the run settles."""
|
||||
js = _text(HEADER_JS)
|
||||
body = _body(js, "enterSyncRunningState")
|
||||
assert "syncBtn.disabled = true" in body
|
||||
assert 'syncBtn.setAttribute("aria-busy", "true")' in body
|
||||
assert 'syncBtn.removeAttribute("title")' in body
|
||||
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
||||
assert "syncBtn.classList.remove(\"is-error\")" in body
|
||||
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
||||
assert '"Syncing…"' in body
|
||||
|
||||
|
||||
def test_sources_js_terminal_states() -> None:
|
||||
def test_header_js_terminal_states() -> None:
|
||||
"""Terminal rendering: success → enabled + 'Synced HH:MM' (local
|
||||
time of finished_at) + the last-result counts ('added' always
|
||||
announced, zero terms omitted — a no-op re-sync reads '0 added ·
|
||||
1 unchanged', never an empty live region) + a live catalog refresh
|
||||
(the KB just changed — never a stale table); failed → enabled +
|
||||
retry-ready 'Sync sources' label + the role='alert' banner with
|
||||
the error; the result is cleared on a failure."""
|
||||
js = _text(SOURCES_JS)
|
||||
time of finished_at); failed → enabled + retry-ready 'Sync sources'
|
||||
label + the sanitized error in the button's title + aria-label +
|
||||
the .is-error class (non-Sources pages: that is where the failure
|
||||
is visible). The counts formatting (fmtSyncResult) lives here and
|
||||
is EXPORTED for the Sources page ('added' always announced, zero
|
||||
terms omitted — a no-op re-sync reads '0 added · 1 unchanged')."""
|
||||
js = _text(HEADER_JS)
|
||||
|
||||
success = _body(js, "applySyncSuccess")
|
||||
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success
|
||||
assert "fmtSyncResult(status.detail)" in success
|
||||
# A successful sync just changed the KB: the catalog re-fetches live
|
||||
# (table / stats / empty state never sit stale under "Synced").
|
||||
assert "loadDocs()" in success
|
||||
|
||||
failure = _body(js, "applySyncFailure")
|
||||
assert 'settleSyncButton("Sync sources")' in failure # retry-ready
|
||||
assert "showSyncError(status.error)" in failure
|
||||
assert "syncBtn.title = error" in failure
|
||||
assert 'syncBtn.setAttribute("aria-label", error)' in failure
|
||||
assert "syncBtn.classList.add(\"is-error\")" in failure
|
||||
assert "sanitizeSyncError(status.error)" in failure
|
||||
|
||||
result = _body(js, "fmtSyncResult")
|
||||
# "added" is the always-announced headline term; "unchanged" covers
|
||||
# the no-op case ("0 added · 1 unchanged"); updated/pruned are
|
||||
# zero-omitted.
|
||||
assert "added" in result and "unchanged" in result
|
||||
assert " · " in result
|
||||
assert "> 0" in result, "zero terms must be omitted"
|
||||
@@ -222,26 +236,38 @@ def test_sources_js_terminal_states() -> None:
|
||||
assert "getHours()" in time and "getMinutes()" in time, "local HH:MM of finished_at"
|
||||
|
||||
|
||||
def test_sources_js_settles_the_button_on_terminal() -> None:
|
||||
"""settleSyncButton re-enables the control, drops aria-busy, and
|
||||
un-spins the icon — the button can never sit disabled after a run
|
||||
reaches a terminal state (failed included: retry-ready)."""
|
||||
js = _text(SOURCES_JS)
|
||||
fn = js.find("function settleSyncButton")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}", fn)]
|
||||
def test_header_js_failed_error_is_sanitized() -> None:
|
||||
"""The button's title/aria-label error is sanitized for the
|
||||
attributes: the server already masks credentials (sync.py
|
||||
_sanitize_error); the module collapses whitespace to a single line
|
||||
and caps the length, and a missing error still names a failure."""
|
||||
js = _text(HEADER_JS)
|
||||
body = _body(js, "sanitizeSyncError")
|
||||
assert "replace(/\\s+/g, \" \")" in body, "single line for the attributes"
|
||||
assert "200" in body, "long errors (chatty git stderr) are capped"
|
||||
assert '"The sync failed."' in body
|
||||
|
||||
|
||||
def test_header_js_settles_the_button_on_terminal() -> None:
|
||||
"""settleSyncButton re-enables the control, drops aria-busy, the
|
||||
title, and the failed affordances, and un-spins the icon — the
|
||||
button can never sit disabled after a run reaches a terminal state
|
||||
(failed included: retry-ready)."""
|
||||
js = _text(HEADER_JS)
|
||||
body = _body(js, "settleSyncButton")
|
||||
assert "syncBtn.disabled = false" in body
|
||||
assert 'syncBtn.removeAttribute("aria-busy")' in body
|
||||
assert 'syncBtn.removeAttribute("title")' in body
|
||||
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
||||
assert "syncIcon.classList.remove(\"is-spinning\")" in body
|
||||
|
||||
|
||||
def test_sources_js_never_starts_a_second_poll_loop() -> None:
|
||||
def test_header_js_never_starts_a_second_poll_loop() -> None:
|
||||
"""startSyncPolling is guarded by the module-level timer: a 409
|
||||
adoption, a reload re-attach, or a stray call can never run two
|
||||
poll loops at once (phase completion criterion)."""
|
||||
js = _text(SOURCES_JS)
|
||||
js = _text(HEADER_JS)
|
||||
fn = js.find("function startSyncPolling")
|
||||
assert fn != -1
|
||||
head = js[fn : js.find("const tick", fn)]
|
||||
assert re.search(r"if\s*\(\s*syncPollTimer\s*!==\s*null\s*\)\s*return", head), (
|
||||
"the single-loop guard must be the first statement"
|
||||
@@ -249,37 +275,103 @@ def test_sources_js_never_starts_a_second_poll_loop() -> None:
|
||||
assert "clearTimeout(syncPollTimer)" in _body(js, "stopSyncPolling")
|
||||
|
||||
|
||||
def test_sources_js_has_no_client_side_hard_timeout() -> None:
|
||||
"""Phase locked decision: a sync can legitimately run for minutes,
|
||||
so there is NO client-side hard timeout — the 2 s poll is the
|
||||
feedback loop and the server state is authoritative (the 120 s
|
||||
LLM-turn guard must not leak into the sync path)."""
|
||||
js = _text(SOURCES_JS)
|
||||
def test_header_js_has_no_client_side_hard_timeout() -> None:
|
||||
"""Phase locked decision: a sync can legitimately run for minutes
|
||||
(and outlive the page), so there is NO client-side hard timeout —
|
||||
the 2 s poll is the feedback loop and the server state is
|
||||
authoritative (the 120 s LLM-turn guard must not leak into the
|
||||
sync path)."""
|
||||
js = _text(HEADER_JS)
|
||||
assert "TURN_TIMEOUT" not in js
|
||||
assert "120" not in js[js.find("Phase 32") :], (
|
||||
"no turn-timeout constant in the sync section"
|
||||
)
|
||||
sync_start = js.find("sync sources (phase 32")
|
||||
assert sync_start != -1, "the sync section marker comment"
|
||||
assert "120" not in js[sync_start:]
|
||||
|
||||
|
||||
def test_sources_js_reattaches_on_load() -> None:
|
||||
"""initSyncButton (run from the IIFE on the admin path, after
|
||||
initSharedHeader) fetches the status once and re-enters the running
|
||||
state on 'running' (reload mid-sync) or renders the last result on
|
||||
a terminal state; the click binding wires startSync to the button."""
|
||||
js = _text(SOURCES_JS)
|
||||
def test_header_js_reattaches_on_load_admin_only() -> None:
|
||||
"""initSyncButton (run at module import, button pages only) awaits
|
||||
the SAME cached whoami — ADMIN ONLY (non-admins never poll, the
|
||||
status endpoint is admin-only): a running run re-enters the
|
||||
running state (reload mid-sync), a terminal run renders its last
|
||||
result, idle settles retry-ready; the click binding wires
|
||||
startSync to the button."""
|
||||
js = _text(HEADER_JS)
|
||||
fn = js.find("function initSyncButton")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("\n}\n", fn)]
|
||||
assert "await fetchIsAdmin()" in body, "admin-only boot (no extra fetch)"
|
||||
assert 'fetch("/api/sync/status")' in body
|
||||
assert 'status.state === "running"' in body
|
||||
assert 'status.state === "success"' in body
|
||||
assert 'status.state === "failed"' in body
|
||||
assert "syncBtn.addEventListener(\"click\", startSync)" in js
|
||||
# The IIFE runs it on the admin path only (after the whoami gate).
|
||||
iife = js[js.find("(async () => {") :]
|
||||
admin_idx = iife.find("await isAdmin()")
|
||||
init_idx = iife.find("initSyncButton();")
|
||||
assert -1 < admin_idx < init_idx, "re-attach must run only for the admin"
|
||||
tail = js[js.rfind("if (syncBtn)") :]
|
||||
assert "initSyncButton()" in tail, "boot re-attach runs at module import"
|
||||
|
||||
|
||||
def test_header_js_emits_bor_sync_status_on_state_changes() -> None:
|
||||
"""Every state change dispatches window 'bor:sync-status' with the
|
||||
status object as detail — the channel the Sources page's
|
||||
banner/result line subscribe to. The click path emits the
|
||||
synthetic running frame IMMEDIATELY (no 2 s poll lag — the exact
|
||||
old enterRunningState clear behavior, now event-driven)."""
|
||||
js = _text(HEADER_JS)
|
||||
assert (
|
||||
'window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }))'
|
||||
in js
|
||||
)
|
||||
for fn in ("applySyncSuccess", "applySyncFailure", "applySyncIdle"):
|
||||
assert "emitSyncStatus" in _body(js, fn), f"{fn} must emit its frame"
|
||||
# the running frame: synthetic on click, the real object on boot
|
||||
assert 'emitSyncStatus({ state: "running" })' in js
|
||||
assert "emitSyncStatus(status)" in _body(js, "initSyncButton")
|
||||
|
||||
|
||||
# ---------- sources.js: the event-driven result line + banner ----------
|
||||
|
||||
|
||||
def test_sources_js_renders_off_the_sync_status_event() -> None:
|
||||
"""The Sources page keeps ONLY its page-specific rendering:
|
||||
#sync-result (aria-live) + #sync-error-banner (role=alert), driven
|
||||
by the module's 'bor:sync-status' event (detail = the status
|
||||
object): running → clear + hide; success → the counts (the
|
||||
imported fmtSyncResult) + a live catalog refresh; failed → the
|
||||
banner with the error; idle → hide + clear."""
|
||||
js = _text(SOURCES_JS)
|
||||
assert 'window.addEventListener("bor:sync-status"' in js
|
||||
assert 'status.state === "running"' in js
|
||||
assert 'status.state === "success"' in js
|
||||
assert 'status.state === "failed"' in js
|
||||
assert "fmtSyncResult(status.detail)" in js
|
||||
assert "loadDocs()" in js, "the catalog re-fetches live on a successful sync"
|
||||
assert "showSyncError(status.error)" in js
|
||||
assert "syncResult.textContent" in js
|
||||
|
||||
|
||||
def test_sources_js_no_longer_owns_the_sync_machine() -> None:
|
||||
"""The state machine is GONE from sources.js (header.js owns it):
|
||||
no button refs, no POST, no status poll, no button-state helpers,
|
||||
no click binding, no load re-attach."""
|
||||
js = _text(SOURCES_JS)
|
||||
for gone in (
|
||||
"SYNC_POLL_MS",
|
||||
"syncPollTimer",
|
||||
"startSyncPolling",
|
||||
"stopSyncPolling",
|
||||
"enterRunningState",
|
||||
"settleSyncButton",
|
||||
"applySyncSuccess",
|
||||
"applySyncFailure",
|
||||
"applySyncIdle",
|
||||
"initSyncButton",
|
||||
"startSync",
|
||||
'fetch("/api/sync", { method: "POST" })',
|
||||
'fetch("/api/sync/status")',
|
||||
'querySelector("#sync-btn")',
|
||||
'querySelector("#sync-label")',
|
||||
'querySelector(".sync-icon")',
|
||||
):
|
||||
assert gone not in js, f"{gone!r} must be gone from sources.js (header.js owns it)"
|
||||
|
||||
|
||||
# ---------- styles.css: the §7.4 states ----------
|
||||
|
||||
Reference in New Issue
Block a user