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