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 @@
- + Brain of Reese + + - - - - - - + + + - - - - - - + + + - - - - - - + + +