Files
brain-of-reese/frontend/assets/header.js
T
ducoterra 6be692d999 feat(header): hamburger dropdown nav on mobile (owner permission)
TODO.md L9 (owner permission 2026-08-27, roadmap A5): "The navbar on
mobile is way too squished. Make it a hamburger dropdown menu with a
nice animation." At <=640px the nav links leave the bar — a 44px
#nav-toggle opens #app-nav as an animated (180ms slide+fade)
edge-to-edge dropdown with comfortable rows and the auth visibility
contract intact inside the menu; at >640px the bar is byte-identical
to pre-phase-46 (hamburger absent, inline pills as before).

- frontend/*.html (all six pages): the shared bar gains the
  #nav-toggle button (type=button, aria-expanded=false,
  aria-controls="app-nav", aria-label="Menu", aria-hidden 3-line
  SVG icon) immediately before the nav, and the nav gains
  id="app-nav" — one <nav>, no duplicated links, so the whoami reveal
  works inside the menu unchanged (phase-34 same-bar contract intact).
- frontend/assets/styles.css: .nav-toggle is display:none outside media
  queries (desktop untouched); the <=640px block adds the 44px toggle
  (+hover in the .steering-toggle:hover family, sized 20px icon), turns
  .app-nav into the dropdown (absolute top:100% edge-to-edge under the
  sticky header, surface + hairline + --shadow-lg, z-index 21 =
  header+1, closed state invisible + non-interactive with the 180ms
  opacity/transform/visibility-delayed pair, .is-open the only
  opener), and comfortable 1rem/0.75rem menu rows — superseding the
  phase-34/35 pill-squeeze rules for .nav-link/.app-nav (the 900px
  tablet block, action pills, and 58px bar height untouched). The
  reduced-motion block stills BOTH the closed and .is-open states: the
  .is-open rule (0,2,0) out-specifies a bare .app-nav (0,1,0), so the
  override must name both — verified live in Chromium (task 03).
- frontend/assets/header.js: ONE module-owned binding (import-time,
  null-safe like the sign-out binding): click toggles .is-open +
  aria-expanded in sync, a delegated nav-link click closes, Esc closes
  and refocuses the toggle, and matchMedia("(max-width: 640px)")
  change drops the state on resize back to desktop. The binding
  touches only the container — ship-hidden whoami links stay hidden.
- tests/unit/test_hamburger_nav.py (new): the markup/CSS/JS contract
  pins (six identical toggles in the shared row, desktop byte-
  identical, dropdown + .is-open + 180ms + reduced-motion rules, the
  superseded squeeze rules gone, the one-binding behavior).
- tests/e2e/test_shared_header.py: assert_shared_bar gains mobile=True
  (at <=640px the bar shows the hamburger + the closed nav; the
  per-role menu contents are pinned by the story suite).
- tests/e2e/test_mobile_hamburger_nav.py (new, story suite, 375x812):
  toggle is a visible >=44px target, menu closed (opacity 0 /
  visibility hidden), no horizontal overflow; anonymous menu shows
  exactly "Chat" (admin-only links stay hidden inside); admin menu
  shows all four links (whoami reveal inside the menu); a link click
  navigates + the arrival page ships closed; Esc closes and refocuses
  the toggle (outside click does NOT close — accepted: the locked
  close set is Esc + link + resize, no backdrop); the 180ms
  opacity/transform pair is live and reducedMotion:reduce stills both
  states with open/close still working; 1280x800 regression — toggle
  display:none, all four inline links inside the header band.

Gates: unit+integration 773 passed; app/ coverage TOTAL 99%
(unchanged — frontend-only phase); story E2E 7 passed in isolation
(mock LLM, DB up); regression suites test_nav_consistency (6) /
test_header_consistency (3) / test_shared_header (6) /
test_responsive_polish (7) / test_tuning_nav_link (4) all pass in
isolation; ruff check + pyright clean. A11 honored: no CDN, no new
assets.

Also records the 46_mobile_hamburger_nav todo/ -> complete/ move.
2026-08-28 06:07:02 -04:00

746 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Brain of Reese — shared header module (phase 19).
*
* Owner report 2026-08-23: clicking "Sources" made New Chat and Sign in
* vanish — the user expects ONE consistent bar on every page. This module
* is the single owner of the shared header controls:
*
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
* decided by /api/whoami at load);
* • the admin-only nav links — "Sources" (#nav-sources, phase 19),
* "Git sources" (#nav-git-sources, phase 35) and "Tuning"
* (#nav-tuning, phase 29) — phase 19 UX revision
* (owner permission 2026-08-23): hidden for anonymous on EVERY
* page, revealed for admin. Phase 34 task 03 (owner confirmation
* 2026-08-26): the SAME nav ships on all five pages (chat,
* sources, document viewer, tuning, login) — the viewer's
* "no nav" bar is gone. The links SHIP hidden in the HTML
* (anonymous-safe default — the phase-16 "absent, not hidden"
* spirit), so no anonymous user ever sees one for a frame; and
* the "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
* 2026-08-27, TODO.md L9) — at ≤640px (CSS hides the button
* elsewhere) the #nav-toggle button opens the nav as an animated
* dropdown (#app-nav .is-open — the 180ms slide+fade state from
* task 01's CSS): a click toggles it with aria-expanded kept in
* sync, a nav link click shuts it (the navigation happens anyway),
* Esc shuts it and returns focus to the toggle, and resizing back
* to >640px drops the open state (matchMedia change) so
* aria-expanded stays honest. One binding for all six pages; a
* page without either element is a no-op. The binding toggles
* ONLY the container — the nav links keep their ship-hidden
* whoami contract (hidden links stay hidden inside the menu);
* • the steering-notes controls (phase 15, moved here from app.js in
* phase 34) — the #steering-toggle open/close + the #steering-panel
* list (newest-first, textContent-rendered, per-note delete, count
* badge, the #steering-announcer live region) — so the toggle can
* sit in every page's header with zero page-script duplication.
* The toggle SHIPS hidden in every page (phase 40, 2026-08-27,
* TODO.md L3 — the exact ship-hidden / reveal-for-admin contract
* the admin-only nav links use: anonymous never sees it for a
* single frame) and initSharedHeader unhides it only when whoami
* says admin. refreshSteering() / announceSteering() are exported
* for the chat page's per-bubble Tune form (which stays in app.js);
* anonymous visitors get the phase-16 "absent, not hidden"
* treatment (toggle + panel removed from the DOM, /api/steering
* never fetched);
* • the Sync sources state machine (phase 32, moved here from
* sources.js in phase 34 task 02) — the §7.4 never-stale lifecycle
* for #sync-btn (idle → running → success | failed): admin-only
* boot re-attach on the SAME cached whoami (non-admins never poll),
* POST /api/sync (202 start / 409 adopt), the 2 s
* GET /api/sync/status poll (one live timer, NO client-side hard
* timeout — the server state is authoritative). Every state change
* dispatches window "bor:sync-status" (detail = the status object)
* so the Sources page renders its #sync-result line +
* #sync-error-banner off the event; the button title/aria are the
* secondary failure surfaces — and a failed run ALSO opens the
* module-owned error modal (phase 41, 2026-08-27, TODO.md L4, the
* primary readable failure surface): lazily built by this module,
* appended to <body>, error text textContent-rendered, closable via
* its button / Esc / backdrop, focus in-and-out to #sync-btn —
* every page carrying #sync-btn gets it with zero page-markup
* changes;
* • the SINGLE New chat binding (phase 34 task 02 — it was
* duplicated across app.js / sources.js / tuning.js / document.js):
* on the chat page (#messages exists) the module dispatches
* window "bor:new-chat" and app.js acts (it owns the in-flight-turn
* guard + the list reset); on every other page it means "go to the
* chat, fresh" — clearChatStorage() + navigate to "/";
* • the sign-in ?next= rewrite (phase 34 task 02) — initSharedHeader
* points #sign-in-link at /login.html?next=<current pathname>
* (default "/"), so the admin lands back on the page they signed in
* from; the page markup keeps its own href as the no-JS fallback;
* • clearChatStorage() — the phase-14 conversation key, for the
* New chat action on the NON-CHAT pages (sources / document viewer /
* tuning / login): a new chat means going to the chat, fresh.
*
* Every page loads this module (type="module", before its page script)
* and its page script calls initSharedHeader() once at boot. init…
* toggles ONLY the controls that exist on the page — a missing element
* is a no-op. Phase 34 task 03 ships the SAME full header block on all
* five pages (the login page included), so every control resolves on
* every page; a page that lacks one simply skips it.
*
* whoami is fetched at most ONCE per page load: the promise is cached in
* the module-level `adminPromise`, so app.js's tuning gate, the sources
* page's catalog gate, and the header toggling all share one request.
* Anonymous-safe: any network failure resolves to false (the anonymous
* UI), mirroring the per-page catch the pages used before phase 19.
*
* A10/A11 untouched: no API change, no CDN, no state beyond the cached
* promise; the soft gate page and the A10 API split are unchanged —
* this is UI visibility only.
*/
let adminPromise = null;
/* The SINGLE /api/whoami call site for the whole frontend. First call
stores the promise in `adminPromise`; every later call — on this page
— returns the same promise, i.e. exactly one request per page load.
Anonymous-safe: non-2xx or a network failure resolves to false. */
export function fetchIsAdmin() {
if (!adminPromise) {
adminPromise = fetch("/api/whoami")
.then(async (r) => (r.ok ? (await r.json()).authenticated === true : false))
.catch(() => false);
}
return adminPromise;
}
/* Toggle the shared header controls, only the ones present on this page
(querySelector, null-safe — missing → no-op). Returns the admin flag
so callers can reuse it instead of awaiting fetchIsAdmin() again (the
cached promise makes both awaits the same single request). */
export async function initSharedHeader() {
const admin = await fetchIsAdmin();
// The Sign in link: hidden for the admin, visible otherwise — and its
// href is rewritten to return the admin to THIS page after login
// (phase 34 task 02: "return to where you were"). The markup keeps its
// own static ?next= as the no-JS fallback. location.pathname is always
// a query-safe "/…" string (never "//"; ? # and spaces stay
// percent-encoded in it), so it rides in next= as-is — the same shape
// the static fallbacks use (login.js safeNext re-validates it).
const signIn = document.querySelector("#sign-in-link");
if (signIn) {
signIn.hidden = admin;
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
}
const signOut = document.querySelector("#sign-out-btn");
if (signOut) signOut.hidden = !admin;
const navSources = document.querySelector("#nav-sources");
if (navSources) navSources.hidden = !admin;
// Phase 35 (owner permission 2026-08-26): the Git sources nav link —
// admin-only, the same ship-hidden / reveal-for-admin contract as
// the Sources link above.
const navGitSources = document.querySelector("#nav-git-sources");
if (navGitSources) navGitSources.hidden = !admin;
// Phase 29: the Global Tuning nav link (every page from phase 34
// task 03) — admin-only, the same ship-hidden / reveal-for-admin
// contract as the Sources link.
const navTuning = document.querySelector("#nav-tuning");
if (navTuning) navTuning.hidden = !admin;
// Phase 32: the "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.
if (admin) {
if (steeringToggle) steeringToggle.hidden = false;
if (steeringPanel) refreshSteering();
} else {
steeringToggle?.remove();
steeringPanel?.remove();
}
return admin;
}
/* Remove the phase-14 conversation key — same key + fail-silence
contract as app.js's clearStoredConversation: private mode or a
storage error is swallowed, the navigation still happens. */
export function clearChatStorage() {
try {
localStorage.removeItem("bor.chat.v1");
} catch {
/* nothing was stored */
}
}
/* 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;
try {
await fetch("/api/logout", { method: "POST" });
} catch {
/* the reload resets the UI either way */
}
window.location.reload();
});
}
/* ---------- mobile hamburger (phase 46; module-owned) ----------
* ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the
* nav as a dropdown (#app-nav .is-open — the animated state, task 01
* CSS). One binding for all six pages; a page without either element
* is a no-op, like the rest of this module. The nav LINKS keep their
* ship-hidden whoami contract (hidden links stay hidden inside the
* menu) — this binding only toggles the container. */
const navToggle = document.querySelector("#nav-toggle");
const appNav = document.querySelector("#app-nav");
function setNavMenu(open) {
if (!appNav || !navToggle) return;
appNav.classList.toggle("is-open", open);
navToggle.setAttribute("aria-expanded", open ? "true" : "false");
}
if (navToggle && appNav) {
navToggle.addEventListener("click", () =>
setNavMenu(!appNav.classList.contains("is-open")));
// A link click navigates (or closes same-page) — shut the menu.
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).
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && appNav.classList.contains("is-open")) {
setNavMenu(false);
navToggle.focus(); // focus returns to the opener
}
});
// Resize back to desktop: the inline nav reappears — no stale open
// state (the .is-open class is scoped by the ≤640px CSS anyway, but
// dropping it keeps aria-expanded honest).
const mq = window.matchMedia("(max-width: 640px)");
const onMqChange = () => { if (!mq.matches) setNavMenu(false); };
if (mq.addEventListener) mq.addEventListener("change", onMqChange);
else mq.addListener(onMqChange); // older engines, defensive
}
/* ---------- steering notes (phase 15; module-owned from phase 34) ----------
*
* The owner's tuning notes steer every future answer: they live in
* Postgres (stateless API, A10) and the chat turn reads them into the
* system prompt. The header panel — toggle, list, per-note delete, count
* badge, announcer — is owned by THIS module: every page that ships the
* panel markup gets exactly this behavior, with zero page-script
* duplication. The chat page keeps only its per-bubble Tune form
* (app.js), which refreshes the panel through refreshSteering() and
* announces through announceSteering().
*
* All elements are looked up null-safe (querySelector + guard): a page
* that lacks the panel markup is a no-op — the same contract as
* initSharedHeader().
*/
const steeringToggle = document.querySelector("#steering-toggle");
const steeringCount = document.querySelector("#steering-count");
const steeringPanel = document.querySelector("#steering-panel");
const steeringList = document.querySelector("#steering-list");
const steeringEmpty = document.querySelector("#steering-empty");
const steeringAnnouncer = document.querySelector("#steering-announcer");
/* Announce a steering change through the polite live region
(role="status", aria-live="polite") — exported so the chat page's
per-bubble Tune form (app.js) announces on the exact same channel. */
export function announceSteering(message) {
if (steeringAnnouncer) steeringAnnouncer.textContent = message;
}
/* Fetch + render the note list (exported — the chat page's per-bubble
Tune form calls it on save, so the panel + count badge update without
owning the fetch itself). Non-2xx (the anonymous 403) or an
unreachable API render the empty state: count badge 0, the "no notes
yet" text visible — the safe fallback in either case. */
export async function refreshSteering() {
let notes = [];
try {
const r = await fetch("/api/steering");
if (r.ok) notes = (await r.json()).notes || [];
} catch {
/* API unreachable: the empty list state is the safe fallback */
}
renderSteeringPanel(notes);
return notes;
}
/* Newest-first list — the note is ALWAYS rendered with textContent
(XSS-safe, never innerHTML), a per-note Remove button with a labeled
aria-label, the empty text toggled on notes.length, and the header
count badge. */
function renderSteeringPanel(notes) {
if (!steeringList) return;
steeringList.textContent = "";
for (const n of notes) {
const li = document.createElement("li");
li.className = "steering-note";
const text = document.createElement("span");
text.className = "steering-note-text";
text.textContent = n.note; // rendered as text, never as HTML
li.appendChild(text);
const del = document.createElement("button");
del.type = "button";
del.className = "steering-delete";
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
del.innerHTML =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
li.appendChild(del);
steeringList.appendChild(li);
}
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
if (steeringCount) steeringCount.textContent = String(notes.length);
}
/* Per-note delete: disable the row button (no double-fire), DELETE
/api/steering/{id}, re-load the list, announce through
#steering-announcer. A 404 means the note was already gone — say so
and still refresh; any other failure re-enables the button so the
user can retry. */
async function deleteSteeringNote(id, btn) {
btn.disabled = true;
try {
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.status === 404) {
announceSteering("That note was already removed.");
await refreshSteering();
return;
}
if (!r.ok) {
announceSteering("Could not delete the note — try again.");
btn.disabled = false;
return;
}
await refreshSteering();
announceSteering("Tuning note deleted.");
} catch {
announceSteering("Could not delete the note — is the app reachable?");
btn.disabled = false;
}
}
/* Open/close the panel, keeping the toggle's aria-expanded in sync —
the exact phase-15 chat-page contract (open on click, close on click;
the panel itself is a plain region — no Esc / outside-click close in
the original, so none here). Re-opening refreshes the list, so notes
changed elsewhere (the Tuning page, another tab) show up. */
function setSteeringPanel(open) {
if (!steeringPanel || !steeringToggle) return;
steeringPanel.hidden = !open;
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
}
/* Toggle binding (module-owned, like the sign-out binding): runs at
module import, so a page with the toggle markup gets exactly one
implementation. */
if (steeringToggle && steeringPanel) {
steeringToggle.addEventListener("click", () => {
setSteeringPanel(steeringPanel.hidden);
if (!steeringPanel.hidden) refreshSteering(); // refresh when (re)opened
});
}
/* ---------- New chat (the SINGLE binding — module-owned from phase 34
* task 02) ----------
*
* The binding used to be duplicated across app.js / sources.js /
* tuning.js / document.js with the same page-kind branch. It lives here
* exactly once (module import, like the sign-out binding): on the chat
* page (#messages exists) the module dispatches window "bor:new-chat"
* and app.js acts — the chat script owns the in-flight-turn guard and
* the rendered-list reset; on every other page "new chat" means go to
* the chat, fresh: clear the phase-14 conversation key, then navigate
* to "/" (its empty state, since the conversation is gone from storage).
*/
const newChatBtn = document.querySelector("#new-chat-btn");
if (newChatBtn) {
newChatBtn.addEventListener("click", () => {
if (document.querySelector("#messages")) {
window.dispatchEvent(new CustomEvent("bor:new-chat"));
return;
}
clearChatStorage();
window.location.href = "/";
});
}
/* ---------- sync sources (phase 32; module-owned from phase 34 task 02) ----------
*
* The "never stale" lifecycle for the long background sync job, moved
* here from sources.js so the SAME #sync-btn markup on ANY page (phase
* 34 task 03) behaves identically. The button is the module's; the
* Sources page's #sync-result line + #sync-error-banner render off the
* "bor:sync-status" event this machine dispatches (sources.js
* subscribes):
*
* idle → click → POST /api/sync
* 202 → running (disabled, aria-busy, spinning icon, "Syncing…")
* + a 2 s poll of GET /api/sync/status;
* 409 → the in-flight run is ADOPTED the same way (one sync
* at a time, one poll loop at a time);
* success → "Synced HH:MM"; failed → retry-ready "Sync sources"
* + the sanitized error in the button's title +
* aria-label + the module-owned error modal (phase 41,
* 2026-08-27, TODO.md L4 — the primary readable failure
* surface on every page; the button affordance and the
* Sources banner stay the secondary surfaces).
*
* Boot (admin only — non-admins never poll, the status endpoint is
* admin-only): one GET /api/sync/status on the SAME cached whoami —
* running re-enters the running state (reload mid-sync), a terminal
* state renders its last result. NO client-side hard timeout (phase 32
* locked decision): a sync can legitimately outlive the page, so the
* 2 s poll is the feedback loop and the server state is authoritative.
*
* All elements are looked up null-safe: a page that doesn't (yet) carry
* the #sync-btn markup is a complete no-op, exactly like the rest of
* this module.
*/
const syncBtn = document.querySelector("#sync-btn");
const syncLabel = document.querySelector("#sync-label");
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
const SYNC_POLL_MS = 2000; // the 2 s status poll (phase 32 contract)
let syncPollTimer = null; // at most ONE live poll loop
let lastSyncState = null; // the last state emitted on bor:sync-status
/* The module → page channel: detail is the GET /api/sync/status object
(or the synthetic { state: "running" } frame the click path emits
before the first poll tick — the Sources handlers only need the
state, the next real object carries the full fields). */
function emitSyncStatus(status) {
lastSyncState = status.state;
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
}
function stopSyncPolling() {
if (syncPollTimer !== null) {
clearTimeout(syncPollTimer);
syncPollTimer = null;
}
}
/* The local HH:MM of finished_at — 24-hour, locale-independent, so the
* "Synced 14:32" last-result label is deterministic. */
function fmtSyncTime(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/* The last-result line for the Sources page's #sync-result (aria-live).
* "added" is ALWAYS announced (the run's headline term); "updated" /
* "pruned" only when they happened (zero terms omitted); "unchanged"
* whenever it is non-zero — or whenever nothing was added or updated,
* so a no-op re-sync reads "0 added · 1 unchanged" instead of an empty
* live region (the story gate's idempotency check). Exported so the
* Sources page renders the counts from ONE implementation. */
export function fmtSyncResult(detail) {
const d = detail || {};
const added = d.added || 0;
const updated = d.updated || 0;
const parts = [`${added} added`];
if (updated > 0) parts.push(`${updated} updated`);
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
parts.push(`${d.unchanged || 0} unchanged`);
}
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
return parts.join(" · ");
}
/* The failed-state affordance text for the button's title + aria-label
* (non-Sources pages: that is where the failure is visible). The server
* already masks any embedded credentials (sync.py _sanitize_error);
* here the string is collapsed to a single line and capped so a chatty
* git stderr can't bloat the attributes. */
function sanitizeSyncError(message) {
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
/* §7.4 running state: disabled + aria-busy + spinning icon + the
* "Syncing…" label — and a fresh run starts clean: the previous
* failure's affordances (title / aria-label / .is-error) come off NOW,
* not when the run settles. The button only — the Sources page's
* result line / banner clear off the matching "running" event (no
* 2 s lag). */
function enterSyncRunningState() {
if (!syncBtn) return;
syncBtn.disabled = true;
syncBtn.setAttribute("aria-busy", "true");
syncBtn.removeAttribute("title");
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.add("is-spinning");
if (syncLabel) syncLabel.textContent = "Syncing…";
}
/* Settle the button back to clickable + un-spun with the given label,
* dropping the failed-state affordances (a fresh run starts clean). */
function settleSyncButton(label) {
if (!syncBtn) return;
syncBtn.disabled = false;
syncBtn.removeAttribute("aria-busy");
syncBtn.removeAttribute("title");
syncBtn.setAttribute("aria-label", "Sync sources");
syncBtn.classList.remove("is-error");
if (syncIcon) syncIcon.classList.remove("is-spinning");
if (syncLabel) syncLabel.textContent = label;
}
function applySyncSuccess(status) {
const time = fmtSyncTime(status.finished_at);
settleSyncButton(time ? `Synced ${time}` : "Synced");
emitSyncStatus(status);
}
function applySyncFailure(status) {
const error = sanitizeSyncError(status.error);
settleSyncButton("Sync sources"); // retry-ready
if (syncBtn) {
// The failed look: error text in title + aria-label (and the
// .is-error class for the non-Sources pages' visible error state).
syncBtn.title = error;
syncBtn.setAttribute("aria-label", error);
syncBtn.classList.add("is-error");
}
emitSyncStatus(status);
// Phase 41 (2026-08-27, TODO.md L4): the module-owned error modal —
// the primary readable failure surface (the button title/aria and the
// Sources banner above stay as the secondary surfaces).
showSyncModal(error);
}
/* ---------- sync failure modal (phase 41, 2026-08-27, TODO.md L4) ----------
*
* A tooltip on the button is not a readable error — a failed sync ALSO
* opens a modal dialog. It is built by THIS module (the owner of the
* sync state machine), so every page carrying #sync-btn gets it with
* zero page-markup changes: created lazily ONCE (module-level
* `syncModal`) and appended to <body> — a .sync-modal-backdrop (fixed,
* full-viewport dim) holding the .sync-modal panel
* (role="alertdialog", aria-modal, labelled + described). The error
* text is ALWAYS set via textContent (XSS-safe — never innerHTML with
* user data); a second failure while open updates the text IN PLACE
* (no stacking). Closes via the close button, Esc (ONE document
* keydown binding, acting only while open), or a click on the backdrop
* itself (never the panel); focus moves to the close button on open
* and back to the remembered element (#sync-btn — the control that
* started the run) on close. Null-safe: no #sync-btn (or no <body>) →
* no modal, exactly like the rest of this module.
*/
let syncModal = null; // the backdrop element — created once, lazily
let syncModalReturnFocus = null; // the element to refocus on close
function createSyncModal() {
const backdrop = document.createElement("div");
backdrop.className = "sync-modal-backdrop";
// Static skeleton — no user data anywhere in it; the error text is
// filled via textContent in showSyncModal, never interpolated here.
backdrop.innerHTML =
'<div class="sync-modal" role="alertdialog" aria-modal="true" ' +
'aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">' +
'<h2 id="sync-modal-title">Sync failed</h2>' +
'<p id="sync-modal-error"></p>' +
'<button type="button" class="sync-modal-close" aria-label="Close error dialog">\u00d7</button>' +
"</div>";
document.body.appendChild(backdrop);
// Close path 1: the close button (×).
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
// Close path 2: a click on the backdrop element itself — never one
// that bubbles up from the panel (event.target check).
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) closeSyncModal();
});
// Close path 3: Esc — ONE document-level keydown binding for the
// life of the page, acting only while the modal is open.
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
});
return backdrop;
}
function showSyncModal(error) {
if (!syncBtn || !document.body) return; // null-safe: pages without the button
if (!syncModal) syncModal = createSyncModal();
// The sanitized error as TEXT (XSS-safe) — a second failure while
// open updates the text in place (no stacking, no focus jump).
syncModal.querySelector("#sync-modal-error").textContent = error;
if (syncModal.classList.contains("is-open")) return;
// First open: remember the focused element and move focus into the
// dialog (the close button). While the run was in flight the button
// was disabled (focus had fallen to <body>), so a body-level active
// element means "no meaningful focus target" — remember #sync-btn,
// the control that started the run, so the close returns focus there.
const active = document.activeElement;
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
syncModal.classList.add("is-open");
syncModal.querySelector(".sync-modal-close").focus();
}
function closeSyncModal() {
if (!syncModal || !syncModal.classList.contains("is-open")) return;
syncModal.classList.remove("is-open");
// Focus returns to the remembered element — #sync-btn when present.
const target = syncModalReturnFocus;
syncModalReturnFocus = null;
if (target && document.contains(target)) target.focus();
}
/* A run can only vanish with a server restart mid-sync (status resets
* to idle — the phase-accepted behavior): retry-ready, no error to
* name. Also the post-403 cleanup. */
function applySyncIdle(status) {
settleSyncButton("Sync sources");
emitSyncStatus(status || { state: "idle" });
}
/* The 2 s poll loop — the ONLY feedback timer (no client-side hard
* timeout, phase 32 locked decision). One tick at a time (re-scheduled
* only while the run is still live, so an in-flight fetch can never
* overlap the next tick), and startSyncPolling refuses to run a second
* loop (a 409 adoption or a reload never doubles the polling). */
function startSyncPolling() {
if (syncPollTimer !== null) return;
const tick = async () => {
let status = null;
let notAdmin = false;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) notAdmin = true;
else if (r.ok) status = await r.json();
} catch {
/* network blip — the next tick retries (no client timeout to trip) */
}
if (notAdmin) {
// Session lost mid-sync: defense in depth — hide the button.
stopSyncPolling();
if (syncBtn) syncBtn.hidden = true;
applySyncIdle();
return;
}
if (!status) {
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
return;
}
if (status.state === "success") {
stopSyncPolling();
applySyncSuccess(status);
return;
}
if (status.state === "failed") {
stopSyncPolling();
applySyncFailure(status);
return;
}
if (status.state === "idle") {
// The run died with a server restart — retry-ready, no banner.
stopSyncPolling();
applySyncIdle(status);
return;
}
// Still running: keep the button state honest (idempotent) and
// re-schedule. No event — the running frame was already emitted
// when the state entered (click / boot), and the Sources handlers
// are no-ops for repeated running frames anyway.
enterSyncRunningState();
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
};
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
}
/* Click → POST /api/sync. 202 starts the run; 409 adopts the in-flight
* one (started elsewhere — e.g. a second tab); 403 hides the button
* (defense in depth); anything else names the failure (banner on
* Sources via the event, button affordance everywhere). */
async function startSync() {
let r;
try {
r = await fetch("/api/sync", { method: "POST" });
} catch {
applySyncFailure({
state: "failed",
error: "Could not reach the server to start the sync — try again.",
});
return;
}
if (r.status === 403) {
stopSyncPolling();
if (syncBtn) syncBtn.hidden = true;
applySyncIdle();
return;
}
if (r.status === 202 || r.status === 409) {
enterSyncRunningState();
// The synthetic running frame clears the Sources result line /
// banner IMMEDIATELY (before the first poll tick, 2 s away) — the
// exact sources.js enterRunningState behavior, now event-driven.
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
startSyncPolling();
return;
}
let detail = "";
try {
detail = (await r.json()).detail || "";
} catch {
/* non-JSON error body */
}
applySyncFailure({
state: "failed",
error: detail || `The server refused to start the sync (${r.status}).`,
});
}
/* Load-time re-attach (ADMIN ONLY — non-admins never poll, the status
* endpoint is admin-only): a running run re-enters the running state
* (the user may have reloaded mid-sync), a terminal run renders its
* last result, idle settles nothing visible. Awaits the SAME cached
* whoami promise — exactly one /api/whoami per page load, unchanged. */
async function initSyncButton() {
if (!syncBtn) return;
if (!(await fetchIsAdmin())) return; // anonymous: the button stays hidden
let status;
try {
const r = await fetch("/api/sync/status");
if (r.status === 403) {
syncBtn.hidden = true; // defense in depth
return;
}
if (!r.ok) return;
status = await r.json();
} catch {
return; // network blip — the button stays idle and clickable
}
if (status.state === "running") {
enterSyncRunningState();
emitSyncStatus(status);
startSyncPolling();
} else if (status.state === "success") {
applySyncSuccess(status);
} else if (status.state === "failed") {
applySyncFailure(status);
} else {
applySyncIdle(status); // idle: settle + the idle frame
}
}
if (syncBtn) {
syncBtn.addEventListener("click", startSync);
initSyncButton(); // re-attach to a running / last sync run (admin only)
}