diff --git a/frontend/assets/git-sources.js b/frontend/assets/git-sources.js
index bdf25aa..85f4201 100644
--- a/frontend/assets/git-sources.js
+++ b/frontend/assets/git-sources.js
@@ -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
diff --git a/frontend/assets/header.js b/frontend/assets/header.js
index 4204875..34f2870 100644
--- a/frontend/assets/header.js
+++ b/frontend/assets/header.js
@@ -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
, 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=
* (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 — 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 ) →
- * 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 =
- '
' +
- '
Sync failed
' +
- '' +
- '' +
- "
";
- 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 ), 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)
-}
diff --git a/frontend/assets/login.js b/frontend/assets/login.js
index ec2bcff..a3f1a6f 100644
--- a/frontend/assets/login.js
+++ b/frontend/assets/login.js
@@ -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();
diff --git a/frontend/assets/sources.js b/frontend/assets/sources.js
index 164127b..028f9f3 100644
--- a/frontend/assets/sources.js
+++ b/frontend/assets/sources.js
@@ -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 =
+ '
' +
+ '
Sync failed
' +
+ '' +
+ '' +
+ "
";
+ 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.
})();
diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css
index 0a861eb..b3a8505 100644
--- a/frontend/assets/styles.css
+++ b/frontend/assets/styles.css
@@ -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 , 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 .
+ 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
diff --git a/frontend/document.html b/frontend/document.html
index 0167ea8..fe41046 100644
--- a/frontend/document.html
+++ b/frontend/document.html
@@ -5,7 +5,7 @@
Document · Brain of Reese
-
+
@@ -24,7 +24,7 @@
+ Every *.md file indexed from ~/Homelab and
+ ~/Deployments. Press Sync sources to clone
+ the repos and re-import.
+
+
+
+
+
-
-
Knowledge base
-
- Every *.md file indexed from ~/Homelab and
- ~/Deployments. Re-run the import to refresh — or hit
- Sync sources in the header to clone the repos and
- re-import.
-
-
-
diff --git a/frontend/tuning.html b/frontend/tuning.html
index f121a59..97ab85d 100644
--- a/frontend/tuning.html
+++ b/frontend/tuning.html
@@ -5,7 +5,7 @@
Global Tuning · Brain of Reese
-
+
@@ -14,7 +14,7 @@