various fixes
This commit is contained in:
@@ -46,9 +46,8 @@
|
||||
* adds, and removals.
|
||||
*
|
||||
* Scope boundary (phase locked decisions): adding or removing a repo
|
||||
* does NOT clone, import, or prune — the header's Sync sources button
|
||||
* (module-owned in assets/header.js) performs that; the page's hint
|
||||
* box says so.
|
||||
* does NOT clone, import, or prune — the sync service (server-side)
|
||||
* performs that; the page's hint box says so.
|
||||
*
|
||||
* The shared header module loads through this script's own relative
|
||||
* import ("./header.js") — a hoisted import evaluated before this body
|
||||
|
||||
+57
-476
@@ -16,9 +16,6 @@
|
||||
* "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 "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 mobile hamburger binding (phase 46, owner permission
|
||||
@@ -33,43 +30,28 @@
|
||||
* page without either element is a no-op. The binding toggles
|
||||
* ONLY the container — the nav links keep their ship-hidden
|
||||
* whoami contract (hidden links stay hidden inside the menu);
|
||||
* • 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.
|
||||
* The toggle SHIPS hidden in every page (phase 40, 2026-08-27,
|
||||
* TODO.md L3 — the exact ship-hidden / reveal-for-admin contract
|
||||
* the admin-only nav links use: anonymous never sees it for a
|
||||
* single frame) and initSharedHeader unhides it only when whoami
|
||||
* says admin. 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; the button title/aria are the
|
||||
* secondary failure surfaces — and a failed run ALSO opens the
|
||||
* module-owned error modal (phase 41, 2026-08-27, TODO.md L4, the
|
||||
* primary readable failure surface): lazily built by this module,
|
||||
* appended to <body>, error text textContent-rendered, closable via
|
||||
* its button / Esc / backdrop, focus in-and-out to #sync-btn —
|
||||
* every page carrying #sync-btn gets it with zero page-markup
|
||||
* changes;
|
||||
* • the steering-notes panel (phase 15, moved here from app.js in
|
||||
* phase 34) — the #steering-panel list (newest-first,
|
||||
* textContent-rendered, per-note delete) + the #steering-announcer
|
||||
* live region — so every page that ships the panel markup gets
|
||||
* exactly this behavior with zero page-script duplication. The
|
||||
* navbar #steering-toggle was REMOVED at owner request
|
||||
* (2026-08-28) — note management now lives on /tuning.html — so
|
||||
* the panel ships hidden on every page and is only kept fresh,
|
||||
* never opened from the header. 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 (panel removed from the
|
||||
* DOM, /api/steering never fetched);
|
||||
* • 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 "/";
|
||||
* duplicated across app.js / sources.js / tuning.js / document.js;
|
||||
* moved from the navbar to the chat page at owner request):
|
||||
* the button now lives ONLY on the chat page (inside .chat-shell,
|
||||
* above #messages). When clicked it dispatches window
|
||||
* "bor:new-chat" and app.js acts (it owns the in-flight-turn
|
||||
* guard + the list reset).
|
||||
* • clearChatStorage() — the phase-14 conversation key, for clearing
|
||||
* the conversation when navigating away from the chat page.
|
||||
* • 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
|
||||
@@ -129,8 +111,7 @@ export async function initSharedHeader() {
|
||||
signIn.hidden = admin;
|
||||
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
|
||||
}
|
||||
const signOut = document.querySelector("#sign-out-btn");
|
||||
if (signOut) signOut.hidden = !admin;
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; });
|
||||
const navSources = document.querySelector("#nav-sources");
|
||||
if (navSources) navSources.hidden = !admin;
|
||||
// Phase 35 (owner permission 2026-08-26): the Git sources nav link —
|
||||
@@ -143,22 +124,15 @@ export async function initSharedHeader() {
|
||||
// contract as the Sources link.
|
||||
const navTuning = document.querySelector("#nav-tuning");
|
||||
if (navTuning) navTuning.hidden = !admin;
|
||||
// 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:
|
||||
// unhide the toggle — it SHIPS hidden (phase 40, 2026-08-27, TODO.md
|
||||
// L3, the same ship-hidden / reveal-for-admin contract as the
|
||||
// admin-only nav links) — then 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.
|
||||
// Phase 34: the steering panel (phase 15) is module-owned. The
|
||||
// navbar #steering-toggle was removed at owner request (2026-08-28)
|
||||
// — the panel ships hidden and is only kept fresh. Admin: refresh
|
||||
// the list (fire-and-forget). Anonymous: the panel is REMOVED from
|
||||
// the DOM entirely — the phase-16 contract says "absent", not just
|
||||
// hidden — and /api/steering is never fetched.
|
||||
if (admin) {
|
||||
if (steeringToggle) steeringToggle.hidden = false;
|
||||
if (steeringPanel) refreshSteering();
|
||||
} else {
|
||||
steeringToggle?.remove();
|
||||
steeringPanel?.remove();
|
||||
}
|
||||
return admin;
|
||||
@@ -177,13 +151,14 @@ export function clearChatStorage() {
|
||||
|
||||
/* Sign-out binding (phase 16 behavior, now module-owned): runs at module
|
||||
import, so every page that loads header.js gets it exactly once.
|
||||
Disable during the call, POST /api/logout (the result is ignored —
|
||||
the reload resets the UI either way), then reload so the header
|
||||
re-resolves to the anonymous state (Sign in back, Sources gone). */
|
||||
const signOutBtn = document.querySelector("#sign-out-btn");
|
||||
if (signOutBtn) {
|
||||
signOutBtn.addEventListener("click", async () => {
|
||||
signOutBtn.disabled = true;
|
||||
Binds to all .sign-out-btn elements (bar copy for desktop + mobile
|
||||
dropdown copy for ≤640px). Disable during the call, POST /api/logout
|
||||
(the result is ignored — the reload resets the UI either way), then
|
||||
reload so the header re-resolves to the anonymous state (Sign in
|
||||
back, Sources gone). */
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await fetch("/api/logout", { method: "POST" });
|
||||
} catch {
|
||||
@@ -191,7 +166,7 @@ if (signOutBtn) {
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- mobile hamburger (phase 46; module-owned) ----------
|
||||
* ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the
|
||||
@@ -216,8 +191,8 @@ if (navToggle && appNav) {
|
||||
appNav.addEventListener("click", (e) => {
|
||||
if (e.target.closest("a")) setNavMenu(false);
|
||||
});
|
||||
// Esc closes while open (document-level; the sync failure modal's
|
||||
// Esc acts only while IT is open — the two never fight for a key).
|
||||
// Esc closes while open (document-level — no other modal to fight
|
||||
// for a key).
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && appNav.classList.contains("is-open")) {
|
||||
setNavMenu(false);
|
||||
@@ -237,10 +212,12 @@ if (navToggle && appNav) {
|
||||
*
|
||||
* 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
|
||||
* system prompt. The header panel — list, per-note delete, announcer —
|
||||
* is owned by THIS module: every page that ships the panel markup gets
|
||||
* exactly this behavior, with zero page-script duplication. The navbar
|
||||
* #steering-toggle was removed at owner request (2026-08-28) — note
|
||||
* management now lives on /tuning.html — so the panel ships hidden and
|
||||
* is only kept fresh. The chat page keeps only its per-bubble Tune form
|
||||
* (app.js), which refreshes the panel through refreshSteering() and
|
||||
* announces through announceSteering().
|
||||
*
|
||||
@@ -248,8 +225,6 @@ if (navToggle && appNav) {
|
||||
* 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");
|
||||
@@ -263,10 +238,10 @@ export function announceSteering(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. */
|
||||
Tune form calls it on save, so the panel updates without owning the
|
||||
fetch itself). Non-2xx (the anonymous 403) or an unreachable API
|
||||
render the empty state: the "no notes yet" text visible — the safe
|
||||
fallback in either case. */
|
||||
export async function refreshSteering() {
|
||||
let notes = [];
|
||||
try {
|
||||
@@ -281,8 +256,7 @@ export async function refreshSteering() {
|
||||
|
||||
/* 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. */
|
||||
aria-label, and the empty text toggled on notes.length. */
|
||||
function renderSteeringPanel(notes) {
|
||||
if (!steeringList) return;
|
||||
steeringList.textContent = "";
|
||||
@@ -304,7 +278,6 @@ function renderSteeringPanel(notes) {
|
||||
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
|
||||
@@ -334,412 +307,20 @@ async function deleteSteeringNote(id, btn) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 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) ----------
|
||||
* task 02; moved from navbar to chat page at owner request) ----------
|
||||
*
|
||||
* 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).
|
||||
* tuning.js / document.js. It lives here exactly once (module import,
|
||||
* like the sign-out binding). The button now lives ONLY on the chat
|
||||
* page (inside .chat-shell, above #messages), so the click always
|
||||
* dispatches "bor:new-chat" — app.js acts (it owns the in-flight-turn
|
||||
* guard and the rendered-list reset).
|
||||
*/
|
||||
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 = "/";
|
||||
window.dispatchEvent(new CustomEvent("bor:new-chat"));
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 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 + the module-owned error modal (phase 41,
|
||||
* 2026-08-27, TODO.md L4 — the primary readable failure
|
||||
* surface on every page; the button affordance and the
|
||||
* Sources banner stay the secondary surfaces).
|
||||
*
|
||||
* 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);
|
||||
// Phase 41 (2026-08-27, TODO.md L4): the module-owned error modal —
|
||||
// the primary readable failure surface (the button title/aria and the
|
||||
// Sources banner above stay as the secondary surfaces).
|
||||
showSyncModal(error);
|
||||
}
|
||||
|
||||
/* ---------- sync failure modal (phase 41, 2026-08-27, TODO.md L4) ----------
|
||||
*
|
||||
* A tooltip on the button is not a readable error — a failed sync ALSO
|
||||
* opens a modal dialog. It is built by THIS module (the owner of the
|
||||
* sync state machine), so every page carrying #sync-btn gets it with
|
||||
* zero page-markup changes: created lazily ONCE (module-level
|
||||
* `syncModal`) and appended to <body> — a .sync-modal-backdrop (fixed,
|
||||
* full-viewport dim) holding the .sync-modal panel
|
||||
* (role="alertdialog", aria-modal, labelled + described). The error
|
||||
* text is ALWAYS set via textContent (XSS-safe — never innerHTML with
|
||||
* user data); a second failure while open updates the text IN PLACE
|
||||
* (no stacking). Closes via the close button, Esc (ONE document
|
||||
* keydown binding, acting only while open), or a click on the backdrop
|
||||
* itself (never the panel); focus moves to the close button on open
|
||||
* and back to the remembered element (#sync-btn — the control that
|
||||
* started the run) on close. Null-safe: no #sync-btn (or no <body>) →
|
||||
* no modal, exactly like the rest of this module.
|
||||
*/
|
||||
let syncModal = null; // the backdrop element — created once, lazily
|
||||
let syncModalReturnFocus = null; // the element to refocus on close
|
||||
|
||||
function createSyncModal() {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "sync-modal-backdrop";
|
||||
// Static skeleton — no user data anywhere in it; the error text is
|
||||
// filled via textContent in showSyncModal, never interpolated here.
|
||||
backdrop.innerHTML =
|
||||
'<div class="sync-modal" role="alertdialog" aria-modal="true" ' +
|
||||
'aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">' +
|
||||
'<h2 id="sync-modal-title">Sync failed</h2>' +
|
||||
'<p id="sync-modal-error"></p>' +
|
||||
'<button type="button" class="sync-modal-close" aria-label="Close error dialog">\u00d7</button>' +
|
||||
"</div>";
|
||||
document.body.appendChild(backdrop);
|
||||
// Close path 1: the close button (×).
|
||||
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
|
||||
// Close path 2: a click on the backdrop element itself — never one
|
||||
// that bubbles up from the panel (event.target check).
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) closeSyncModal();
|
||||
});
|
||||
// Close path 3: Esc — ONE document-level keydown binding for the
|
||||
// life of the page, acting only while the modal is open.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
|
||||
});
|
||||
return backdrop;
|
||||
}
|
||||
|
||||
function showSyncModal(error) {
|
||||
if (!syncBtn || !document.body) return; // null-safe: pages without the button
|
||||
if (!syncModal) syncModal = createSyncModal();
|
||||
// The sanitized error as TEXT (XSS-safe) — a second failure while
|
||||
// open updates the text in place (no stacking, no focus jump).
|
||||
syncModal.querySelector("#sync-modal-error").textContent = error;
|
||||
if (syncModal.classList.contains("is-open")) return;
|
||||
// First open: remember the focused element and move focus into the
|
||||
// dialog (the close button). While the run was in flight the button
|
||||
// was disabled (focus had fallen to <body>), so a body-level active
|
||||
// element means "no meaningful focus target" — remember #sync-btn,
|
||||
// the control that started the run, so the close returns focus there.
|
||||
const active = document.activeElement;
|
||||
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
|
||||
syncModal.classList.add("is-open");
|
||||
syncModal.querySelector(".sync-modal-close").focus();
|
||||
}
|
||||
|
||||
function closeSyncModal() {
|
||||
if (!syncModal || !syncModal.classList.contains("is-open")) return;
|
||||
syncModal.classList.remove("is-open");
|
||||
// Focus returns to the remembered element — #sync-btn when present.
|
||||
const target = syncModalReturnFocus;
|
||||
syncModalReturnFocus = null;
|
||||
if (target && document.contains(target)) target.focus();
|
||||
}
|
||||
|
||||
/* 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)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,8 @@ form.addEventListener("submit", async (e) => {
|
||||
* 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. */
|
||||
* stay hidden, the steering panel removed (the navbar toggle no longer
|
||||
* ships at all — removed at owner request, 2026-08-28). */
|
||||
(async () => {
|
||||
const admin = await alreadySignedIn();
|
||||
await initSharedHeader();
|
||||
|
||||
+275
-68
@@ -10,12 +10,9 @@
|
||||
* 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 "/").
|
||||
* (assets/header.js), including 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
|
||||
@@ -27,7 +24,278 @@
|
||||
* tag; esbuild inlines it into the page bundle).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin, fmtSyncResult, initSharedHeader } from "./header.js";
|
||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
|
||||
/* ---------- Sync sources button (Sources page only) ----------
|
||||
*
|
||||
* The §7.4 never-stale lifecycle: idle → click → POST /api/sync
|
||||
* → running (2 s poll of GET /api/sync/status) → success | failed.
|
||||
* Admin-only: the button is hidden for anonymous users (initSharedHeader
|
||||
* hides it). A failed run opens an error modal (same as the former
|
||||
* header.js module — recreated here since the navbar button is gone).
|
||||
*
|
||||
* Elements: #sync-btn (the button), #sync-label (the text),
|
||||
* #sync-icon (the spinner icon), #sync-result (aria-live result
|
||||
* line), #sync-error-banner / #sync-error-text (error banner).
|
||||
*/
|
||||
const syncBtn = document.querySelector("#sync-btn");
|
||||
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
||||
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;
|
||||
let syncPollTimer = null;
|
||||
let lastSyncState = null;
|
||||
|
||||
function emitSyncStatus(status) {
|
||||
lastSyncState = status.state;
|
||||
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
|
||||
}
|
||||
|
||||
function stopSyncPolling() {
|
||||
if (syncPollTimer !== null) {
|
||||
clearTimeout(syncPollTimer);
|
||||
syncPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
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())}`;
|
||||
}
|
||||
|
||||
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 sanitizeSyncError(message) {
|
||||
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
|
||||
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||||
}
|
||||
|
||||
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…";
|
||||
}
|
||||
|
||||
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 showSyncError(detail) {
|
||||
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
|
||||
if (syncErrorBanner) syncErrorBanner.hidden = false;
|
||||
}
|
||||
|
||||
function hideSyncError() {
|
||||
if (syncErrorText) syncErrorText.textContent = "";
|
||||
if (syncErrorBanner) syncErrorBanner.hidden = true;
|
||||
}
|
||||
|
||||
/* ---------- sync failure modal (recreated here since the navbar button is gone) ---------- */
|
||||
let syncModal = null;
|
||||
let syncModalReturnFocus = null;
|
||||
|
||||
function createSyncModal() {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "sync-modal-backdrop";
|
||||
backdrop.innerHTML =
|
||||
'<div class="sync-modal" role="alertdialog" aria-modal="true" ' +
|
||||
'aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">' +
|
||||
'<h2 id="sync-modal-title">Sync failed</h2>' +
|
||||
'<p id="sync-modal-error"></p>' +
|
||||
'<button type="button" class="sync-modal-close" aria-label="Close error dialog">\u00d7</button>' +
|
||||
"</div>";
|
||||
document.body.appendChild(backdrop);
|
||||
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) closeSyncModal();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
|
||||
});
|
||||
return backdrop;
|
||||
}
|
||||
|
||||
function showSyncModal(error) {
|
||||
if (!syncBtn || !document.body) return;
|
||||
if (!syncModal) syncModal = createSyncModal();
|
||||
syncModal.querySelector("#sync-modal-error").textContent = error;
|
||||
if (syncModal.classList.contains("is-open")) return;
|
||||
const active = document.activeElement;
|
||||
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
|
||||
syncModal.classList.add("is-open");
|
||||
syncModal.querySelector(".sync-modal-close").focus();
|
||||
}
|
||||
|
||||
function closeSyncModal() {
|
||||
if (!syncModal || !syncModal.classList.contains("is-open")) return;
|
||||
syncModal.classList.remove("is-open");
|
||||
const target = syncModalReturnFocus;
|
||||
syncModalReturnFocus = null;
|
||||
if (target && document.contains(target)) target.focus();
|
||||
}
|
||||
|
||||
function applySyncSuccess(status) {
|
||||
const time = fmtSyncTime(status.finished_at);
|
||||
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
||||
if (syncResult) syncResult.textContent = fmtSyncResult(status);
|
||||
hideSyncError();
|
||||
emitSyncStatus(status);
|
||||
// Refresh the catalog live — the KB just changed.
|
||||
loadDocs();
|
||||
}
|
||||
|
||||
function applySyncFailure(status) {
|
||||
const error = sanitizeSyncError(status.error);
|
||||
settleSyncButton("Sync sources");
|
||||
if (syncBtn) {
|
||||
syncBtn.title = error;
|
||||
syncBtn.setAttribute("aria-label", error);
|
||||
syncBtn.classList.add("is-error");
|
||||
}
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
showSyncError(error);
|
||||
emitSyncStatus(status);
|
||||
showSyncModal(error);
|
||||
}
|
||||
|
||||
function applySyncIdle(status) {
|
||||
settleSyncButton("Sync sources");
|
||||
emitSyncStatus(status || { state: "idle" });
|
||||
}
|
||||
|
||||
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 — retry next tick */ }
|
||||
if (notAdmin) {
|
||||
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") {
|
||||
stopSyncPolling();
|
||||
applySyncIdle(status);
|
||||
return;
|
||||
}
|
||||
// Still running: keep button state honest and re-schedule.
|
||||
enterSyncRunningState();
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
};
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
}
|
||||
|
||||
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();
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
hideSyncError();
|
||||
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
|
||||
startSyncPolling();
|
||||
return;
|
||||
}
|
||||
let detail = "";
|
||||
try { detail = (await r.json()).detail || ""; } catch { /* non-JSON */ }
|
||||
applySyncFailure({
|
||||
state: "failed",
|
||||
error: detail || `The server refused to start the sync (${r.status}).`,
|
||||
});
|
||||
}
|
||||
|
||||
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running state,
|
||||
* a terminal run renders its last result. */
|
||||
async function initSyncButton() {
|
||||
if (!syncBtn) return;
|
||||
if (!(await fetchIsAdmin())) return;
|
||||
let status;
|
||||
try {
|
||||
const r = await fetch("/api/sync/status");
|
||||
if (r.status === 403) { syncBtn.hidden = true; return; }
|
||||
if (!r.ok) return;
|
||||
status = await r.json();
|
||||
} catch { return; }
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (syncBtn) {
|
||||
syncBtn.addEventListener("click", startSync);
|
||||
initSyncButton();
|
||||
}
|
||||
|
||||
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
||||
|
||||
const tbody = document.querySelector("#docs-tbody");
|
||||
@@ -141,65 +409,6 @@ function showEmpty() {
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
}
|
||||
|
||||
/* ---------- Phase 32 (state machine module-owned from phase 34
|
||||
* task 02): the #sync-result line + #sync-error-banner ----------
|
||||
*
|
||||
* 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):
|
||||
*
|
||||
* 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 syncResult = document.querySelector("#sync-result");
|
||||
const syncErrorBanner = document.querySelector("#sync-error-banner");
|
||||
const syncErrorText = document.querySelector("#sync-error-text");
|
||||
|
||||
function showSyncError(detail) {
|
||||
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
|
||||
if (syncErrorBanner) syncErrorBanner.hidden = false;
|
||||
}
|
||||
|
||||
function hideSyncError() {
|
||||
if (syncErrorText) syncErrorText.textContent = "";
|
||||
if (syncErrorBanner) syncErrorBanner.hidden = true;
|
||||
}
|
||||
|
||||
window.addEventListener("bor:sync-status", (e) => {
|
||||
const status = e.detail || {};
|
||||
if (status.state === "running") {
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
hideSyncError();
|
||||
} else if (status.state === "success") {
|
||||
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
|
||||
hideSyncError();
|
||||
// The KB just changed — refresh the catalog live so the table,
|
||||
// stats, and empty state never sit stale under the "Synced" label
|
||||
// (the sync is the page's own action; a reload should not be
|
||||
// needed to see it).
|
||||
loadDocs();
|
||||
} else if (status.state === "failed") {
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
showSyncError(status.error);
|
||||
} else {
|
||||
// idle
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
hideSyncError();
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar
|
||||
@@ -213,6 +422,4 @@ window.addEventListener("bor:sync-status", (e) => {
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
loadDocs();
|
||||
// Phase 34 task 02: the sync re-attach is module-owned (header.js
|
||||
// boots it on the same cached whoami) — nothing to start here.
|
||||
})();
|
||||
|
||||
+196
-233
@@ -1,27 +1,27 @@
|
||||
/* ==========================================================================
|
||||
Brain of Reese — design system (no CDN; system fonts only)
|
||||
Dark tech theme (phase 08): emoji-free chrome, subtle animated pure-CSS
|
||||
Dark red theme (phase 08): emoji-free chrome, subtle animated pure-CSS
|
||||
background, WCAG 2.1 AA dark palette (every pair computed >= 4.5:1).
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
|
||||
--bg: #0a0e17; /* page: ink on bg 16.2:1 */
|
||||
--surface: #121a2e; /* ink on surface 14.5:1 */
|
||||
--ink: #e8ebf4;
|
||||
--ink-soft: #9aa4bd; /* 6.9:1 on --surface */
|
||||
--line: #26304a; /* decorative 1px borders */
|
||||
--brand: #6d78f2; /* text on brand is DARK ink (--bg): 5.2:1 —
|
||||
--bg: #0f0a0a; /* page: ink on bg 16.7:1 */
|
||||
--surface: #1a0f0f; /* ink on surface 13.8:1 */
|
||||
--ink: #f0e6e6;
|
||||
--ink-soft: #b8a8a8; /* 5.1:1 on --surface */
|
||||
--line: #2d1a1a; /* decorative 1px borders */
|
||||
--brand: #f43f5e; /* text on brand is DARK ink (--bg): 5.2:1 —
|
||||
never white on brand (3.7:1, fails) */
|
||||
--brand-soft: #232b52;
|
||||
--brand-ink: #a5b4fc; /* 8.7:1 on --surface, 6.9:1 on --brand-soft */
|
||||
--brand-soft: #2d0a0a;
|
||||
--brand-ink: #fca5a5; /* 9.0:1 on --surface, 12.4:1 on --brand-soft */
|
||||
--accent-bg: #2b2110;
|
||||
--accent-ink: #fbbf24; /* 9.5:1 on --accent-bg */
|
||||
--accent-line: #f59e0b; /* 8.9:1 on --bg (deflection border) */
|
||||
--ok-bg: #10241b;
|
||||
--ok-ink: #6ee7a8; /* 10.6:1 on --ok-bg */
|
||||
--err-bg: #2d1318;
|
||||
--err-ink: #fca5a5; /* 9.1:1 on --err-bg */
|
||||
--err-bg: #2d0a0a;
|
||||
--err-ink: #fca5a5; /* 9.3:1 on --err-bg */
|
||||
--err-line: #ef4444; /* 4.6:1 on --err-bg (UI boundary, not text) */
|
||||
|
||||
--radius: 10px;
|
||||
@@ -95,8 +95,8 @@ body::before {
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, rgb(38 48 74 / 0.6) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgb(38 48 74 / 0.6) 1px, transparent 1px);
|
||||
linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
-webkit-mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
|
||||
mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
|
||||
@@ -110,7 +110,7 @@ body::after {
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), transparent 62%);
|
||||
background-image: radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), transparent 62%);
|
||||
animation: bg-glow-a 26s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ html::before {
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), transparent 62%);
|
||||
background-image: radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), transparent 62%);
|
||||
animation: bg-glow-b 34s ease-in-out -12s infinite;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ html::after {
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle 52rem at 14% 86%, rgb(109 120 242 / 0.09), transparent 62%);
|
||||
background-image: radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), transparent 62%);
|
||||
animation: bg-glow-c 42s ease-in-out -23s infinite;
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ html::after {
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgb(109 120 242 / 0.45);
|
||||
background: rgb(244 63 94 / 0.45);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@@ -217,9 +217,9 @@ html::after {
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(109 120 242 / 0.55),
|
||||
rgb(34 211 238 / 0.30) 45%,
|
||||
rgb(34 211 238 / 0.05) 90%
|
||||
rgb(244 63 94 / 0.55),
|
||||
rgb(251 146 60 / 0.30) 45%,
|
||||
rgb(251 191 36 / 0.05) 90%
|
||||
);
|
||||
}
|
||||
/* margin-left:auto on the nav (not justify-content:space-between) so the
|
||||
@@ -294,16 +294,16 @@ html::after {
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
border: 0;
|
||||
background: var(--brand);
|
||||
color: var(--bg);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.new-chat-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
.new-chat-btn:hover { background: #f55a72; color: var(--bg); }
|
||||
/* The plus mark is hidden on desktop (label carries the pill); it is the
|
||||
whole control below 640px. */
|
||||
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
|
||||
@@ -335,178 +335,10 @@ html::after {
|
||||
.auth-link:disabled { opacity: 0.6; cursor: wait; }
|
||||
.auth-link svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* Phase 32: the admin-only "Sync sources" pill (Sources header) — the
|
||||
same ghost pill as New chat / the auth links, so the bar keeps one
|
||||
visual language. ink-soft on surface ≈6.9:1 (WCAG AA); hover pair
|
||||
brand-ink/brand-soft ≈6.9:1. The refresh icon is always visible (it
|
||||
doubles as the running-state spinner); icon-only below 640px like
|
||||
the other pills (aria-label keeps the accessible name). ≥44px touch
|
||||
target at every width; :focus-visible via the global rule. */
|
||||
.sync-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.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. */
|
||||
.sync-btn .sync-icon.is-spinning { animation: spin 1s linear infinite; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sync-btn .sync-icon.is-spinning { animation: none; }
|
||||
}
|
||||
/* The aria-live last-result announcer ("2 added · 1 pruned") — soft ink
|
||||
on the header surface (≈6.9:1), small mono to match the stat cards. */
|
||||
.sync-result {
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Phase 41 (2026-08-27, TODO.md L4): the module-owned sync failure
|
||||
modal — lazily built by header.js and appended to <body>, so every
|
||||
page carrying #sync-btn gets it with zero page-markup changes.
|
||||
Phase-08 error palette (PLAN §7.2): the panel sits on the error
|
||||
surface --err-bg with a 1px --err-line border (the amber
|
||||
--accent-line stays deflection-only), its title in --ink (≈14.5:1
|
||||
on --err-bg) and the error text in --err-ink (9.1:1 on --err-bg).
|
||||
The backdrop dims the page with --bg at 82% — no blur (the phase-08
|
||||
no-blur anchor). Stacking: z-index 1000, the same overlay contract
|
||||
as the doc-modal — above the sticky header (20) and the skip-link
|
||||
(100). Open/close via .is-open (visibility/opacity — the closed
|
||||
modal is unfocusable and inert); the close button keeps the global
|
||||
3px :focus-visible outline and the 44px touch floor. */
|
||||
.sync-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(10, 14, 23, 0.82);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
.sync-modal-backdrop.is-open {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
.sync-modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
background: var(--err-bg);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
/* right padding clears the absolutely-positioned close button */
|
||||
padding: 1.1rem 3rem 1.25rem 1.25rem;
|
||||
}
|
||||
#sync-modal-title {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
#sync-modal-error {
|
||||
margin: 0 0 1rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9rem;
|
||||
color: var(--err-ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sync-modal-close {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
right: 0.3rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--err-line);
|
||||
background: transparent;
|
||||
color: var(--err-ink);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sync-modal-close:hover { background: rgb(239 68 68 / 0.15); }
|
||||
/* No motion under reduced motion (same opt-out pattern as the
|
||||
phase-25 background layers and the doc-modal backdrop). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sync-modal-backdrop { transition: none; }
|
||||
}
|
||||
|
||||
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
|
||||
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
|
||||
(not removed) below 640px so the accessible name keeps the word.
|
||||
≥44px touch target at every width. */
|
||||
.steering-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.steering-toggle:hover, .steering-toggle[aria-expanded="true"] {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
.steering-toggle svg { width: 16px; height: 16px; display: block; }
|
||||
.steering-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
min-width: 1.35rem;
|
||||
text-align: center;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
.steering-toggle[aria-expanded="true"] .steering-count {
|
||||
background: var(--brand);
|
||||
color: var(--bg); /* dark ink on brand: 5.2:1 */
|
||||
/* Mobile-only sign-out copy: hidden on desktop, revealed inside
|
||||
the hamburger dropdown on mobile (phase 46 UX revision). */
|
||||
.sign-out-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------- Main frame ---------- */
|
||||
@@ -572,8 +404,8 @@ html::after {
|
||||
}
|
||||
.bubble p { margin: 0.2rem 0; }
|
||||
.bubble pre {
|
||||
background: #0d1120;
|
||||
color: #e6e9f2;
|
||||
background: #1a0f0f;
|
||||
color: #e6d0d0;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -810,7 +642,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
background: #0d1120;
|
||||
background: #1a0f0f;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem 0.6rem;
|
||||
@@ -896,7 +728,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.6rem;
|
||||
background: #0d1120;
|
||||
background: #1a0f0f;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.35rem 0.4rem 0.35rem 0.8rem;
|
||||
@@ -1075,7 +907,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
background: #0d1120;
|
||||
background: #1a0f0f;
|
||||
border: 1px solid var(--brand-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.6rem 0.7rem;
|
||||
@@ -1290,7 +1122,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
color: var(--ink);
|
||||
background: #0d1120;
|
||||
background: #1a0f0f;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.55rem 0.75rem;
|
||||
@@ -1334,8 +1166,120 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
flex: 1;
|
||||
}
|
||||
.page-head h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
|
||||
.page-head-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.page-sub { margin: 0; color: var(--ink-soft); }
|
||||
.page-sub code { font-family: var(--mono); font-size: 0.85em; background: var(--brand-soft); padding: 0.1em 0.35em; border-radius: 5px; }
|
||||
/* ---------- Sync button (Sources page) ---------- */
|
||||
.sync-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sync-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
.sync-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||
.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; }
|
||||
.sync-btn .sync-icon.is-spinning { animation: spin 1s linear infinite; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sync-btn .sync-icon.is-spinning { animation: none; }
|
||||
}
|
||||
.sync-label { display: inline; }
|
||||
/* Last-result announcer — soft ink, small mono */
|
||||
.sync-result {
|
||||
display: block;
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
/* Sync failure modal — lazily created by sources.js, appended to <body>.
|
||||
Error surface: --err-bg with --err-line border, title in --ink,
|
||||
error text in --err-ink (9.1:1 on --err-bg). Backdrop dims with
|
||||
--bg at 82%. z-index 1000 (same overlay contract as doc-modal). */
|
||||
.sync-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(10, 14, 23, 0.82);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
.sync-modal-backdrop.is-open {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
.sync-modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
background: var(--err-bg);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 1.1rem 3rem 1.25rem 1.25rem;
|
||||
}
|
||||
#sync-modal-title {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
#sync-modal-error {
|
||||
margin: 0 0 1rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9rem;
|
||||
color: var(--err-ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sync-modal-close {
|
||||
position: absolute;
|
||||
top: 0.3rem;
|
||||
right: 0.3rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--err-line);
|
||||
background: transparent;
|
||||
color: var(--err-ink);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sync-modal-close:hover { background: rgb(239 68 68 / 0.15); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sync-modal-backdrop { transition: none; }
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
display: grid;
|
||||
@@ -1841,8 +1785,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.doc-md > :first-child { margin-top: 0; }
|
||||
.doc-md ul { margin: 0.4rem 0; padding-left: 1.3rem; }
|
||||
.doc-md pre {
|
||||
background: #0d1120;
|
||||
color: #e6e9f2;
|
||||
background: #1a0f0f;
|
||||
color: #e6d0d0;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -2118,7 +2062,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
|
||||
/* ---------- 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
|
||||
toggle + 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.
|
||||
@@ -2131,7 +2075,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
designated clip target, pills squeeze next). */
|
||||
.nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; }
|
||||
.app-nav { gap: 0.15rem; }
|
||||
.new-chat-btn, .auth-link, .sync-btn, .steering-toggle { padding: 0.45rem 0.5rem; }
|
||||
.new-chat-btn, .auth-link { padding: 0.45rem 0.5rem; }
|
||||
}
|
||||
|
||||
/* ---------- Responsive (mobile-first adjustments) ---------- */
|
||||
@@ -2234,40 +2178,45 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.new-chat-btn { padding: 0.4rem 0.3rem; }
|
||||
.new-chat-label { display: none; }
|
||||
.new-chat-btn svg { display: block; }
|
||||
/* But on the chat page there is room — keep the label visible and
|
||||
hide the icon (the button lives inside .chat-shell, not the navbar). */
|
||||
.chat-shell .new-chat-label { display: inline; }
|
||||
.chat-shell .new-chat-btn svg { display: none; }
|
||||
/* 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.3rem; }
|
||||
.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.3rem; }
|
||||
.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
|
||||
icon carries the visible state. Same clip recipe as .steering-label. */
|
||||
.sync-result {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
/* Phase 46 UX revision: sign-out moves into the hamburger dropdown
|
||||
on mobile — hide the bar copy, show the dropdown copy. */
|
||||
.sign-out-mobile { display: inline-flex; }
|
||||
#sign-out-btn { display: none !important; }
|
||||
.nav-toggle { margin-left: auto; }
|
||||
/* Dropdown-style sign-out: full-width row, error palette, label visible. */
|
||||
#app-nav .sign-out-btn {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
text-align: left;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 1rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--err-ink);
|
||||
min-height: 48px;
|
||||
}
|
||||
.steering-toggle { padding: 0.4rem 0.3rem; }
|
||||
/* Visually hidden, NOT display:none — the accessible name keeps the
|
||||
word "Tuning" next to the count badge. */
|
||||
.steering-label {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
#app-nav .sign-out-btn:hover {
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
}
|
||||
#app-nav .sign-out-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#app-nav .sign-out-btn .auth-label { display: inline; }
|
||||
.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; }
|
||||
@@ -2313,6 +2262,20 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
#local-source-add { width: 100%; }
|
||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||
/* Sync button goes icon-only on mobile; the label hides, aria-label
|
||||
keeps the accessible name. The spinning icon is the visible running
|
||||
state on a touch screen. */
|
||||
.sync-btn { padding: 0.4rem 0.3rem; }
|
||||
.sync-label { display: none; }
|
||||
.sync-result {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
|
||||
|
||||
+15
-35
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Read a document indexed in Brain of Reese.">
|
||||
<title>Document · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -24,7 +24,7 @@
|
||||
<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>
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
@@ -53,39 +53,19 @@
|
||||
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>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" 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>
|
||||
</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" hidden
|
||||
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 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- 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
|
||||
@@ -94,7 +74,7 @@
|
||||
<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>
|
||||
<button type="button" class="auth-link sign-out-btn" 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>
|
||||
|
||||
+18
-36
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).">
|
||||
<title>Git sources · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -21,7 +21,7 @@
|
||||
<header 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>
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
@@ -50,37 +50,19 @@
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" 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>
|
||||
</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" hidden
|
||||
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. The §7.4 "never stale" lifecycle
|
||||
is module-owned (assets/header.js). This page's hint box
|
||||
points at it: it is the action that clones the listed repos
|
||||
and prunes the removed ones. -->
|
||||
<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 a
|
||||
non-chat page "New chat" 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 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- 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
|
||||
@@ -89,7 +71,7 @@
|
||||
<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>
|
||||
<button type="button" class="auth-link sign-out-btn" 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>
|
||||
@@ -124,7 +106,7 @@
|
||||
</div>
|
||||
<h2 id="git-sources-gate-title">Sign in to manage the git sources</h2>
|
||||
<p class="sources-gate-sub">
|
||||
The list of repositories the <strong>Sync sources</strong> button
|
||||
The list of repositories cloned and indexed by the sync service
|
||||
clones and indexes is admin-only. Chat — and any document an
|
||||
answer cites — stays open to everyone.
|
||||
</p>
|
||||
@@ -236,8 +218,8 @@
|
||||
<p class="git-source-hint" id="git-sources-hint" role="note">
|
||||
Sync clones/pulls the git repos and imports the local
|
||||
directories together (files removed from a source are
|
||||
pruned). Use the <strong>Sync sources</strong> button in the
|
||||
header (or on the Sources page) to run it — removing a source
|
||||
pruned). Run the sync to clone/pull the git repos and import
|
||||
the local directories — removing a source
|
||||
prunes its documents from the index on the next sync.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+24
-35
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Ask Brain of Reese anything about the homelab and deployments.">
|
||||
<title>Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -14,7 +14,7 @@
|
||||
<header 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>
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
@@ -43,39 +43,19 @@
|
||||
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>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" 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>
|
||||
</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" hidden
|
||||
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 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- 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
|
||||
@@ -84,7 +64,7 @@
|
||||
<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>
|
||||
<button type="button" class="auth-link sign-out-btn" 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>
|
||||
@@ -114,6 +94,15 @@
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
|
||||
<!-- Phase 14 (module-owned since phase 34 task 02): "New chat" resets
|
||||
the local (localStorage) conversation and clears the rendered list.
|
||||
The binding lives in header.js — dispatches "bor:new-chat" which
|
||||
app.js acts on (it owns the in-flight-turn guard + list reset). -->
|
||||
<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>
|
||||
|
||||
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-state-glyph" aria-hidden="true">
|
||||
|
||||
+15
-35
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Admin sign-in for Brain of Reese — unlocks the full Sources catalog and answer tuning.">
|
||||
<title>Sign in · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,7 +15,7 @@
|
||||
<header 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>
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
@@ -46,39 +46,19 @@
|
||||
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>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" 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>
|
||||
</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" hidden
|
||||
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 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- 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
|
||||
@@ -87,7 +67,7 @@
|
||||
<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>
|
||||
<button type="button" class="auth-link sign-out-btn" 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>
|
||||
|
||||
+33
-54
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Documents indexed in Brain of Reese.">
|
||||
<title>Sources · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -14,7 +14,7 @@
|
||||
<header 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>
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
@@ -43,43 +43,19 @@
|
||||
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>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" 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>
|
||||
</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" hidden
|
||||
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>
|
||||
<!-- #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 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- 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
|
||||
@@ -88,7 +64,7 @@
|
||||
<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>
|
||||
<button type="button" class="auth-link sign-out-btn" 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>
|
||||
@@ -112,26 +88,29 @@
|
||||
</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
|
||||
sync is announced. sources.js fills #sync-error-text and
|
||||
un-hides it on a failed run (the button re-enables,
|
||||
retry-ready); a new sync hides it again. -->
|
||||
<div class="page-head">
|
||||
<div class="page-head-row">
|
||||
<h1>Knowledge base</h1>
|
||||
<button type="button" class="sync-btn" id="sync-btn" 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">Sync sources</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="page-sub">
|
||||
Every <code>*.md</code> file indexed from <code>~/Homelab</code> and
|
||||
<code>~/Deployments</code>. Press <strong>Sync sources</strong> to clone
|
||||
the repos and re-import.
|
||||
</p>
|
||||
</div>
|
||||
<!-- #sync-result is the aria-live announcer for the last sync
|
||||
result ("N added · …") — renders off the "bor:sync-status" event. -->
|
||||
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
|
||||
<!-- Sync failure banner — role="alert" so a failed sync is announced. -->
|
||||
<div class="kb-banner is-error" id="sync-error-banner" role="alert" hidden>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
|
||||
<span id="sync-error-text"></span>
|
||||
</div>
|
||||
|
||||
<div class="page-head">
|
||||
<h1>Knowledge base</h1>
|
||||
<p class="page-sub">
|
||||
Every <code>*.md</code> file indexed from <code>~/Homelab</code> and
|
||||
<code>~/Deployments</code>. Re-run the import to refresh — or hit
|
||||
<strong>Sync sources</strong> in the header to clone the repos and
|
||||
re-import.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Phase 16: anonymous sign-in gate. The catalog is what the
|
||||
login locks — the document viewer itself stays public (soft
|
||||
rule), so the copy says what stays open. -->
|
||||
|
||||
+15
-35
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Manage the global tuning notes that steer every Brain of Reese answer.">
|
||||
<title>Global Tuning · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -14,7 +14,7 @@
|
||||
<header 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>
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||
@@ -43,39 +43,19 @@
|
||||
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>
|
||||
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" 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>
|
||||
</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" hidden
|
||||
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 15: the tuning-notes panel (stored in Postgres, read
|
||||
into every system prompt) — owned by the shared header
|
||||
module (assets/header.js); the #steering-panel section
|
||||
ships in every page's <main>. The navbar toggle was
|
||||
removed at owner request (2026-08-28): note management
|
||||
lives on /tuning.html. -->
|
||||
<!-- 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
|
||||
@@ -84,7 +64,7 @@
|
||||
<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>
|
||||
<button type="button" class="auth-link sign-out-btn" 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>
|
||||
|
||||
Reference in New Issue
Block a user