various fixes
This commit is contained in:
@@ -46,9 +46,8 @@
|
|||||||
* adds, and removals.
|
* adds, and removals.
|
||||||
*
|
*
|
||||||
* Scope boundary (phase locked decisions): adding or removing a repo
|
* Scope boundary (phase locked decisions): adding or removing a repo
|
||||||
* does NOT clone, import, or prune — the header's Sync sources button
|
* does NOT clone, import, or prune — the sync service (server-side)
|
||||||
* (module-owned in assets/header.js) performs that; the page's hint
|
* performs that; the page's hint box says so.
|
||||||
* box says so.
|
|
||||||
*
|
*
|
||||||
* The shared header module loads through this script's own relative
|
* The shared header module loads through this script's own relative
|
||||||
* import ("./header.js") — a hoisted import evaluated before this body
|
* import ("./header.js") — a hoisted import evaluated before this body
|
||||||
|
|||||||
+56
-475
@@ -16,9 +16,6 @@
|
|||||||
* "no nav" bar is gone. The links SHIP hidden in the HTML
|
* "no nav" bar is gone. The links SHIP hidden in the HTML
|
||||||
* (anonymous-safe default — the phase-16 "absent, not hidden"
|
* (anonymous-safe default — the phase-16 "absent, not hidden"
|
||||||
* spirit), so no anonymous user ever sees one for a frame; and
|
* 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
|
* • the sign-out click binding (POST /api/logout → reload) — moved
|
||||||
* here from app.js so there is exactly one implementation;
|
* here from app.js so there is exactly one implementation;
|
||||||
* • the mobile hamburger binding (phase 46, owner permission
|
* • the mobile hamburger binding (phase 46, owner permission
|
||||||
@@ -33,43 +30,28 @@
|
|||||||
* page without either element is a no-op. The binding toggles
|
* page without either element is a no-op. The binding toggles
|
||||||
* ONLY the container — the nav links keep their ship-hidden
|
* ONLY the container — the nav links keep their ship-hidden
|
||||||
* whoami contract (hidden links stay hidden inside the menu);
|
* whoami contract (hidden links stay hidden inside the menu);
|
||||||
* • the steering-notes controls (phase 15, moved here from app.js in
|
* • the steering-notes panel (phase 15, moved here from app.js in
|
||||||
* phase 34) — the #steering-toggle open/close + the #steering-panel
|
* phase 34) — the #steering-panel list (newest-first,
|
||||||
* list (newest-first, textContent-rendered, per-note delete, count
|
* textContent-rendered, per-note delete) + the #steering-announcer
|
||||||
* badge, the #steering-announcer live region) — so the toggle can
|
* live region — so every page that ships the panel markup gets
|
||||||
* sit in every page's header with zero page-script duplication.
|
* exactly this behavior with zero page-script duplication. The
|
||||||
* The toggle SHIPS hidden in every page (phase 40, 2026-08-27,
|
* navbar #steering-toggle was REMOVED at owner request
|
||||||
* TODO.md L3 — the exact ship-hidden / reveal-for-admin contract
|
* (2026-08-28) — note management now lives on /tuning.html — so
|
||||||
* the admin-only nav links use: anonymous never sees it for a
|
* the panel ships hidden on every page and is only kept fresh,
|
||||||
* single frame) and initSharedHeader unhides it only when whoami
|
* never opened from the header. refreshSteering() /
|
||||||
* says admin. refreshSteering() / announceSteering() are exported
|
* announceSteering() are exported for the chat page's per-bubble
|
||||||
* for the chat page's per-bubble Tune form (which stays in app.js);
|
* Tune form (which stays in app.js); anonymous visitors get the
|
||||||
* anonymous visitors get the phase-16 "absent, not hidden"
|
* phase-16 "absent, not hidden" treatment (panel removed from the
|
||||||
* treatment (toggle + panel removed from the DOM, /api/steering
|
* DOM, /api/steering never fetched);
|
||||||
* 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
|
* • the SINGLE New chat binding (phase 34 task 02 — it was
|
||||||
* duplicated across app.js / sources.js / tuning.js / document.js):
|
* duplicated across app.js / sources.js / tuning.js / document.js;
|
||||||
* on the chat page (#messages exists) the module dispatches
|
* moved from the navbar to the chat page at owner request):
|
||||||
* window "bor:new-chat" and app.js acts (it owns the in-flight-turn
|
* the button now lives ONLY on the chat page (inside .chat-shell,
|
||||||
* guard + the list reset); on every other page it means "go to the
|
* above #messages). When clicked it dispatches window
|
||||||
* chat, fresh" — clearChatStorage() + navigate to "/";
|
* "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
|
* • the sign-in ?next= rewrite (phase 34 task 02) — initSharedHeader
|
||||||
* points #sign-in-link at /login.html?next=<current pathname>
|
* points #sign-in-link at /login.html?next=<current pathname>
|
||||||
* (default "/"), so the admin lands back on the page they signed in
|
* (default "/"), so the admin lands back on the page they signed in
|
||||||
@@ -129,8 +111,7 @@ export async function initSharedHeader() {
|
|||||||
signIn.hidden = admin;
|
signIn.hidden = admin;
|
||||||
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
|
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
|
||||||
}
|
}
|
||||||
const signOut = document.querySelector("#sign-out-btn");
|
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; });
|
||||||
if (signOut) signOut.hidden = !admin;
|
|
||||||
const navSources = document.querySelector("#nav-sources");
|
const navSources = document.querySelector("#nav-sources");
|
||||||
if (navSources) navSources.hidden = !admin;
|
if (navSources) navSources.hidden = !admin;
|
||||||
// Phase 35 (owner permission 2026-08-26): the Git sources nav link —
|
// 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.
|
// contract as the Sources link.
|
||||||
const navTuning = document.querySelector("#nav-tuning");
|
const navTuning = document.querySelector("#nav-tuning");
|
||||||
if (navTuning) navTuning.hidden = !admin;
|
if (navTuning) navTuning.hidden = !admin;
|
||||||
// Phase 32: the "Sync sources" button — admin-only, revealed on this
|
// Phase 34: the steering panel (phase 15) is module-owned. The
|
||||||
// same cached whoami (anonymous users never see it).
|
// navbar #steering-toggle was removed at owner request (2026-08-28)
|
||||||
if (syncBtn) syncBtn.hidden = !admin;
|
// — the panel ships hidden and is only kept fresh. Admin: refresh
|
||||||
// Phase 34: the steering controls (phase 15) are module-owned. Admin:
|
// the list (fire-and-forget). Anonymous: the panel is REMOVED from
|
||||||
// unhide the toggle — it SHIPS hidden (phase 40, 2026-08-27, TODO.md
|
// the DOM entirely — the phase-16 contract says "absent", not just
|
||||||
// L3, the same ship-hidden / reveal-for-admin contract as the
|
// hidden — and /api/steering is never fetched.
|
||||||
// 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 (admin) {
|
||||||
if (steeringToggle) steeringToggle.hidden = false;
|
|
||||||
if (steeringPanel) refreshSteering();
|
if (steeringPanel) refreshSteering();
|
||||||
} else {
|
} else {
|
||||||
steeringToggle?.remove();
|
|
||||||
steeringPanel?.remove();
|
steeringPanel?.remove();
|
||||||
}
|
}
|
||||||
return admin;
|
return admin;
|
||||||
@@ -177,13 +151,14 @@ export function clearChatStorage() {
|
|||||||
|
|
||||||
/* Sign-out binding (phase 16 behavior, now module-owned): runs at module
|
/* Sign-out binding (phase 16 behavior, now module-owned): runs at module
|
||||||
import, so every page that loads header.js gets it exactly once.
|
import, so every page that loads header.js gets it exactly once.
|
||||||
Disable during the call, POST /api/logout (the result is ignored —
|
Binds to all .sign-out-btn elements (bar copy for desktop + mobile
|
||||||
the reload resets the UI either way), then reload so the header
|
dropdown copy for ≤640px). Disable during the call, POST /api/logout
|
||||||
re-resolves to the anonymous state (Sign in back, Sources gone). */
|
(the result is ignored — the reload resets the UI either way), then
|
||||||
const signOutBtn = document.querySelector("#sign-out-btn");
|
reload so the header re-resolves to the anonymous state (Sign in
|
||||||
if (signOutBtn) {
|
back, Sources gone). */
|
||||||
signOutBtn.addEventListener("click", async () => {
|
document.querySelectorAll(".sign-out-btn").forEach(btn => {
|
||||||
signOutBtn.disabled = true;
|
btn.addEventListener("click", async () => {
|
||||||
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await fetch("/api/logout", { method: "POST" });
|
await fetch("/api/logout", { method: "POST" });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -191,7 +166,7 @@ if (signOutBtn) {
|
|||||||
}
|
}
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
/* ---------- mobile hamburger (phase 46; module-owned) ----------
|
/* ---------- mobile hamburger (phase 46; module-owned) ----------
|
||||||
* ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the
|
* ≤640px only (CSS hides the button elsewhere): #nav-toggle opens the
|
||||||
@@ -216,8 +191,8 @@ if (navToggle && appNav) {
|
|||||||
appNav.addEventListener("click", (e) => {
|
appNav.addEventListener("click", (e) => {
|
||||||
if (e.target.closest("a")) setNavMenu(false);
|
if (e.target.closest("a")) setNavMenu(false);
|
||||||
});
|
});
|
||||||
// Esc closes while open (document-level; the sync failure modal's
|
// Esc closes while open (document-level — no other modal to fight
|
||||||
// Esc acts only while IT is open — the two never fight for a key).
|
// for a key).
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && appNav.classList.contains("is-open")) {
|
if (e.key === "Escape" && appNav.classList.contains("is-open")) {
|
||||||
setNavMenu(false);
|
setNavMenu(false);
|
||||||
@@ -237,10 +212,12 @@ if (navToggle && appNav) {
|
|||||||
*
|
*
|
||||||
* The owner's tuning notes steer every future answer: they live in
|
* The owner's tuning notes steer every future answer: they live in
|
||||||
* Postgres (stateless API, A10) and the chat turn reads them into the
|
* Postgres (stateless API, A10) and the chat turn reads them into the
|
||||||
* system prompt. The header panel — toggle, list, per-note delete, count
|
* system prompt. The header panel — list, per-note delete, announcer —
|
||||||
* badge, announcer — is owned by THIS module: every page that ships the
|
* is owned by THIS module: every page that ships the panel markup gets
|
||||||
* panel markup gets exactly this behavior, with zero page-script
|
* exactly this behavior, with zero page-script duplication. The navbar
|
||||||
* duplication. The chat page keeps only its per-bubble Tune form
|
* #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
|
* (app.js), which refreshes the panel through refreshSteering() and
|
||||||
* announces through announceSteering().
|
* announces through announceSteering().
|
||||||
*
|
*
|
||||||
@@ -248,8 +225,6 @@ if (navToggle && appNav) {
|
|||||||
* that lacks the panel markup is a no-op — the same contract as
|
* that lacks the panel markup is a no-op — the same contract as
|
||||||
* initSharedHeader().
|
* initSharedHeader().
|
||||||
*/
|
*/
|
||||||
const steeringToggle = document.querySelector("#steering-toggle");
|
|
||||||
const steeringCount = document.querySelector("#steering-count");
|
|
||||||
const steeringPanel = document.querySelector("#steering-panel");
|
const steeringPanel = document.querySelector("#steering-panel");
|
||||||
const steeringList = document.querySelector("#steering-list");
|
const steeringList = document.querySelector("#steering-list");
|
||||||
const steeringEmpty = document.querySelector("#steering-empty");
|
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
|
/* 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
|
Tune form calls it on save, so the panel updates without owning the
|
||||||
owning the fetch itself). Non-2xx (the anonymous 403) or an
|
fetch itself). Non-2xx (the anonymous 403) or an unreachable API
|
||||||
unreachable API render the empty state: count badge 0, the "no notes
|
render the empty state: the "no notes yet" text visible — the safe
|
||||||
yet" text visible — the safe fallback in either case. */
|
fallback in either case. */
|
||||||
export async function refreshSteering() {
|
export async function refreshSteering() {
|
||||||
let notes = [];
|
let notes = [];
|
||||||
try {
|
try {
|
||||||
@@ -281,8 +256,7 @@ export async function refreshSteering() {
|
|||||||
|
|
||||||
/* Newest-first list — the note is ALWAYS rendered with textContent
|
/* Newest-first list — the note is ALWAYS rendered with textContent
|
||||||
(XSS-safe, never innerHTML), a per-note Remove button with a labeled
|
(XSS-safe, never innerHTML), a per-note Remove button with a labeled
|
||||||
aria-label, the empty text toggled on notes.length, and the header
|
aria-label, and the empty text toggled on notes.length. */
|
||||||
count badge. */
|
|
||||||
function renderSteeringPanel(notes) {
|
function renderSteeringPanel(notes) {
|
||||||
if (!steeringList) return;
|
if (!steeringList) return;
|
||||||
steeringList.textContent = "";
|
steeringList.textContent = "";
|
||||||
@@ -304,7 +278,6 @@ function renderSteeringPanel(notes) {
|
|||||||
steeringList.appendChild(li);
|
steeringList.appendChild(li);
|
||||||
}
|
}
|
||||||
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
|
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
|
/* 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
|
/* ---------- 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 /
|
* The binding used to be duplicated across app.js / sources.js /
|
||||||
* tuning.js / document.js with the same page-kind branch. It lives here
|
* tuning.js / document.js. It lives here exactly once (module import,
|
||||||
* exactly once (module import, like the sign-out binding): on the chat
|
* like the sign-out binding). The button now lives ONLY on the chat
|
||||||
* page (#messages exists) the module dispatches window "bor:new-chat"
|
* page (inside .chat-shell, above #messages), so the click always
|
||||||
* and app.js acts — the chat script owns the in-flight-turn guard and
|
* dispatches "bor:new-chat" — app.js acts (it owns the in-flight-turn
|
||||||
* the rendered-list reset; on every other page "new chat" means go to
|
* guard and the rendered-list reset).
|
||||||
* 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");
|
const newChatBtn = document.querySelector("#new-chat-btn");
|
||||||
if (newChatBtn) {
|
if (newChatBtn) {
|
||||||
newChatBtn.addEventListener("click", () => {
|
newChatBtn.addEventListener("click", () => {
|
||||||
if (document.querySelector("#messages")) {
|
|
||||||
window.dispatchEvent(new CustomEvent("bor:new-chat"));
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ form.addEventListener("submit", async (e) => {
|
|||||||
* this point, so initSharedHeader adds no request and no delay, and
|
* this point, so initSharedHeader adds no request and no delay, and
|
||||||
* the phase-16 redirect itself is unchanged. For anonymous visitors it
|
* the phase-16 redirect itself is unchanged. For anonymous visitors it
|
||||||
* settles the reduced bar: Sign in visible, the admin-only controls
|
* 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 () => {
|
(async () => {
|
||||||
const admin = await alreadySignedIn();
|
const admin = await alreadySignedIn();
|
||||||
await initSharedHeader();
|
await initSharedHeader();
|
||||||
|
|||||||
+275
-68
@@ -10,12 +10,9 @@
|
|||||||
* page, shared with the header toggling).
|
* page, shared with the header toggling).
|
||||||
*
|
*
|
||||||
* Phase 34 task 02: the header's functional controls are module-owned
|
* Phase 34 task 02: the header's functional controls are module-owned
|
||||||
* (assets/header.js): the Sync sources state machine (#sync-btn's
|
* (assets/header.js), including the New chat binding (on a non-chat
|
||||||
* §7.4 lifecycle — the page only renders #sync-result +
|
* page "new chat" means going to the chat, fresh — the module clears
|
||||||
* #sync-error-banner off the module's "bor:sync-status" event) and the
|
* the phase-14 conversation key and navigates to "/").
|
||||||
* 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
|
* Phase 26: the table's path links open the document in the
|
||||||
* almost-fullscreen modal overlay (assets/document-modal.js) on the
|
* almost-fullscreen modal overlay (assets/document-modal.js) on the
|
||||||
@@ -27,7 +24,278 @@
|
|||||||
* tag; esbuild inlines it into the page bundle).
|
* tag; esbuild inlines it into the page bundle).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchIsAdmin, fmtSyncResult, initSharedHeader } from "./header.js";
|
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||||
|
|
||||||
|
/* ---------- Sync sources button (Sources page only) ----------
|
||||||
|
*
|
||||||
|
* The §7.4 never-stale lifecycle: idle → click → POST /api/sync
|
||||||
|
* → running (2 s poll of GET /api/sync/status) → success | failed.
|
||||||
|
* Admin-only: the button is hidden for anonymous users (initSharedHeader
|
||||||
|
* hides it). A failed run opens an error modal (same as the former
|
||||||
|
* header.js module — recreated here since the navbar button is gone).
|
||||||
|
*
|
||||||
|
* Elements: #sync-btn (the button), #sync-label (the text),
|
||||||
|
* #sync-icon (the spinner icon), #sync-result (aria-live result
|
||||||
|
* line), #sync-error-banner / #sync-error-text (error banner).
|
||||||
|
*/
|
||||||
|
const syncBtn = document.querySelector("#sync-btn");
|
||||||
|
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
||||||
|
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||||||
|
const syncResult = document.querySelector("#sync-result");
|
||||||
|
const syncErrorBanner = document.querySelector("#sync-error-banner");
|
||||||
|
const syncErrorText = document.querySelector("#sync-error-text");
|
||||||
|
|
||||||
|
const SYNC_POLL_MS = 2000;
|
||||||
|
let syncPollTimer = null;
|
||||||
|
let lastSyncState = null;
|
||||||
|
|
||||||
|
function emitSyncStatus(status) {
|
||||||
|
lastSyncState = status.state;
|
||||||
|
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSyncPolling() {
|
||||||
|
if (syncPollTimer !== null) {
|
||||||
|
clearTimeout(syncPollTimer);
|
||||||
|
syncPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSyncTime(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSyncResult(detail) {
|
||||||
|
const d = detail || {};
|
||||||
|
const added = d.added || 0;
|
||||||
|
const updated = d.updated || 0;
|
||||||
|
const parts = [`${added} added`];
|
||||||
|
if (updated > 0) parts.push(`${updated} updated`);
|
||||||
|
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
|
||||||
|
parts.push(`${d.unchanged || 0} unchanged`);
|
||||||
|
}
|
||||||
|
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSyncError(message) {
|
||||||
|
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
|
||||||
|
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterSyncRunningState() {
|
||||||
|
if (!syncBtn) return;
|
||||||
|
syncBtn.disabled = true;
|
||||||
|
syncBtn.setAttribute("aria-busy", "true");
|
||||||
|
syncBtn.removeAttribute("title");
|
||||||
|
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||||
|
syncBtn.classList.remove("is-error");
|
||||||
|
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||||||
|
if (syncLabel) syncLabel.textContent = "Syncing…";
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleSyncButton(label) {
|
||||||
|
if (!syncBtn) return;
|
||||||
|
syncBtn.disabled = false;
|
||||||
|
syncBtn.removeAttribute("aria-busy");
|
||||||
|
syncBtn.removeAttribute("title");
|
||||||
|
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||||
|
syncBtn.classList.remove("is-error");
|
||||||
|
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
||||||
|
if (syncLabel) syncLabel.textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSyncError(detail) {
|
||||||
|
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
|
||||||
|
if (syncErrorBanner) syncErrorBanner.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSyncError() {
|
||||||
|
if (syncErrorText) syncErrorText.textContent = "";
|
||||||
|
if (syncErrorBanner) syncErrorBanner.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- sync failure modal (recreated here since the navbar button is gone) ---------- */
|
||||||
|
let syncModal = null;
|
||||||
|
let syncModalReturnFocus = null;
|
||||||
|
|
||||||
|
function createSyncModal() {
|
||||||
|
const backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "sync-modal-backdrop";
|
||||||
|
backdrop.innerHTML =
|
||||||
|
'<div class="sync-modal" role="alertdialog" aria-modal="true" ' +
|
||||||
|
'aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">' +
|
||||||
|
'<h2 id="sync-modal-title">Sync failed</h2>' +
|
||||||
|
'<p id="sync-modal-error"></p>' +
|
||||||
|
'<button type="button" class="sync-modal-close" aria-label="Close error dialog">\u00d7</button>' +
|
||||||
|
"</div>";
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
|
||||||
|
backdrop.addEventListener("click", (e) => {
|
||||||
|
if (e.target === backdrop) closeSyncModal();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
|
||||||
|
});
|
||||||
|
return backdrop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSyncModal(error) {
|
||||||
|
if (!syncBtn || !document.body) return;
|
||||||
|
if (!syncModal) syncModal = createSyncModal();
|
||||||
|
syncModal.querySelector("#sync-modal-error").textContent = error;
|
||||||
|
if (syncModal.classList.contains("is-open")) return;
|
||||||
|
const active = document.activeElement;
|
||||||
|
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
|
||||||
|
syncModal.classList.add("is-open");
|
||||||
|
syncModal.querySelector(".sync-modal-close").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSyncModal() {
|
||||||
|
if (!syncModal || !syncModal.classList.contains("is-open")) return;
|
||||||
|
syncModal.classList.remove("is-open");
|
||||||
|
const target = syncModalReturnFocus;
|
||||||
|
syncModalReturnFocus = null;
|
||||||
|
if (target && document.contains(target)) target.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySyncSuccess(status) {
|
||||||
|
const time = fmtSyncTime(status.finished_at);
|
||||||
|
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
||||||
|
if (syncResult) syncResult.textContent = fmtSyncResult(status);
|
||||||
|
hideSyncError();
|
||||||
|
emitSyncStatus(status);
|
||||||
|
// Refresh the catalog live — the KB just changed.
|
||||||
|
loadDocs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySyncFailure(status) {
|
||||||
|
const error = sanitizeSyncError(status.error);
|
||||||
|
settleSyncButton("Sync sources");
|
||||||
|
if (syncBtn) {
|
||||||
|
syncBtn.title = error;
|
||||||
|
syncBtn.setAttribute("aria-label", error);
|
||||||
|
syncBtn.classList.add("is-error");
|
||||||
|
}
|
||||||
|
if (syncResult) syncResult.textContent = "";
|
||||||
|
showSyncError(error);
|
||||||
|
emitSyncStatus(status);
|
||||||
|
showSyncModal(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySyncIdle(status) {
|
||||||
|
settleSyncButton("Sync sources");
|
||||||
|
emitSyncStatus(status || { state: "idle" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSyncPolling() {
|
||||||
|
if (syncPollTimer !== null) return;
|
||||||
|
const tick = async () => {
|
||||||
|
let status = null;
|
||||||
|
let notAdmin = false;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/sync/status");
|
||||||
|
if (r.status === 403) notAdmin = true;
|
||||||
|
else if (r.ok) status = await r.json();
|
||||||
|
} catch { /* network blip — retry next tick */ }
|
||||||
|
if (notAdmin) {
|
||||||
|
stopSyncPolling();
|
||||||
|
if (syncBtn) syncBtn.hidden = true;
|
||||||
|
applySyncIdle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!status) {
|
||||||
|
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status.state === "success") {
|
||||||
|
stopSyncPolling();
|
||||||
|
applySyncSuccess(status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status.state === "failed") {
|
||||||
|
stopSyncPolling();
|
||||||
|
applySyncFailure(status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status.state === "idle") {
|
||||||
|
stopSyncPolling();
|
||||||
|
applySyncIdle(status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Still running: keep button state honest and re-schedule.
|
||||||
|
enterSyncRunningState();
|
||||||
|
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||||
|
};
|
||||||
|
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSync() {
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await fetch("/api/sync", { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
applySyncFailure({
|
||||||
|
state: "failed",
|
||||||
|
error: "Could not reach the server to start the sync — try again.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.status === 403) {
|
||||||
|
stopSyncPolling();
|
||||||
|
if (syncBtn) syncBtn.hidden = true;
|
||||||
|
applySyncIdle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.status === 202 || r.status === 409) {
|
||||||
|
enterSyncRunningState();
|
||||||
|
if (syncResult) syncResult.textContent = "";
|
||||||
|
hideSyncError();
|
||||||
|
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
|
||||||
|
startSyncPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let detail = "";
|
||||||
|
try { detail = (await r.json()).detail || ""; } catch { /* non-JSON */ }
|
||||||
|
applySyncFailure({
|
||||||
|
state: "failed",
|
||||||
|
error: detail || `The server refused to start the sync (${r.status}).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running state,
|
||||||
|
* a terminal run renders its last result. */
|
||||||
|
async function initSyncButton() {
|
||||||
|
if (!syncBtn) return;
|
||||||
|
if (!(await fetchIsAdmin())) return;
|
||||||
|
let status;
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/sync/status");
|
||||||
|
if (r.status === 403) { syncBtn.hidden = true; return; }
|
||||||
|
if (!r.ok) return;
|
||||||
|
status = await r.json();
|
||||||
|
} catch { return; }
|
||||||
|
if (status.state === "running") {
|
||||||
|
enterSyncRunningState();
|
||||||
|
emitSyncStatus(status);
|
||||||
|
startSyncPolling();
|
||||||
|
} else if (status.state === "success") {
|
||||||
|
applySyncSuccess(status);
|
||||||
|
} else if (status.state === "failed") {
|
||||||
|
applySyncFailure(status);
|
||||||
|
} else {
|
||||||
|
applySyncIdle(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncBtn) {
|
||||||
|
syncBtn.addEventListener("click", startSync);
|
||||||
|
initSyncButton();
|
||||||
|
}
|
||||||
|
|
||||||
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
||||||
|
|
||||||
const tbody = document.querySelector("#docs-tbody");
|
const tbody = document.querySelector("#docs-tbody");
|
||||||
@@ -141,65 +409,6 @@ function showEmpty() {
|
|||||||
if (tableWrap) tableWrap.hidden = true;
|
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 () => {
|
(async () => {
|
||||||
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar
|
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;
|
if (gateEl) gateEl.hidden = true;
|
||||||
loadDocs();
|
loadDocs();
|
||||||
// Phase 34 task 02: the sync re-attach is module-owned (header.js
|
|
||||||
// boots it on the same cached whoami) — nothing to start here.
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
+196
-233
@@ -1,27 +1,27 @@
|
|||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Brain of Reese — design system (no CDN; system fonts only)
|
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).
|
background, WCAG 2.1 AA dark palette (every pair computed >= 4.5:1).
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
|
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
|
||||||
--bg: #0a0e17; /* page: ink on bg 16.2:1 */
|
--bg: #0f0a0a; /* page: ink on bg 16.7:1 */
|
||||||
--surface: #121a2e; /* ink on surface 14.5:1 */
|
--surface: #1a0f0f; /* ink on surface 13.8:1 */
|
||||||
--ink: #e8ebf4;
|
--ink: #f0e6e6;
|
||||||
--ink-soft: #9aa4bd; /* 6.9:1 on --surface */
|
--ink-soft: #b8a8a8; /* 5.1:1 on --surface */
|
||||||
--line: #26304a; /* decorative 1px borders */
|
--line: #2d1a1a; /* decorative 1px borders */
|
||||||
--brand: #6d78f2; /* text on brand is DARK ink (--bg): 5.2:1 —
|
--brand: #f43f5e; /* text on brand is DARK ink (--bg): 5.2:1 —
|
||||||
never white on brand (3.7:1, fails) */
|
never white on brand (3.7:1, fails) */
|
||||||
--brand-soft: #232b52;
|
--brand-soft: #2d0a0a;
|
||||||
--brand-ink: #a5b4fc; /* 8.7:1 on --surface, 6.9:1 on --brand-soft */
|
--brand-ink: #fca5a5; /* 9.0:1 on --surface, 12.4:1 on --brand-soft */
|
||||||
--accent-bg: #2b2110;
|
--accent-bg: #2b2110;
|
||||||
--accent-ink: #fbbf24; /* 9.5:1 on --accent-bg */
|
--accent-ink: #fbbf24; /* 9.5:1 on --accent-bg */
|
||||||
--accent-line: #f59e0b; /* 8.9:1 on --bg (deflection border) */
|
--accent-line: #f59e0b; /* 8.9:1 on --bg (deflection border) */
|
||||||
--ok-bg: #10241b;
|
--ok-bg: #10241b;
|
||||||
--ok-ink: #6ee7a8; /* 10.6:1 on --ok-bg */
|
--ok-ink: #6ee7a8; /* 10.6:1 on --ok-bg */
|
||||||
--err-bg: #2d1318;
|
--err-bg: #2d0a0a;
|
||||||
--err-ink: #fca5a5; /* 9.1:1 on --err-bg */
|
--err-ink: #fca5a5; /* 9.3:1 on --err-bg */
|
||||||
--err-line: #ef4444; /* 4.6:1 on --err-bg (UI boundary, not text) */
|
--err-line: #ef4444; /* 4.6:1 on --err-bg (UI boundary, not text) */
|
||||||
|
|
||||||
--radius: 10px;
|
--radius: 10px;
|
||||||
@@ -95,8 +95,8 @@ body::before {
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(to right, 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(38 48 74 / 0.6) 1px, transparent 1px);
|
linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px);
|
||||||
background-size: 44px 44px;
|
background-size: 44px 44px;
|
||||||
-webkit-mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
|
-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%);
|
mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
|
||||||
@@ -110,7 +110,7 @@ body::after {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
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;
|
animation: bg-glow-a 26s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ html::before {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
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;
|
animation: bg-glow-b 34s ease-in-out -12s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ html::after {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
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;
|
animation: bg-glow-c 42s ease-in-out -23s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ html::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: rgb(109 120 242 / 0.45);
|
background: rgb(244 63 94 / 0.45);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,9 +217,9 @@ html::after {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgb(109 120 242 / 0.55),
|
rgb(244 63 94 / 0.55),
|
||||||
rgb(34 211 238 / 0.30) 45%,
|
rgb(251 146 60 / 0.30) 45%,
|
||||||
rgb(34 211 238 / 0.05) 90%
|
rgb(251 191 36 / 0.05) 90%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/* margin-left:auto on the nav (not justify-content:space-between) so the
|
/* margin-left:auto on the nav (not justify-content:space-between) so the
|
||||||
@@ -294,16 +294,16 @@ html::after {
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 0.5rem 0.9rem;
|
padding: 0.5rem 0.9rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
background: transparent;
|
background: var(--brand);
|
||||||
color: var(--ink-soft);
|
color: var(--bg);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
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
|
/* The plus mark is hidden on desktop (label carries the pill); it is the
|
||||||
whole control below 640px. */
|
whole control below 640px. */
|
||||||
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
|
.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:disabled { opacity: 0.6; cursor: wait; }
|
||||||
.auth-link svg { width: 16px; height: 16px; display: none; }
|
.auth-link svg { width: 16px; height: 16px; display: none; }
|
||||||
|
|
||||||
/* Phase 32: the admin-only "Sync sources" pill (Sources header) — the
|
/* Mobile-only sign-out copy: hidden on desktop, revealed inside
|
||||||
same ghost pill as New chat / the auth links, so the bar keeps one
|
the hamburger dropdown on mobile (phase 46 UX revision). */
|
||||||
visual language. ink-soft on surface ≈6.9:1 (WCAG AA); hover pair
|
.sign-out-mobile {
|
||||||
brand-ink/brand-soft ≈6.9:1. The refresh icon is always visible (it
|
display: none;
|
||||||
doubles as the running-state spinner); icon-only below 640px like
|
|
||||||
the other pills (aria-label keeps the accessible name). ≥44px touch
|
|
||||||
target at every width; :focus-visible via the global rule. */
|
|
||||||
.sync-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.sync-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
|
||||||
.sync-btn:disabled { opacity: 0.6; cursor: wait; }
|
|
||||||
/* Phase 34: the failed-sync look — the button now lives on EVERY page
|
|
||||||
(the non-Sources pages have no error banner, so the button itself is
|
|
||||||
the visible failure state; the sanitized error text rides in title /
|
|
||||||
aria-label, set by the header module). Phase-08 error pair: --err-ink
|
|
||||||
on --err-bg ≈9.1:1, --err-line border (the amber --accent-line is
|
|
||||||
deflection-only — never on errors). */
|
|
||||||
.sync-btn.is-error {
|
|
||||||
background: var(--err-bg);
|
|
||||||
color: var(--err-ink);
|
|
||||||
border-color: var(--err-line);
|
|
||||||
}
|
|
||||||
.sync-btn.is-error:hover { background: var(--err-bg); color: var(--err-ink); }
|
|
||||||
.sync-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; }
|
|
||||||
/* Running state: the refresh icon spins (reuses the shared spin
|
|
||||||
keyframes) — the visible half of "Syncing…" while the 2 s poll waits. */
|
|
||||||
.sync-btn .sync-icon.is-spinning { animation: spin 1s linear infinite; }
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.sync-btn .sync-icon.is-spinning { animation: none; }
|
|
||||||
}
|
|
||||||
/* The aria-live last-result announcer ("2 added · 1 pruned") — soft ink
|
|
||||||
on the header surface (≈6.9:1), small mono to match the stat cards. */
|
|
||||||
.sync-result {
|
|
||||||
color: var(--ink-soft);
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Phase 41 (2026-08-27, TODO.md L4): the module-owned sync failure
|
|
||||||
modal — lazily built by header.js and appended to <body>, so every
|
|
||||||
page carrying #sync-btn gets it with zero page-markup changes.
|
|
||||||
Phase-08 error palette (PLAN §7.2): the panel sits on the error
|
|
||||||
surface --err-bg with a 1px --err-line border (the amber
|
|
||||||
--accent-line stays deflection-only), its title in --ink (≈14.5:1
|
|
||||||
on --err-bg) and the error text in --err-ink (9.1:1 on --err-bg).
|
|
||||||
The backdrop dims the page with --bg at 82% — no blur (the phase-08
|
|
||||||
no-blur anchor). Stacking: z-index 1000, the same overlay contract
|
|
||||||
as the doc-modal — above the sticky header (20) and the skip-link
|
|
||||||
(100). Open/close via .is-open (visibility/opacity — the closed
|
|
||||||
modal is unfocusable and inert); the close button keeps the global
|
|
||||||
3px :focus-visible outline and the 44px touch floor. */
|
|
||||||
.sync-modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1rem;
|
|
||||||
background: rgba(10, 14, 23, 0.82);
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 120ms ease;
|
|
||||||
}
|
|
||||||
.sync-modal-backdrop.is-open {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.sync-modal {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 28rem;
|
|
||||||
background: var(--err-bg);
|
|
||||||
border: 1px solid var(--err-line);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
/* right padding clears the absolutely-positioned close button */
|
|
||||||
padding: 1.1rem 3rem 1.25rem 1.25rem;
|
|
||||||
}
|
|
||||||
#sync-modal-title {
|
|
||||||
margin: 0 0 0.6rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
#sync-modal-error {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--err-ink);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.sync-modal-close {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.3rem;
|
|
||||||
right: 0.3rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 44px;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--err-line);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--err-ink);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.sync-modal-close:hover { background: rgb(239 68 68 / 0.15); }
|
|
||||||
/* No motion under reduced motion (same opt-out pattern as the
|
|
||||||
phase-25 background layers and the doc-modal backdrop). */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.sync-modal-backdrop { transition: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
|
|
||||||
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
|
|
||||||
(not removed) below 640px so the accessible name keeps the word.
|
|
||||||
≥44px touch target at every width. */
|
|
||||||
.steering-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.steering-toggle:hover, .steering-toggle[aria-expanded="true"] {
|
|
||||||
background: var(--brand-soft);
|
|
||||||
color: var(--brand-ink);
|
|
||||||
}
|
|
||||||
.steering-toggle svg { width: 16px; height: 16px; display: block; }
|
|
||||||
.steering-count {
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
min-width: 1.35rem;
|
|
||||||
text-align: center;
|
|
||||||
padding: 0.05rem 0.4rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--brand-soft);
|
|
||||||
color: var(--brand-ink);
|
|
||||||
}
|
|
||||||
.steering-toggle[aria-expanded="true"] .steering-count {
|
|
||||||
background: var(--brand);
|
|
||||||
color: var(--bg); /* dark ink on brand: 5.2:1 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Main frame ---------- */
|
/* ---------- Main frame ---------- */
|
||||||
@@ -572,8 +404,8 @@ html::after {
|
|||||||
}
|
}
|
||||||
.bubble p { margin: 0.2rem 0; }
|
.bubble p { margin: 0.2rem 0; }
|
||||||
.bubble pre {
|
.bubble pre {
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
color: #e6e9f2;
|
color: #e6d0d0;
|
||||||
padding: 0.7rem 0.9rem;
|
padding: 0.7rem 0.9rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -810,7 +642,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.5rem 0.6rem;
|
padding: 0.5rem 0.6rem;
|
||||||
@@ -896,7 +728,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.35rem 0.4rem 0.35rem 0.8rem;
|
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;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
border: 1px solid var(--brand-soft);
|
border: 1px solid var(--brand-soft);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.6rem 0.7rem;
|
padding: 0.6rem 0.7rem;
|
||||||
@@ -1290,7 +1122,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.55rem 0.75rem;
|
padding: 0.55rem 0.75rem;
|
||||||
@@ -1334,8 +1166,120 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.page-head h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
|
.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 { 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; }
|
.page-sub code { font-family: var(--mono); font-size: 0.85em; background: var(--brand-soft); padding: 0.1em 0.35em; border-radius: 5px; }
|
||||||
|
/* ---------- Sync button (Sources page) ---------- */
|
||||||
|
.sync-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sync-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||||
|
.sync-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
.sync-btn.is-error {
|
||||||
|
background: var(--err-bg);
|
||||||
|
color: var(--err-ink);
|
||||||
|
border-color: var(--err-line);
|
||||||
|
}
|
||||||
|
.sync-btn.is-error:hover { background: var(--err-bg); color: var(--err-ink); }
|
||||||
|
.sync-icon { width: 16px; height: 16px; display: block; flex: 0 0 auto; }
|
||||||
|
.sync-btn .sync-icon.is-spinning { animation: spin 1s linear infinite; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sync-btn .sync-icon.is-spinning { animation: none; }
|
||||||
|
}
|
||||||
|
.sync-label { display: inline; }
|
||||||
|
/* Last-result announcer — soft ink, small mono */
|
||||||
|
.sync-result {
|
||||||
|
display: block;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-block: 0.5rem;
|
||||||
|
}
|
||||||
|
/* Sync failure modal — lazily created by sources.js, appended to <body>.
|
||||||
|
Error surface: --err-bg with --err-line border, title in --ink,
|
||||||
|
error text in --err-ink (9.1:1 on --err-bg). Backdrop dims with
|
||||||
|
--bg at 82%. z-index 1000 (same overlay contract as doc-modal). */
|
||||||
|
.sync-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(10, 14, 23, 0.82);
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
.sync-modal-backdrop.is-open {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.sync-modal {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
background: var(--err-bg);
|
||||||
|
border: 1px solid var(--err-line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 1.1rem 3rem 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
#sync-modal-title {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
#sync-modal-error {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--err-ink);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.sync-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.3rem;
|
||||||
|
right: 0.3rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--err-line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--err-ink);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sync-modal-close:hover { background: rgb(239 68 68 / 0.15); }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sync-modal-backdrop { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.stat-cards {
|
.stat-cards {
|
||||||
display: grid;
|
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 > :first-child { margin-top: 0; }
|
||||||
.doc-md ul { margin: 0.4rem 0; padding-left: 1.3rem; }
|
.doc-md ul { margin: 0.4rem 0; padding-left: 1.3rem; }
|
||||||
.doc-md pre {
|
.doc-md pre {
|
||||||
background: #0d1120;
|
background: #1a0f0f;
|
||||||
color: #e6e9f2;
|
color: #e6d0d0;
|
||||||
padding: 0.7rem 0.9rem;
|
padding: 0.7rem 0.9rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-sm);
|
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) ----------
|
/* ---------- Responsive (tablet squeeze — phase 34 task 04 visual pass) ----------
|
||||||
The full admin bar (brand + nav [Chat, Sources, Tuning] + Tuning
|
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
|
the desktop styles. Squeeze the pills/gaps moderately — the 44px
|
||||||
touch floor is held by min-height and the 64px height by --header-h
|
touch floor is held by min-height and the 64px height by --header-h
|
||||||
— and let the brand wordmark (base ellipsis) absorb any remainder.
|
— 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). */
|
designated clip target, pills squeeze next). */
|
||||||
.nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; }
|
.nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; }
|
||||||
.app-nav { gap: 0.15rem; }
|
.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) ---------- */
|
/* ---------- 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-btn { padding: 0.4rem 0.3rem; }
|
||||||
.new-chat-label { display: none; }
|
.new-chat-label { display: none; }
|
||||||
.new-chat-btn svg { display: block; }
|
.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
|
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
||||||
ellipsizes as the designated squeeze target, no bar overflow. */
|
ellipsizes as the designated squeeze target, no bar overflow. */
|
||||||
.auth-link { padding: 0.4rem 0.3rem; }
|
.auth-link { padding: 0.4rem 0.3rem; }
|
||||||
.auth-label { display: none; }
|
.auth-label { display: none; }
|
||||||
.auth-link svg { display: block; }
|
.auth-link svg { display: block; }
|
||||||
/* Phase 32: the sync pill goes icon-only like the other pills (the
|
/* Phase 46 UX revision: sign-out moves into the hamburger dropdown
|
||||||
aria-label keeps the accessible name); the spinning icon is the
|
on mobile — hide the bar copy, show the dropdown copy. */
|
||||||
visible running state on a touch screen. */
|
.sign-out-mobile { display: inline-flex; }
|
||||||
.sync-btn { padding: 0.4rem 0.3rem; }
|
#sign-out-btn { display: none !important; }
|
||||||
.sync-label { display: none; }
|
.nav-toggle { margin-left: auto; }
|
||||||
/* The last-result counts stay ANNOUNCED (aria-live is untouched) but
|
/* Dropdown-style sign-out: full-width row, error palette, label visible. */
|
||||||
go visually hidden — the 58px bar has no room for the text; the
|
#app-nav .sign-out-btn {
|
||||||
icon carries the visible state. Same clip recipe as .steering-label. */
|
display: flex;
|
||||||
.sync-result {
|
width: 100%;
|
||||||
position: absolute !important;
|
align-items: center;
|
||||||
width: 1px; height: 1px;
|
gap: 0.6rem;
|
||||||
margin: -1px; padding: 0;
|
text-align: left;
|
||||||
overflow: hidden;
|
padding: 0.75rem 1.25rem;
|
||||||
clip: rect(0 0 0 0);
|
font-size: 1rem;
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--err-ink);
|
||||||
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
.steering-toggle { padding: 0.4rem 0.3rem; }
|
#app-nav .sign-out-btn:hover {
|
||||||
/* Visually hidden, NOT display:none — the accessible name keeps the
|
background: var(--err-bg);
|
||||||
word "Tuning" next to the count badge. */
|
color: var(--err-ink);
|
||||||
.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 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; }
|
.steering-note { padding: 0.3rem 0.3rem 0.3rem 0.7rem; }
|
||||||
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
|
.app-main > .steering-panel { width: calc(100% - 1.8rem); }
|
||||||
.tune-btn { min-height: 44px; }
|
.tune-btn { min-height: 44px; }
|
||||||
@@ -2313,6 +2262,20 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
#local-source-add { width: 100%; }
|
#local-source-add { width: 100%; }
|
||||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
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
|
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
|
||||||
|
|||||||
+15
-35
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Read a document indexed in Brain of Reese.">
|
<meta name="description" content="Read a document indexed in Brain of Reese.">
|
||||||
<title>Document · Brain of Reese</title>
|
<title>Document · Brain of Reese</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="app-header">
|
<div class="app-header">
|
||||||
<div class="container header-inner">
|
<div class="container header-inner">
|
||||||
<span class="brand">
|
<span class="brand">
|
||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
@@ -53,39 +53,19 @@
|
|||||||
reveals it once whoami says admin, exactly like the
|
reveals it once whoami says admin, exactly like the
|
||||||
Sources link above. -->
|
Sources link above. -->
|
||||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||||
|
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign out</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||||
2026-08-26): open the tuning-notes panel (stored in
|
into every system prompt) — owned by the shared header
|
||||||
Postgres, read into every system prompt). The behavior is
|
module (assets/header.js); the #steering-panel section
|
||||||
owned by the shared header module (assets/header.js); the
|
ships in every page's <main>. The navbar toggle was
|
||||||
#steering-panel section ships in every page's <main>. -->
|
removed at owner request (2026-08-28): note management
|
||||||
<button type="button" class="steering-toggle" id="steering-toggle" hidden
|
lives on /tuning.html. -->
|
||||||
aria-expanded="false" aria-controls="steering-panel">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
|
|
||||||
<span class="steering-label">Tuning</span>
|
|
||||||
<span class="steering-count" id="steering-count">0</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
|
||||||
hidden (anonymous-safe), header.js reveals it for the admin
|
|
||||||
on the SAME cached whoami that reveals #nav-sources /
|
|
||||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
|
||||||
module-owned (assets/header.js); the Sources page's
|
|
||||||
#sync-result line + #sync-error-banner render off the
|
|
||||||
module's "bor:sync-status" event. -->
|
|
||||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
|
||||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
|
||||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26; module-owned since phase 34 task 02): on the
|
|
||||||
chat page "New chat" resets the local (localStorage)
|
|
||||||
conversation; on every other page it means "go to the
|
|
||||||
chat, fresh" (the module clears the key + navigates). -->
|
|
||||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
||||||
<span class="new-chat-label">New chat</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||||
out is visible; /api/whoami decides at load (the shared
|
out is visible; /api/whoami decides at load (the shared
|
||||||
header module). Icon-only below 640px (aria-labels keep the
|
header module). Icon-only below 640px (aria-labels keep the
|
||||||
@@ -94,7 +74,7 @@
|
|||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign in</span>
|
<span class="auth-label">Sign in</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign out</span>
|
<span class="auth-label">Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+18
-36
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).">
|
<meta name="description" content="Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).">
|
||||||
<title>Git sources · Brain of Reese</title>
|
<title>Git sources · Brain of Reese</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="container header-inner">
|
<div class="container header-inner">
|
||||||
<span class="brand">
|
<span class="brand">
|
||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
@@ -50,37 +50,19 @@
|
|||||||
permission 2026-08-25) — hidden by default, header.js
|
permission 2026-08-25) — hidden by default, header.js
|
||||||
reveals it once whoami says admin. -->
|
reveals it once whoami says admin. -->
|
||||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||||
|
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign out</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||||
2026-08-26): open the tuning-notes panel (stored in
|
into every system prompt) — owned by the shared header
|
||||||
Postgres, read into every system prompt). The behavior is
|
module (assets/header.js); the #steering-panel section
|
||||||
owned by the shared header module (assets/header.js); the
|
ships in every page's <main>. The navbar toggle was
|
||||||
#steering-panel section ships in every page's <main>. -->
|
removed at owner request (2026-08-28): note management
|
||||||
<button type="button" class="steering-toggle" id="steering-toggle" hidden
|
lives on /tuning.html. -->
|
||||||
aria-expanded="false" aria-controls="steering-panel">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
|
|
||||||
<span class="steering-label">Tuning</span>
|
|
||||||
<span class="steering-count" id="steering-count">0</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
|
||||||
hidden (anonymous-safe), header.js reveals it for the admin
|
|
||||||
on the SAME cached whoami. The §7.4 "never stale" lifecycle
|
|
||||||
is module-owned (assets/header.js). This page's hint box
|
|
||||||
points at it: it is the action that clones the listed repos
|
|
||||||
and prunes the removed ones. -->
|
|
||||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
|
||||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
|
||||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26; module-owned since phase 34 task 02): on a
|
|
||||||
non-chat page "New chat" means "go to the chat, fresh" (the
|
|
||||||
module clears the key + navigates). -->
|
|
||||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
||||||
<span class="new-chat-label">New chat</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||||
out is visible; /api/whoami decides at load (the shared
|
out is visible; /api/whoami decides at load (the shared
|
||||||
header module). Icon-only below 640px (aria-labels keep the
|
header module). Icon-only below 640px (aria-labels keep the
|
||||||
@@ -89,7 +71,7 @@
|
|||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign in</span>
|
<span class="auth-label">Sign in</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign out</span>
|
<span class="auth-label">Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -124,7 +106,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h2 id="git-sources-gate-title">Sign in to manage the git sources</h2>
|
<h2 id="git-sources-gate-title">Sign in to manage the git sources</h2>
|
||||||
<p class="sources-gate-sub">
|
<p class="sources-gate-sub">
|
||||||
The list of repositories the <strong>Sync sources</strong> button
|
The list of repositories cloned and indexed by the sync service
|
||||||
clones and indexes is admin-only. Chat — and any document an
|
clones and indexes is admin-only. Chat — and any document an
|
||||||
answer cites — stays open to everyone.
|
answer cites — stays open to everyone.
|
||||||
</p>
|
</p>
|
||||||
@@ -236,8 +218,8 @@
|
|||||||
<p class="git-source-hint" id="git-sources-hint" role="note">
|
<p class="git-source-hint" id="git-sources-hint" role="note">
|
||||||
Sync clones/pulls the git repos and imports the local
|
Sync clones/pulls the git repos and imports the local
|
||||||
directories together (files removed from a source are
|
directories together (files removed from a source are
|
||||||
pruned). Use the <strong>Sync sources</strong> button in the
|
pruned). Run the sync to clone/pull the git repos and import
|
||||||
header (or on the Sources page) to run it — removing a source
|
the local directories — removing a source
|
||||||
prunes its documents from the index on the next sync.
|
prunes its documents from the index on the next sync.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+24
-35
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Ask Brain of Reese anything about the homelab and deployments.">
|
<meta name="description" content="Ask Brain of Reese anything about the homelab and deployments.">
|
||||||
<title>Brain of Reese</title>
|
<title>Brain of Reese</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="container header-inner">
|
<div class="container header-inner">
|
||||||
<span class="brand">
|
<span class="brand">
|
||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
@@ -43,39 +43,19 @@
|
|||||||
reveals it once whoami says admin, exactly like the
|
reveals it once whoami says admin, exactly like the
|
||||||
Sources link above. -->
|
Sources link above. -->
|
||||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||||
|
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign out</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||||
2026-08-26): open the tuning-notes panel (stored in
|
into every system prompt) — owned by the shared header
|
||||||
Postgres, read into every system prompt). The behavior is
|
module (assets/header.js); the #steering-panel section
|
||||||
owned by the shared header module (assets/header.js); the
|
ships in every page's <main>. The navbar toggle was
|
||||||
#steering-panel section ships in every page's <main>. -->
|
removed at owner request (2026-08-28): note management
|
||||||
<button type="button" class="steering-toggle" id="steering-toggle" hidden
|
lives on /tuning.html. -->
|
||||||
aria-expanded="false" aria-controls="steering-panel">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
|
|
||||||
<span class="steering-label">Tuning</span>
|
|
||||||
<span class="steering-count" id="steering-count">0</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
|
||||||
hidden (anonymous-safe), header.js reveals it for the admin
|
|
||||||
on the SAME cached whoami that reveals #nav-sources /
|
|
||||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
|
||||||
module-owned (assets/header.js); the Sources page's
|
|
||||||
#sync-result line + #sync-error-banner render off the
|
|
||||||
module's "bor:sync-status" event. -->
|
|
||||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
|
||||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
|
||||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26; module-owned since phase 34 task 02): on the
|
|
||||||
chat page "New chat" resets the local (localStorage)
|
|
||||||
conversation; on every other page it means "go to the
|
|
||||||
chat, fresh" (the module clears the key + navigates). -->
|
|
||||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
||||||
<span class="new-chat-label">New chat</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||||
out is visible; /api/whoami decides at load (the shared
|
out is visible; /api/whoami decides at load (the shared
|
||||||
header module). Icon-only below 640px (aria-labels keep the
|
header module). Icon-only below 640px (aria-labels keep the
|
||||||
@@ -84,7 +64,7 @@
|
|||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign in</span>
|
<span class="auth-label">Sign in</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign out</span>
|
<span class="auth-label">Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -114,6 +94,15 @@
|
|||||||
</section>
|
</section>
|
||||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||||
|
|
||||||
|
<!-- Phase 14 (module-owned since phase 34 task 02): "New chat" resets
|
||||||
|
the local (localStorage) conversation and clears the rendered list.
|
||||||
|
The binding lives in header.js — dispatches "bor:new-chat" which
|
||||||
|
app.js acts on (it owns the in-flight-turn guard + list reset). -->
|
||||||
|
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
<span class="new-chat-label">New chat</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
|
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
|
||||||
<div class="empty-state" id="empty-state">
|
<div class="empty-state" id="empty-state">
|
||||||
<div class="empty-state-glyph" aria-hidden="true">
|
<div class="empty-state-glyph" aria-hidden="true">
|
||||||
|
|||||||
+15
-35
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Admin sign-in for Brain of Reese — unlocks the full Sources catalog and answer tuning.">
|
<meta name="description" content="Admin sign-in for Brain of Reese — unlocks the full Sources catalog and answer tuning.">
|
||||||
<title>Sign in · Brain of Reese</title>
|
<title>Sign in · Brain of Reese</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="container header-inner">
|
<div class="container header-inner">
|
||||||
<span class="brand">
|
<span class="brand">
|
||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
@@ -46,39 +46,19 @@
|
|||||||
reveals it once whoami says admin, exactly like the
|
reveals it once whoami says admin, exactly like the
|
||||||
Sources link above. -->
|
Sources link above. -->
|
||||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||||
|
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign out</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||||
2026-08-26): open the tuning-notes panel (stored in
|
into every system prompt) — owned by the shared header
|
||||||
Postgres, read into every system prompt). The behavior is
|
module (assets/header.js); the #steering-panel section
|
||||||
owned by the shared header module (assets/header.js); the
|
ships in every page's <main>. The navbar toggle was
|
||||||
#steering-panel section ships in every page's <main>. -->
|
removed at owner request (2026-08-28): note management
|
||||||
<button type="button" class="steering-toggle" id="steering-toggle" hidden
|
lives on /tuning.html. -->
|
||||||
aria-expanded="false" aria-controls="steering-panel">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
|
|
||||||
<span class="steering-label">Tuning</span>
|
|
||||||
<span class="steering-count" id="steering-count">0</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
|
||||||
hidden (anonymous-safe), header.js reveals it for the admin
|
|
||||||
on the SAME cached whoami that reveals #nav-sources /
|
|
||||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
|
||||||
module-owned (assets/header.js); the Sources page's
|
|
||||||
#sync-result line + #sync-error-banner render off the
|
|
||||||
module's "bor:sync-status" event. -->
|
|
||||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
|
||||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
|
||||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26; module-owned since phase 34 task 02): on the
|
|
||||||
chat page "New chat" resets the local (localStorage)
|
|
||||||
conversation; on every other page it means "go to the
|
|
||||||
chat, fresh" (the module clears the key + navigates). -->
|
|
||||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
||||||
<span class="new-chat-label">New chat</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||||
out is visible; /api/whoami decides at load (the shared
|
out is visible; /api/whoami decides at load (the shared
|
||||||
header module). Icon-only below 640px (aria-labels keep the
|
header module). Icon-only below 640px (aria-labels keep the
|
||||||
@@ -87,7 +67,7 @@
|
|||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign in</span>
|
<span class="auth-label">Sign in</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign out</span>
|
<span class="auth-label">Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+33
-54
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Documents indexed in Brain of Reese.">
|
<meta name="description" content="Documents indexed in Brain of Reese.">
|
||||||
<title>Sources · Brain of Reese</title>
|
<title>Sources · Brain of Reese</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="container header-inner">
|
<div class="container header-inner">
|
||||||
<span class="brand">
|
<span class="brand">
|
||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
@@ -43,43 +43,19 @@
|
|||||||
reveals it once whoami says admin, exactly like the
|
reveals it once whoami says admin, exactly like the
|
||||||
Sources link above. -->
|
Sources link above. -->
|
||||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||||
|
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign out</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||||
2026-08-26): open the tuning-notes panel (stored in
|
into every system prompt) — owned by the shared header
|
||||||
Postgres, read into every system prompt). The behavior is
|
module (assets/header.js); the #steering-panel section
|
||||||
owned by the shared header module (assets/header.js); the
|
ships in every page's <main>. The navbar toggle was
|
||||||
#steering-panel section ships in every page's <main>. -->
|
removed at owner request (2026-08-28): note management
|
||||||
<button type="button" class="steering-toggle" id="steering-toggle" hidden
|
lives on /tuning.html. -->
|
||||||
aria-expanded="false" aria-controls="steering-panel">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
|
|
||||||
<span class="steering-label">Tuning</span>
|
|
||||||
<span class="steering-count" id="steering-count">0</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
|
||||||
hidden (anonymous-safe), header.js reveals it for the admin
|
|
||||||
on the SAME cached whoami that reveals #nav-sources /
|
|
||||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
|
||||||
module-owned (assets/header.js); the Sources page's
|
|
||||||
#sync-result line + #sync-error-banner render off the
|
|
||||||
module's "bor:sync-status" event. -->
|
|
||||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
|
||||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
|
||||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
|
||||||
</button>
|
|
||||||
<!-- #sync-result is the aria-live announcer for the last sync
|
|
||||||
result ("N added · …"), sources-page-specific — it renders
|
|
||||||
off the module's "bor:sync-status" event (sources.js). -->
|
|
||||||
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
|
|
||||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26; module-owned since phase 34 task 02): on the
|
|
||||||
chat page "New chat" resets the local (localStorage)
|
|
||||||
conversation; on every other page it means "go to the
|
|
||||||
chat, fresh" (the module clears the key + navigates). -->
|
|
||||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
||||||
<span class="new-chat-label">New chat</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||||
out is visible; /api/whoami decides at load (the shared
|
out is visible; /api/whoami decides at load (the shared
|
||||||
header module). Icon-only below 640px (aria-labels keep the
|
header module). Icon-only below 640px (aria-labels keep the
|
||||||
@@ -88,7 +64,7 @@
|
|||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign in</span>
|
<span class="auth-label">Sign in</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign out</span>
|
<span class="auth-label">Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -112,26 +88,29 @@
|
|||||||
</section>
|
</section>
|
||||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||||
<div class="container sources-shell">
|
<div class="container sources-shell">
|
||||||
<!-- Phase 32: the sync failure banner — the chat error-banner
|
<div class="page-head">
|
||||||
markup style (kb-banner + is-error), role="alert" so a failed
|
<div class="page-head-row">
|
||||||
sync is announced. sources.js fills #sync-error-text and
|
<h1>Knowledge base</h1>
|
||||||
un-hides it on a failed run (the button re-enables,
|
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources">
|
||||||
retry-ready); a new sync hides it again. -->
|
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||||
|
<span class="sync-label">Sync sources</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="page-sub">
|
||||||
|
Every <code>*.md</code> file indexed from <code>~/Homelab</code> and
|
||||||
|
<code>~/Deployments</code>. Press <strong>Sync sources</strong> to clone
|
||||||
|
the repos and re-import.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- #sync-result is the aria-live announcer for the last sync
|
||||||
|
result ("N added · …") — renders off the "bor:sync-status" event. -->
|
||||||
|
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
|
||||||
|
<!-- Sync failure banner — role="alert" so a failed sync is announced. -->
|
||||||
<div class="kb-banner is-error" id="sync-error-banner" role="alert" hidden>
|
<div class="kb-banner is-error" id="sync-error-banner" role="alert" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
|
||||||
<span id="sync-error-text"></span>
|
<span id="sync-error-text"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-head">
|
|
||||||
<h1>Knowledge base</h1>
|
|
||||||
<p class="page-sub">
|
|
||||||
Every <code>*.md</code> file indexed from <code>~/Homelab</code> and
|
|
||||||
<code>~/Deployments</code>. Re-run the import to refresh — or hit
|
|
||||||
<strong>Sync sources</strong> in the header to clone the repos and
|
|
||||||
re-import.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Phase 16: anonymous sign-in gate. The catalog is what the
|
<!-- Phase 16: anonymous sign-in gate. The catalog is what the
|
||||||
login locks — the document viewer itself stays public (soft
|
login locks — the document viewer itself stays public (soft
|
||||||
rule), so the copy says what stays open. -->
|
rule), so the copy says what stays open. -->
|
||||||
|
|||||||
+15
-35
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Manage the global tuning notes that steer every Brain of Reese answer.">
|
<meta name="description" content="Manage the global tuning notes that steer every Brain of Reese answer.">
|
||||||
<title>Global Tuning · Brain of Reese</title>
|
<title>Global Tuning · Brain of Reese</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="container header-inner">
|
<div class="container header-inner">
|
||||||
<span class="brand">
|
<span class="brand">
|
||||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
</span>
|
</span>
|
||||||
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
|
||||||
@@ -43,39 +43,19 @@
|
|||||||
reveals it once whoami says admin, exactly like the
|
reveals it once whoami says admin, exactly like the
|
||||||
Sources link above. -->
|
Sources link above. -->
|
||||||
<a href="/tuning.html" class="nav-link is-active" aria-current="page" id="nav-tuning" hidden>Tuning</a>
|
<a href="/tuning.html" class="nav-link is-active" aria-current="page" id="nav-tuning" hidden>Tuning</a>
|
||||||
|
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
|
||||||
|
outside the nav; see styles.css .sign-out-mobile rules). -->
|
||||||
|
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" aria-label="Sign out" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign out</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
|
||||||
2026-08-26): open the tuning-notes panel (stored in
|
into every system prompt) — owned by the shared header
|
||||||
Postgres, read into every system prompt). The behavior is
|
module (assets/header.js); the #steering-panel section
|
||||||
owned by the shared header module (assets/header.js); the
|
ships in every page's <main>. The navbar toggle was
|
||||||
#steering-panel section ships in every page's <main>. -->
|
removed at owner request (2026-08-28): note management
|
||||||
<button type="button" class="steering-toggle" id="steering-toggle" hidden
|
lives on /tuning.html. -->
|
||||||
aria-expanded="false" aria-controls="steering-panel">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
|
|
||||||
<span class="steering-label">Tuning</span>
|
|
||||||
<span class="steering-count" id="steering-count">0</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
|
||||||
hidden (anonymous-safe), header.js reveals it for the admin
|
|
||||||
on the SAME cached whoami that reveals #nav-sources /
|
|
||||||
#nav-tuning. The §7.4 "never stale" lifecycle is
|
|
||||||
module-owned (assets/header.js); the Sources page's
|
|
||||||
#sync-result line + #sync-error-banner render off the
|
|
||||||
module's "bor:sync-status" event. -->
|
|
||||||
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
|
||||||
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
|
||||||
<span class="sync-label" id="sync-label">Sync sources</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
|
||||||
2026-08-26; module-owned since phase 34 task 02): on the
|
|
||||||
chat page "New chat" resets the local (localStorage)
|
|
||||||
conversation; on every other page it means "go to the
|
|
||||||
chat, fresh" (the module clears the key + navigates). -->
|
|
||||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
||||||
<span class="new-chat-label">New chat</span>
|
|
||||||
</button>
|
|
||||||
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||||
out is visible; /api/whoami decides at load (the shared
|
out is visible; /api/whoami decides at load (the shared
|
||||||
header module). Icon-only below 640px (aria-labels keep the
|
header module). Icon-only below 640px (aria-labels keep the
|
||||||
@@ -84,7 +64,7 @@
|
|||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign in</span>
|
<span class="auth-label">Sign in</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
<button type="button" class="auth-link sign-out-btn" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
<span class="auth-label">Sign out</span>
|
<span class="auth-label">Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -213,14 +213,17 @@ def test_admin_login_unlocks_sources_and_tuning(
|
|||||||
expect(page.locator("#docs-table")).to_be_visible()
|
expect(page.locator("#docs-table")).to_be_visible()
|
||||||
expect(page.locator("#docs-tbody tr")).to_have_count(13)
|
expect(page.locator("#docs-tbody tr")).to_have_count(13)
|
||||||
|
|
||||||
# Chat: the tuning UI is back — header toggle with count badge,
|
# Chat: the tuning UI is back — Sign out instead of Sign in, Tune
|
||||||
# Sign out instead of Sign in, Tune under the answer.
|
# under the answer. The header toggle is NOT back: removed from the
|
||||||
|
# navbar at owner request (2026-08-28), the panel still ships
|
||||||
|
# hidden (note management lives on /tuning.html).
|
||||||
page.goto(app_url)
|
page.goto(app_url)
|
||||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||||
toggle = page.locator("#steering-toggle")
|
assert page.locator("#steering-toggle").count() == 0, (
|
||||||
expect(toggle).to_be_visible()
|
"the steering toggle was removed from the navbar (2026-08-28)"
|
||||||
expect(page.locator("#steering-count")).to_have_text("0")
|
)
|
||||||
|
expect(page.locator("#steering-panel")).to_be_hidden()
|
||||||
|
|
||||||
_ask(page, QUESTION)
|
_ask(page, QUESTION)
|
||||||
tune = page.locator(".msg.brain .tune-btn").last
|
tune = page.locator(".msg.brain .tune-btn").last
|
||||||
@@ -249,7 +252,10 @@ def test_logout_returns_to_anonymous(
|
|||||||
login(page, app_url, next="/") # straight into the chat
|
login(page, app_url, next="/") # straight into the chat
|
||||||
expect(page).to_have_url(app_url + "/")
|
expect(page).to_have_url(app_url + "/")
|
||||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||||
expect(page.locator("#steering-toggle")).to_be_visible()
|
# The steering toggle was removed from the navbar (2026-08-28) —
|
||||||
|
# absent for the admin too; the panel still ships hidden.
|
||||||
|
assert page.locator("#steering-toggle").count() == 0
|
||||||
|
expect(page.locator("#steering-panel")).to_be_hidden()
|
||||||
|
|
||||||
# One grounded turn as admin (persisted to localStorage by phase 14).
|
# One grounded turn as admin (persisted to localStorage by phase 14).
|
||||||
_ask(page, QUESTION)
|
_ask(page, QUESTION)
|
||||||
|
|||||||
@@ -20,15 +20,18 @@ Per role, the VISIBLE inventory:
|
|||||||
|
|
||||||
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning]
|
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning]
|
||||||
(four links, that order — the Git sources link joined in phase 35,
|
(four links, that order — the Git sources link joined in phase 35,
|
||||||
owner permission 2026-08-26) + #steering-toggle + #sync-btn +
|
owner permission 2026-08-26) + #sync-btn + #new-chat-btn +
|
||||||
#new-chat-btn + #sign-out-btn (with #sign-in-link hidden) — on all
|
#sign-out-btn (with #sign-in-link hidden) — on all five pages, same
|
||||||
five pages, same id+class inventory, same DOM order;
|
id+class inventory, same DOM order. The #steering-toggle was removed
|
||||||
|
from the navbar at owner request (2026-08-28); note management lives
|
||||||
|
on /tuning.html;
|
||||||
* anonymous: brand + nav [Chat] (#nav-sources / #nav-git-sources /
|
* anonymous: brand + nav [Chat] (#nav-sources / #nav-git-sources /
|
||||||
#nav-tuning hidden — locked A10 UI revision) + #new-chat-btn +
|
#nav-tuning hidden — locked A10 UI revision) + #new-chat-btn +
|
||||||
#sign-in-link (with #sync-btn hidden, #sign-out-btn hidden) on all
|
#sign-in-link (with #sync-btn hidden, #sign-out-btn hidden) on all
|
||||||
five pages — and the steering toggle + panel are ABSENT from the DOM
|
five pages — and the steering toggle (removed at owner request,
|
||||||
(phase 16 "absent, not hidden" treatment, carried into phase 34 task
|
2026-08-28) + panel are ABSENT from the DOM (the panel via the phase
|
||||||
01; test_admin_auth pins it).
|
16 "absent, not hidden" treatment, carried into phase 34 task 01;
|
||||||
|
test_admin_auth pins it).
|
||||||
|
|
||||||
Normalization for the inventory comparison: the current-page ``is-active``
|
Normalization for the inventory comparison: the current-page ``is-active``
|
||||||
nav marker and the sign-in ``?next=`` value legitimately differ per page,
|
nav marker and the sign-in ``?next=`` value legitimately differ per page,
|
||||||
@@ -40,26 +43,28 @@ page's bar (64px / 58px) and row 2 (``.doc-titlebar``) is present with
|
|||||||
(phase 13) is honored — ``back=`` accepted for same-origin relative
|
(phase 13) is honored — ``back=`` accepted for same-origin relative
|
||||||
URLs, rejected (→ /sources.html) otherwise.
|
URLs, rejected (→ /sources.html) otherwise.
|
||||||
|
|
||||||
Steering works off-chat: on /tuning.html (admin, zero notes) the toggle
|
Steering off-chat: on /tuning.html (admin, zero notes) the navbar
|
||||||
opens/closes #steering-panel with the empty state and a 0 count badge —
|
carries no steering toggle (removed at owner request, 2026-08-28) — the
|
||||||
no chat needed. Sync is present, not triggered: #sync-btn is visible on
|
header #steering-panel section still ships hidden and the Tuning
|
||||||
/tuning.html but is never clicked here (a real sync clones real repos —
|
page's own note list shows the empty state — no chat needed. Sync is
|
||||||
the full state machine is test_sync_button.py's job).
|
present, not triggered: #sync-btn is visible on /tuning.html but is
|
||||||
|
never clicked here (a real sync clones real repos — the full state
|
||||||
|
machine is test_sync_button.py's job).
|
||||||
|
|
||||||
Determinism note: every assertion is settled-state — each page visit
|
Determinism note: every assertion is settled-state — each page visit
|
||||||
first waits for the whoami toggle to land (exactly one of Sign in /
|
first waits for the whoami toggle to land (exactly one of Sign in /
|
||||||
Sign out visible; the anonymous removal of the steering toggle happens
|
Sign out visible; the anonymous removal of the steering panel happens
|
||||||
in the SAME initSharedHeader pass) and, on the viewer, for the document
|
in the SAME initSharedHeader pass) and, on the viewer, for the document
|
||||||
title to render. The seed truncates steering_notes, so the count badge is
|
title to render. The seed truncates steering_notes, so the Tuning page
|
||||||
0 on every admin page. No chat turn is ever submitted; #sync-btn is
|
shows the empty state on every admin page. No chat turn is ever
|
||||||
never clicked.
|
submitted; #sync-btn is never clicked.
|
||||||
|
|
||||||
Test → story mapping (Playwright Mapping Rule):
|
Test → story mapping (Playwright Mapping Rule):
|
||||||
1. ``test_admin_inventory_identical_on_all_five_pages``
|
1. ``test_admin_inventory_identical_on_all_five_pages``
|
||||||
2. ``test_anonymous_inventory_identical_on_all_five_pages``
|
2. ``test_anonymous_inventory_identical_on_all_five_pages``
|
||||||
3. ``test_viewer_row1_height_matches_chat_and_titlebar_present``
|
3. ``test_viewer_row1_height_matches_chat_and_titlebar_present``
|
||||||
4. ``test_viewer_back_link_honors_back_param``
|
4. ``test_viewer_back_link_honors_back_param``
|
||||||
5. ``test_steering_panel_works_off_chat_on_tuning_page``
|
5. ``test_steering_surface_off_chat_on_tuning_page``
|
||||||
6. ``test_sync_button_present_on_tuning_page_without_triggering``
|
6. ``test_sync_button_present_on_tuning_page_without_triggering``
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -152,7 +157,6 @@ _INVENTORY_JS = """() => {
|
|||||||
const sel = [
|
const sel = [
|
||||||
".brand",
|
".brand",
|
||||||
".app-nav > a.nav-link",
|
".app-nav > a.nav-link",
|
||||||
"#steering-toggle",
|
|
||||||
"#sync-btn",
|
"#sync-btn",
|
||||||
"#new-chat-btn",
|
"#new-chat-btn",
|
||||||
"#sign-in-link",
|
"#sign-in-link",
|
||||||
@@ -180,8 +184,8 @@ def _header_inventory(page: Page) -> list[str]:
|
|||||||
def _wait_settled(page: Page, admin: bool) -> None:
|
def _wait_settled(page: Page, admin: bool) -> None:
|
||||||
"""Wait for initSharedHeader's whoami toggle to land: exactly one of
|
"""Wait for initSharedHeader's whoami toggle to land: exactly one of
|
||||||
Sign in / Sign out is visible (both ship hidden in the HTML). For
|
Sign in / Sign out is visible (both ship hidden in the HTML). For
|
||||||
anonymous visitors the steering toggle + panel removal happens in
|
anonymous visitors the steering panel removal happens in the SAME
|
||||||
the SAME pass, so they are already gone when this returns."""
|
pass, so it is already gone when this returns."""
|
||||||
if admin:
|
if admin:
|
||||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||||
@@ -220,7 +224,11 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
|||||||
# revealed on every page, between Sources and Tuning.
|
# revealed on every page, between Sources and Tuning.
|
||||||
expect(page.locator("#nav-git-sources")).to_be_visible()
|
expect(page.locator("#nav-git-sources")).to_be_visible()
|
||||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||||
expect(page.locator("#steering-toggle")).to_be_visible()
|
# The steering toggle was removed from the navbar at owner
|
||||||
|
# request (2026-08-28) — absent on every page, admin included.
|
||||||
|
assert page.locator("#steering-toggle").count() == 0, (
|
||||||
|
f"{name}: the steering toggle was removed from the navbar"
|
||||||
|
)
|
||||||
expect(page.locator("#sync-btn")).to_be_visible()
|
expect(page.locator("#sync-btn")).to_be_visible()
|
||||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||||
@@ -233,9 +241,11 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
|||||||
expect(page.locator("#sync-btn")).to_be_hidden()
|
expect(page.locator("#sync-btn")).to_be_hidden()
|
||||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||||
expect(page.locator("#sign-in-link")).to_be_visible()
|
expect(page.locator("#sign-in-link")).to_be_visible()
|
||||||
# …and the steering surface is ABSENT (phase 16 "absent, not
|
# …and the steering surface is ABSENT (the toggle was removed
|
||||||
# hidden", carried into the shared module by phase 34 task 01
|
# from the navbar at owner request, 2026-08-28; the panel via
|
||||||
# — test_admin_auth pins the same contract).
|
# phase 16 "absent, not hidden", carried into the shared module
|
||||||
|
# by phase 34 task 01 — test_admin_auth pins the same
|
||||||
|
# contract).
|
||||||
assert page.locator("#steering-toggle").count() == 0, (
|
assert page.locator("#steering-toggle").count() == 0, (
|
||||||
f"{name}: the steering toggle must be absent for anonymous"
|
f"{name}: the steering toggle must be absent for anonymous"
|
||||||
)
|
)
|
||||||
@@ -289,7 +299,9 @@ def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
|
|||||||
expect(page.locator("#nav-sources")).to_be_visible()
|
expect(page.locator("#nav-sources")).to_be_visible()
|
||||||
expect(page.locator("#nav-git-sources")).to_be_visible()
|
expect(page.locator("#nav-git-sources")).to_be_visible()
|
||||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||||
expect(page.locator("#steering-toggle")).to_be_visible()
|
assert page.locator("#steering-toggle").count() == 0, (
|
||||||
|
"login: the steering toggle was removed from the navbar"
|
||||||
|
)
|
||||||
expect(page.locator("#sync-btn")).to_be_visible()
|
expect(page.locator("#sync-btn")).to_be_visible()
|
||||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||||
@@ -434,13 +446,14 @@ def test_viewer_back_link_honors_back_param(
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5. Steering works off-chat: on /tuning.html (admin, zero notes) the
|
# 5. Steering off-chat: on /tuning.html (admin, zero notes) the navbar
|
||||||
# header toggle drives the panel — open/close cycle, empty state,
|
# carries no steering toggle (removed at owner request, 2026-08-28)
|
||||||
# count badge 0. No chat turn is needed.
|
# — the header panel section still ships hidden and the Tuning
|
||||||
|
# page's own note list shows the empty state. No chat turn needed.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_steering_panel_works_off_chat_on_tuning_page(
|
def test_steering_surface_off_chat_on_tuning_page(
|
||||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
page.set_viewport_size({"width": 1280, "height": 800})
|
page.set_viewport_size({"width": 1280, "height": 800})
|
||||||
@@ -450,26 +463,16 @@ def test_steering_panel_works_off_chat_on_tuning_page(
|
|||||||
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
|
expect(page).to_have_url(app_url + TUNING_URL, timeout=30_000)
|
||||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||||
|
|
||||||
# Settled admin state: the toggle is on the bar, the panel ships
|
# Settled admin state: the navbar toggle is GONE (removed at owner
|
||||||
# hidden, and the count badge reads 0 (zero seeded notes).
|
# request, 2026-08-28), the header panel section still ships
|
||||||
expect(page.locator("#steering-toggle")).to_be_visible()
|
# hidden, and the Tuning page's own note list shows the empty
|
||||||
|
# state (zero seeded notes).
|
||||||
|
assert page.locator("#steering-toggle").count() == 0, (
|
||||||
|
"the steering toggle was removed from the navbar"
|
||||||
|
)
|
||||||
expect(page.locator("#steering-panel")).to_be_hidden()
|
expect(page.locator("#steering-panel")).to_be_hidden()
|
||||||
expect(page.locator("#steering-count")).to_have_text("0")
|
expect(page.locator("#tune-list .tuning-note")).to_have_count(0)
|
||||||
|
expect(page.locator("#tune-empty")).to_be_visible()
|
||||||
# Open: the panel shows, the toggle's aria-expanded follows, and
|
|
||||||
# the empty state is visible (the re-open refresh fetched 0 notes).
|
|
||||||
page.click("#steering-toggle")
|
|
||||||
expect(page.locator("#steering-panel")).to_be_visible()
|
|
||||||
expect(page.locator("#steering-toggle")).to_have_attribute("aria-expanded", "true")
|
|
||||||
expect(page.locator("#steering-empty")).to_be_visible()
|
|
||||||
expect(page.locator("#steering-list .steering-note")).to_have_count(0)
|
|
||||||
expect(page.locator("#steering-count")).to_have_text("0")
|
|
||||||
|
|
||||||
# Close: the cycle completes, the count badge still reads 0.
|
|
||||||
page.click("#steering-toggle")
|
|
||||||
expect(page.locator("#steering-panel")).to_be_hidden()
|
|
||||||
expect(page.locator("#steering-toggle")).to_have_attribute("aria-expanded", "false")
|
|
||||||
expect(page.locator("#steering-count")).to_have_text("0")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+52
-62
@@ -13,8 +13,9 @@ stored in Postgres (``steering_notes``) → injected into the system prompt
|
|||||||
of every subsequent turn as the ``<tuning>`` section. The mock LLM
|
of every subsequent turn as the ``<tuning>`` section. The mock LLM
|
||||||
echoes the first tuning note into its answer
|
echoes the first tuning note into its answer
|
||||||
(`` (tuning: <first note line>)``), so prompt injection is observable in
|
(`` (tuning: <first note line>)``), so prompt injection is observable in
|
||||||
the UI deterministically. Notes are listed newest-first in the header
|
the UI deterministically. Notes are listed newest-first on the Tuning
|
||||||
"Tuning" panel, where each can be deleted.
|
page (``/tuning.html``), where each can be deleted — the header "Tuning"
|
||||||
|
toggle was removed from the navbar at owner request (2026-08-28).
|
||||||
|
|
||||||
Test → story mapping (Playwright Mapping Rule):
|
Test → story mapping (Playwright Mapping Rule):
|
||||||
1. ``test_tune_under_answer_persists_and_steers``
|
1. ``test_tune_under_answer_persists_and_steers``
|
||||||
@@ -45,8 +46,9 @@ QUESTION = "How is my Kubernetes cluster set up?"
|
|||||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||||
NOTE = "STEEER-MARKER be concise"
|
NOTE = "STEEER-MARKER be concise"
|
||||||
XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>"
|
XSS_NOTE = "<script>window.__xss = true; alert('xss')</script>"
|
||||||
#: index.html ships exactly three classic/module script tags: the
|
#: Both index.html and tuning.html ship exactly three classic/module
|
||||||
#: phase-39 brand.js classic layer + markdown.js + the app.js module.
|
#: script tags: the phase-39 brand.js classic layer + markdown.js + the
|
||||||
|
#: page module (app.js / tuning.js).
|
||||||
BASE_SCRIPT_COUNT = 3
|
BASE_SCRIPT_COUNT = 3
|
||||||
|
|
||||||
|
|
||||||
@@ -113,11 +115,6 @@ def _tune_and_save(page: Page, note: str) -> None:
|
|||||||
expect(saved).to_contain_text("Saved — future answers will follow this.", timeout=15_000)
|
expect(saved).to_contain_text("Saved — future answers will follow this.", timeout=15_000)
|
||||||
|
|
||||||
|
|
||||||
def _open_panel(page: Page) -> None:
|
|
||||||
page.click("#steering-toggle")
|
|
||||||
expect(page.locator("#steering-panel")).to_be_visible()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. Tune under an answer → persisted → next answer carries the note
|
# 1. Tune under an answer → persisted → next answer carries the note
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -147,14 +144,15 @@ def test_tune_under_answer_persists_and_steers(
|
|||||||
rows = db.scalars(select(SteeringNote)).all()
|
rows = db.scalars(select(SteeringNote)).all()
|
||||||
assert [r.note for r in rows] == [NOTE]
|
assert [r.note for r in rows] == [NOTE]
|
||||||
|
|
||||||
# The header panel shows the note with an updated count badge.
|
# The Tuning page (the steering-notes manager — the navbar toggle
|
||||||
_open_panel(page)
|
# was removed at owner request, 2026-08-28) lists the note.
|
||||||
expect(page.locator("#steering-count")).to_have_text("1")
|
page.goto(app_url + "/tuning.html")
|
||||||
expect(page.locator("#steering-list .steering-note")).to_have_count(1)
|
expect(page.locator("#tune-list .tuning-note")).to_have_count(1)
|
||||||
expect(page.locator("#steering-list .steering-note-text")).to_have_text(NOTE)
|
expect(page.locator("#tune-list .tuning-note-text")).to_have_text(NOTE)
|
||||||
page.click("#steering-toggle") # close again
|
|
||||||
|
|
||||||
# The NEXT answer carries the note — it reached the system prompt.
|
# The NEXT answer carries the note — it reached the system prompt.
|
||||||
|
page.goto(app_url + "/")
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled()
|
||||||
_ask(page, QUESTION)
|
_ask(page, QUESTION)
|
||||||
bubble = page.locator(".msg.brain .bubble").last
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
expect(bubble).to_contain_text(f"(tuning: {NOTE})")
|
expect(bubble).to_contain_text(f"(tuning: {NOTE})")
|
||||||
@@ -179,19 +177,21 @@ def test_delete_note_stops_steering(
|
|||||||
_ask(page, QUESTION)
|
_ask(page, QUESTION)
|
||||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(f"(tuning: {NOTE})")
|
expect(page.locator(".msg.brain .bubble").last).to_contain_text(f"(tuning: {NOTE})")
|
||||||
|
|
||||||
# Delete the note from the panel.
|
# Delete the note from the Tuning page (the header panel is no
|
||||||
_open_panel(page)
|
# longer reachable — the navbar toggle is gone).
|
||||||
expect(page.locator("#steering-count")).to_have_text("1")
|
page.goto(app_url + "/tuning.html")
|
||||||
page.locator("#steering-list .steering-delete").click()
|
expect(page.locator("#tune-list .tuning-note")).to_have_count(1)
|
||||||
expect(page.locator("#steering-list .steering-note")).to_have_count(0)
|
page.locator(".tuning-delete").click()
|
||||||
expect(page.locator("#steering-count")).to_have_text("0")
|
expect(page.locator("#tune-list .tuning-note")).to_have_count(0, timeout=15_000)
|
||||||
expect(page.locator("#steering-empty")).to_be_visible()
|
expect(page.locator("#tune-empty")).to_be_visible()
|
||||||
expect(page.locator("#steering-announcer")).to_contain_text("deleted")
|
expect(page.locator("#tune-announcer")).to_contain_text("deleted")
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
assert db.scalars(select(SteeringNote)).all() == []
|
assert db.scalars(select(SteeringNote)).all() == []
|
||||||
|
|
||||||
# The next answer no longer carries the marker.
|
# The next answer no longer carries the marker.
|
||||||
|
page.goto(app_url + "/")
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled()
|
||||||
_ask(page, QUESTION)
|
_ask(page, QUESTION)
|
||||||
bubble = page.locator(".msg.brain .bubble").last
|
bubble = page.locator(".msg.brain .bubble").last
|
||||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER)
|
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER)
|
||||||
@@ -222,12 +222,13 @@ def test_note_rendered_as_text_xss_safe(
|
|||||||
_ask(page, QUESTION)
|
_ask(page, QUESTION)
|
||||||
_tune_and_save(page, XSS_NOTE)
|
_tune_and_save(page, XSS_NOTE)
|
||||||
|
|
||||||
# Panel: the payload is visible as LITERAL text…
|
# Tuning page: the payload is visible as LITERAL text…
|
||||||
_open_panel(page)
|
page.goto(app_url + "/tuning.html")
|
||||||
expect(page.locator("#steering-list .steering-note-text")).to_have_text(XSS_NOTE)
|
expect(page.locator("#tune-list .tuning-note-text")).to_have_text(XSS_NOTE)
|
||||||
|
|
||||||
# …never as an executed element: no script tag anywhere, no dialog.
|
# …never as an executed element: no script tag in the list, the page
|
||||||
assert page.locator("#steering-panel script").count() == 0
|
# still carries exactly its own three scripts, no dialog.
|
||||||
|
assert page.locator("#tune-list script").count() == 0
|
||||||
expect(page.locator("script")).to_have_count(BASE_SCRIPT_COUNT)
|
expect(page.locator("script")).to_have_count(BASE_SCRIPT_COUNT)
|
||||||
assert dialogs == [], f"the note must never execute as script: {dialogs}"
|
assert dialogs == [], f"the note must never execute as script: {dialogs}"
|
||||||
assert page.evaluate("() => window.__xss === undefined") is True
|
assert page.evaluate("() => window.__xss === undefined") is True
|
||||||
@@ -239,34 +240,27 @@ def test_note_rendered_as_text_xss_safe(
|
|||||||
|
|
||||||
|
|
||||||
def test_tuning_panel_a11y(page: Page, app_url: str, db_ready: None) -> None:
|
def test_tuning_panel_a11y(page: Page, app_url: str, db_ready: None) -> None:
|
||||||
_reset_db(mock_port=0, seed=False) # no KB seeding needed for the panel a11y
|
"""The steering surface's a11y now lives on the Tuning page (the
|
||||||
|
header toggle was removed from the navbar at owner request,
|
||||||
|
2026-08-28): the note list, the polite live region, and the labeled
|
||||||
|
per-note delete (≥44px)."""
|
||||||
|
_reset_db(mock_port=0, seed=False) # no KB seeding needed for the page a11y
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
page.goto(app_url)
|
page.goto(app_url)
|
||||||
login(page, app_url, next="/") # phase 16: the panel is admin-only
|
login(page, app_url, next="/tuning.html") # phase 16: the notes are admin-only
|
||||||
|
|
||||||
toggle = page.locator("#steering-toggle")
|
# The navbar no longer carries a steering toggle — absent for the
|
||||||
panel = page.locator("#steering-panel")
|
# admin too — and the header panel section still ships hidden.
|
||||||
announcer = page.locator("#steering-announcer")
|
assert page.locator("#steering-toggle").count() == 0
|
||||||
|
expect(page.locator("#steering-panel")).to_be_hidden()
|
||||||
|
|
||||||
# Initial: closed, correctly wired, polite live region present.
|
# The page's own polite live region.
|
||||||
expect(toggle).to_have_attribute("aria-expanded", "false")
|
announcer = page.locator("#tune-announcer")
|
||||||
expect(toggle).to_have_attribute("aria-controls", "steering-panel")
|
|
||||||
expect(panel).to_have_attribute("role", "region")
|
|
||||||
assert "Tuning notes" in (panel.get_attribute("aria-label") or "")
|
|
||||||
expect(panel).to_be_hidden()
|
|
||||||
assert announcer.get_attribute("role") == "status"
|
assert announcer.get_attribute("role") == "status"
|
||||||
assert announcer.get_attribute("aria-live") == "polite"
|
assert announcer.get_attribute("aria-live") == "polite"
|
||||||
# Accessible name comes from its visible text (icon is aria-hidden).
|
|
||||||
assert "Tuning" in toggle.inner_text()
|
|
||||||
|
|
||||||
# Open: expanded + the designed empty state.
|
# Add a note (API), reload to refresh the list (tuning.js fetches
|
||||||
toggle.click()
|
# on boot).
|
||||||
expect(toggle).to_have_attribute("aria-expanded", "true")
|
|
||||||
expect(panel).to_be_visible()
|
|
||||||
expect(page.locator("#steering-empty")).to_be_visible()
|
|
||||||
expect(page.locator("#steering-count")).to_have_text("0")
|
|
||||||
|
|
||||||
# Add a note (API), then re-open the panel to refresh it.
|
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"""async () => {
|
"""async () => {
|
||||||
const r = await fetch('/api/steering', {
|
const r = await fetch('/api/steering', {
|
||||||
@@ -277,26 +271,22 @@ def test_tuning_panel_a11y(page: Page, app_url: str, db_ready: None) -> None:
|
|||||||
if (!r.ok) throw new Error('steering POST failed: ' + r.status);
|
if (!r.ok) throw new Error('steering POST failed: ' + r.status);
|
||||||
}"""
|
}"""
|
||||||
)
|
)
|
||||||
toggle.click() # close
|
page.reload()
|
||||||
toggle.click() # re-open (refreshes the list)
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||||
note_item = page.locator("#steering-list .steering-note")
|
note_item = page.locator("#tune-list .tuning-note")
|
||||||
expect(note_item).to_have_count(1)
|
expect(note_item).to_have_count(1)
|
||||||
expect(note_item.locator(".steering-note-text")).to_have_text("a11y note one")
|
expect(note_item.locator(".tuning-note-text")).to_have_text("a11y note one")
|
||||||
|
|
||||||
# The per-note delete is a real, labeled button (≥44px target).
|
# The per-note delete is a real, labeled button (≥44px target).
|
||||||
delete = page.locator("#steering-list .steering-delete")
|
delete = page.locator(".tuning-delete")
|
||||||
expect(delete).to_have_attribute("type", "button")
|
expect(delete).to_have_attribute("type", "button")
|
||||||
assert (delete.get_attribute("aria-label") or "").startswith("Delete tuning note:")
|
assert (delete.get_attribute("aria-label") or "").startswith("Delete tuning note:")
|
||||||
box = delete.bounding_box()
|
box = delete.bounding_box()
|
||||||
assert box is not None and box["height"] >= 44
|
assert box is not None and box["height"] >= 44
|
||||||
|
|
||||||
# Delete: list empties, count updates, the live region announces it.
|
# Delete: list empties, the empty state shows, the live region
|
||||||
|
# announces it.
|
||||||
delete.click()
|
delete.click()
|
||||||
expect(page.locator("#steering-list .steering-note")).to_have_count(0)
|
expect(page.locator("#tune-list .tuning-note")).to_have_count(0, timeout=15_000)
|
||||||
expect(page.locator("#steering-count")).to_have_text("0")
|
expect(page.locator("#tune-empty")).to_be_visible()
|
||||||
expect(announcer).to_contain_text("deleted")
|
expect(announcer).to_contain_text("deleted")
|
||||||
|
|
||||||
# And the toggle closes cleanly again.
|
|
||||||
toggle.click()
|
|
||||||
expect(toggle).to_have_attribute("aria-expanded", "false")
|
|
||||||
expect(panel).to_be_hidden()
|
|
||||||
|
|||||||
@@ -6,27 +6,25 @@ toggle) to ANONYMOUS visitors — it shipped VISIBLE in all six pages'
|
|||||||
markup and ``assets/header.js`` removed it only after ``/api/whoami``
|
markup and ``assets/header.js`` removed it only after ``/api/whoami``
|
||||||
resolved, so the button flashed for the whole whoami round-trip.
|
resolved, so the button flashed for the whole whoami round-trip.
|
||||||
|
|
||||||
The fix mirrors the admin-only nav links (phase 19/29/35 contract):
|
Phase 40 fixed it with the ship-hidden / reveal-for-admin contract
|
||||||
the toggle now SHIPS ``hidden`` in every page and ``initSharedHeader``
|
(the admin-only nav links). The owner then asked for the button to go
|
||||||
unhides it only when whoami says admin; the anonymous end-state is
|
away entirely (2026-08-28): the navbar ``#steering-toggle`` is now
|
||||||
unchanged (toggle + panel REMOVED from the DOM — phase 16 "absent,
|
ABSENT from every page — for the admin AND anonymous — so the
|
||||||
not hidden"). This suite proves the browser-level contract:
|
never-visible contract holds by construction, and note management
|
||||||
|
lives on ``/tuning.html``. The anonymous end-state is unchanged: the
|
||||||
|
``#steering-panel`` is REMOVED from the DOM (phase 16 "absent, not
|
||||||
|
hidden") and ``/api/steering`` is never fetched. This suite proves the
|
||||||
|
browser-level contract:
|
||||||
|
|
||||||
* a MutationObserver (installed via ``add_init_script`` before any
|
* ``#steering-toggle`` is absent from the DOM on every page, for
|
||||||
page code runs) records every frame in which ``#steering-toggle``
|
anonymous AND admin — nothing can flash, because nothing ships;
|
||||||
is both in the DOM and visible (``offsetParent !== null`` or
|
* the anonymous end-state survives the removal: ``#steering-panel``
|
||||||
``!hidden``) — an anonymous load records ZERO such frames, on
|
is removed from the DOM, not just hidden;
|
||||||
every page, from first paint to the settled state;
|
* the admin UX around the removal is intact: the header panel section
|
||||||
* after the whoami round-trip the toggle is ABSENT from the DOM for
|
still ships hidden (never opened from the header anymore), and the
|
||||||
anonymous visitors (removed, not hidden);
|
ship-hidden nav-link contract this phase relies on
|
||||||
* the admin UX is untouched (phase 15/34 behavior): the toggle is
|
(``#nav-sources`` / ``#nav-git-sources`` / ``#nav-tuning``) holds:
|
||||||
revealed, clicking opens ``#steering-panel``
|
hidden for anonymous, revealed for admin.
|
||||||
(``aria-expanded="true"``) and the count badge matches the note
|
|
||||||
list — including a self-check that the observer records the admin
|
|
||||||
reveal, so the zero-frame anonymous assertions are not vacuous;
|
|
||||||
* the ship-hidden nav-link contract this phase relies on
|
|
||||||
(``#nav-sources`` / ``#nav-git-sources`` / ``#nav-tuning``) is
|
|
||||||
intact: hidden for anonymous, revealed for admin.
|
|
||||||
|
|
||||||
Story: ``.agent/user_stories/tuning-toggle-flash.md``
|
Story: ``.agent/user_stories/tuning-toggle-flash.md``
|
||||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
@@ -42,82 +40,27 @@ Test → story mapping (Playwright Mapping Rule):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from playwright.sync_api import Page, expect
|
from playwright.sync_api import Page, expect
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from app.db import SessionLocal
|
|
||||||
from e2e.auth_helpers import login
|
from e2e.auth_helpers import login
|
||||||
|
|
||||||
#: The pages the contract must hold on besides the chat page (mapping
|
#: The pages the contract must hold on besides the chat page (mapping
|
||||||
#: rule 2). document.html / git-sources.html are covered by the
|
#: rule 2). document.html / git-sources.html are covered by the
|
||||||
#: source-level unit pins (tests/unit/test_steering_toggle_visibility.py)
|
#: source-level unit pins (tests/unit/test_steering_toggle_removal.py)
|
||||||
#: — the four pages here are the ones an anonymous visitor actually
|
#: — the three pages here are the other pages an anonymous visitor
|
||||||
#: lands on.
|
#: actually lands on.
|
||||||
OTHER_PAGES = ("/sources.html", "/tuning.html", "/login.html")
|
OTHER_PAGES = ("/sources.html", "/tuning.html", "/login.html")
|
||||||
|
|
||||||
ADMIN_NOTE = "PHASE40-E2E note — badge check"
|
|
||||||
|
|
||||||
#: Admin-only nav links — the ship-hidden / reveal-for-admin family the
|
#: Admin-only nav links — the ship-hidden / reveal-for-admin family the
|
||||||
#: steering toggle now belongs to (phase 19/29/35 contract).
|
#: steering toggle belonged to (phase 19/29/35 contract).
|
||||||
NAV_IDS = ("#nav-sources", "#nav-git-sources", "#nav-tuning")
|
NAV_IDS = ("#nav-sources", "#nav-git-sources", "#nav-tuning")
|
||||||
|
|
||||||
#: Runs in every new document BEFORE any page script (addInitScript):
|
|
||||||
#: arms a MutationObserver over the whole DOM and records every frame
|
|
||||||
#: in which #steering-toggle is attached AND visible — visible meaning
|
|
||||||
#: rendered (offsetParent !== null) OR carrying no [hidden] attribute
|
|
||||||
#: (!el.hidden). A shipped-VISIBLE toggle (the old bug) is recorded the
|
|
||||||
#: moment the parser inserts it; a shipped-hidden toggle that is later
|
|
||||||
#: revealed is recorded at the reveal mutation. The array is fresh per
|
|
||||||
#: document, so each navigation asserts its own frames.
|
|
||||||
VISIBILITY_OBSERVER_JS = """
|
|
||||||
window.__tuningVisibleFrames = [];
|
|
||||||
(() => {
|
|
||||||
const visible = (el) =>
|
|
||||||
!!el && el.isConnected && (el.offsetParent !== null || !el.hidden);
|
|
||||||
const check = () => {
|
|
||||||
if (visible(document.getElementById("steering-toggle"))) {
|
|
||||||
window.__tuningVisibleFrames.push({
|
|
||||||
at: Math.round(performance.now()),
|
|
||||||
href: location.pathname,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const start = () => {
|
|
||||||
check();
|
|
||||||
new MutationObserver(check).observe(document.documentElement, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["hidden"],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if (document.documentElement) start();
|
|
||||||
else document.addEventListener("DOMContentLoaded", start);
|
|
||||||
})();
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
def _assert_no_toggle(page: Page, path: str) -> None:
|
||||||
def install_visibility_observer(page: Page) -> None:
|
"""The navbar toggle is absent from the DOM for one page — nothing
|
||||||
"""Arm the never-visible frame counter on every document this page
|
to flash, for either role."""
|
||||||
creates — the initial load, the post-login redirect, re-gotos."""
|
|
||||||
page.add_init_script(VISIBILITY_OBSERVER_JS)
|
|
||||||
|
|
||||||
|
|
||||||
def visible_frames(page: Page) -> list[dict[str, object]]:
|
|
||||||
"""The frames the observer recorded in the CURRENT document."""
|
|
||||||
return page.evaluate("() => window.__tuningVisibleFrames || []")
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_no_flash(page: Page, path: str) -> None:
|
|
||||||
"""Zero visible frames + the phase-16 absent end-state, for one
|
|
||||||
anonymously loaded page."""
|
|
||||||
frames = visible_frames(page)
|
|
||||||
assert frames == [], f"{path}: the toggle was visible {len(frames)}x: {frames!r}"
|
|
||||||
assert page.locator("#steering-toggle").count() == 0, (
|
assert page.locator("#steering-toggle").count() == 0, (
|
||||||
f"{path}: #steering-toggle must be REMOVED from the DOM for "
|
f"{path}: #steering-toggle must be ABSENT from the DOM "
|
||||||
"anonymous (phase 16 'absent, not hidden')"
|
"(removed from the navbar at owner request, 2026-08-28)"
|
||||||
)
|
|
||||||
assert page.locator("#steering-panel").count() == 0, (
|
|
||||||
f"{path}: #steering-panel must be removed together with the toggle"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,14 +68,21 @@ def _wait_header_settled_anonymous(page: Page) -> None:
|
|||||||
"""Whoami resolved on the current document: the anonymous state
|
"""Whoami resolved on the current document: the anonymous state
|
||||||
reveals the Sign in link and keeps Sign out hidden (the pair is
|
reveals the Sign in link and keeps Sign out hidden (the pair is
|
||||||
decided by the SAME initSharedHeader pass that removes the
|
decided by the SAME initSharedHeader pass that removes the
|
||||||
toggle)."""
|
steering panel)."""
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
|
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
|
||||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_header_settled_admin(page: Page) -> None:
|
||||||
|
"""Whoami resolved for a signed-in admin on the current document."""
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||||
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. Anonymous chat load: zero visible frames, toggle absent afterwards
|
# 1. Anonymous chat load: zero toggle, panel removed (never visible)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -140,14 +90,19 @@ def test_anonymous_never_sees_toggle(
|
|||||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
install_visibility_observer(page)
|
|
||||||
page.goto(app_url + "/")
|
page.goto(app_url + "/")
|
||||||
_wait_header_settled_anonymous(page)
|
_wait_header_settled_anonymous(page)
|
||||||
_assert_no_flash(page, "/")
|
_assert_no_toggle(page, "/")
|
||||||
|
# The phase-16 end-state survives the removal: the panel is REMOVED
|
||||||
|
# from the DOM, not just hidden.
|
||||||
|
assert page.locator("#steering-panel").count() == 0, (
|
||||||
|
"/: #steering-panel must be removed from the DOM for anonymous "
|
||||||
|
"(phase 16 'absent, not hidden')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2. Anonymous loads of the other pages: same zero-flash contract
|
# 2. Anonymous loads of the other pages: same no-toggle contract
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -155,15 +110,19 @@ def test_anonymous_other_pages_never_flash(
|
|||||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
install_visibility_observer(page)
|
|
||||||
for path in OTHER_PAGES:
|
for path in OTHER_PAGES:
|
||||||
page.goto(app_url + path)
|
page.goto(app_url + path)
|
||||||
_wait_header_settled_anonymous(page)
|
_wait_header_settled_anonymous(page)
|
||||||
_assert_no_flash(page, path)
|
_assert_no_toggle(page, path)
|
||||||
|
assert page.locator("#steering-panel").count() == 0, (
|
||||||
|
f"{path}: #steering-panel must be removed from the DOM for "
|
||||||
|
"anonymous (phase 16 'absent, not hidden')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3. Admin: revealed, clickable, count badge matches the list
|
# 3. Admin: the toggle is gone too — the panel still ships hidden and
|
||||||
|
# the nav links are revealed
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -171,60 +130,26 @@ def test_admin_toggle_revealed_and_working(
|
|||||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
with SessionLocal() as db:
|
|
||||||
db.execute(text("TRUNCATE steering_notes"))
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
install_visibility_observer(page)
|
|
||||||
login(page, app_url, next="/")
|
login(page, app_url, next="/")
|
||||||
page.wait_for_load_state("networkidle")
|
_wait_header_settled_admin(page)
|
||||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
|
||||||
|
|
||||||
toggle = page.locator("#steering-toggle")
|
# The navbar toggle was removed at owner request (2026-08-28) —
|
||||||
panel = page.locator("#steering-panel")
|
# absent for the admin as well; nothing in the header can flash
|
||||||
|
# it back, because nothing ships it anymore.
|
||||||
# Revealed — the [hidden] attribute is gone and the button renders.
|
_assert_no_toggle(page, "/")
|
||||||
expect(toggle).to_be_visible()
|
# The header panel section survives (kept fresh by the chat page's
|
||||||
assert toggle.get_attribute("hidden") is None
|
# per-bubble Tune form) but ships hidden — no header control opens
|
||||||
|
# it anymore; note management lives on /tuning.html.
|
||||||
# Observer self-check: the reveal IS a recorded visible frame, so
|
assert page.locator("#steering-panel").count() == 1
|
||||||
# the zero-frame anonymous assertions above cannot be vacuous.
|
expect(page.locator("#steering-panel")).to_be_hidden()
|
||||||
assert visible_frames(page) != [], "the admin reveal was not observed"
|
# The surviving path to the notes: the admin-only Tuning nav link
|
||||||
|
# is revealed.
|
||||||
# Click: the panel opens, aria-expanded tracks it (phase 15/34).
|
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||||
toggle.click()
|
|
||||||
expect(panel).to_be_visible()
|
|
||||||
expect(toggle).to_have_attribute("aria-expanded", "true")
|
|
||||||
|
|
||||||
# Count badge matches the list — first the empty state…
|
|
||||||
expect(page.locator("#steering-count")).to_have_text("0")
|
|
||||||
expect(page.locator("#steering-list .steering-note")).to_have_count(0)
|
|
||||||
expect(page.locator("#steering-empty")).to_be_visible()
|
|
||||||
|
|
||||||
# …then with one note created through the real admin API.
|
|
||||||
page.evaluate(
|
|
||||||
"""async (note) => {
|
|
||||||
const r = await fetch("/api/steering", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify({note}),
|
|
||||||
});
|
|
||||||
if (!r.ok) throw new Error("steering POST failed: " + r.status);
|
|
||||||
}""",
|
|
||||||
ADMIN_NOTE,
|
|
||||||
)
|
|
||||||
toggle.click() # close
|
|
||||||
toggle.click() # re-open (refreshes the list)
|
|
||||||
expect(panel).to_be_visible()
|
|
||||||
expect(toggle).to_have_attribute("aria-expanded", "true")
|
|
||||||
expect(page.locator("#steering-count")).to_have_text("1")
|
|
||||||
expect(page.locator("#steering-list .steering-note")).to_have_count(1)
|
|
||||||
expect(page.locator("#steering-list .steering-note-text")).to_have_text(ADMIN_NOTE)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4. Nav-contract regression (phase 19/34): the ship-hidden family the
|
# 4. Nav-contract regression (phase 19/34): the ship-hidden family the
|
||||||
# toggle now belongs to is intact
|
# toggle used to belong to is intact
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -245,9 +170,8 @@ def test_nav_contract_regression(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Admin: the same links are revealed — the exact contract the
|
# Admin: the same links are revealed — the exact contract the
|
||||||
# steering toggle now mirrors.
|
# steering toggle mirrored before it was removed.
|
||||||
login(page, app_url, next="/")
|
login(page, app_url, next="/")
|
||||||
page.wait_for_load_state("networkidle")
|
_wait_header_settled_admin(page)
|
||||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
|
||||||
for nav in NAV_IDS:
|
for nav in NAV_IDS:
|
||||||
expect(page.locator(nav)).to_be_visible()
|
expect(page.locator(nav)).to_be_visible()
|
||||||
|
|||||||
@@ -219,8 +219,9 @@ def test_header_z_index_is_20_so_the_dropdown_uses_21() -> None:
|
|||||||
def test_mobile_block_renders_the_44px_toggle() -> None:
|
def test_mobile_block_renders_the_44px_toggle() -> None:
|
||||||
"""At ≤640px the toggle is a 44px×44px button (the phase-07 touch
|
"""At ≤640px the toggle is a 44px×44px button (the phase-07 touch
|
||||||
floor), ghost look like the other pills, a hover state in the
|
floor), ghost look like the other pills, a hover state in the
|
||||||
.steering-toggle:hover family, and a SIZED icon (an unsized inline
|
shared pill hover family (brand-ink on brand-soft, as
|
||||||
SVG defaults to 300px and would blow the bar out at 360px).
|
.nav-link:hover), and a SIZED icon (an unsized inline SVG defaults
|
||||||
|
to 300px and would blow the bar out at 360px).
|
||||||
:focus-visible needs no rule — the global 3px outline applies."""
|
:focus-visible needs no rule — the global 3px outline applies."""
|
||||||
mobile = _media_block(_css(), "@media (max-width: 640px)")
|
mobile = _media_block(_css(), "@media (max-width: 640px)")
|
||||||
block = _rule_block(mobile, ".nav-toggle")
|
block = _rule_block(mobile, ".nav-toggle")
|
||||||
@@ -239,7 +240,7 @@ def test_mobile_block_renders_the_44px_toggle() -> None:
|
|||||||
hover = _rule_block(mobile, ".nav-toggle:hover")
|
hover = _rule_block(mobile, ".nav-toggle:hover")
|
||||||
assert "background: var(--brand-soft)" in hover
|
assert "background: var(--brand-soft)" in hover
|
||||||
assert "color: var(--brand-ink)" in hover, (
|
assert "color: var(--brand-ink)" in hover, (
|
||||||
"hover matches the .steering-toggle:hover family (≈6.9:1 pair)"
|
"hover matches the shared pill hover family (≈6.9:1 pair)"
|
||||||
)
|
)
|
||||||
icon = _rule_block(mobile, ".nav-toggle svg")
|
icon = _rule_block(mobile, ".nav-toggle svg")
|
||||||
assert "width: 20px" in icon and "height: 20px" in icon, (
|
assert "width: 20px" in icon and "height: 20px" in icon, (
|
||||||
|
|||||||
@@ -125,8 +125,9 @@ def test_nav_sources_ships_hidden_on_every_nav_page() -> None:
|
|||||||
def test_all_five_pages_share_the_header_control_order() -> None:
|
def test_all_five_pages_share_the_header_control_order() -> None:
|
||||||
"""Phase 34 task 03 (owner confirmation 2026-08-26): every page
|
"""Phase 34 task 03 (owner confirmation 2026-08-26): every page
|
||||||
ships the IDENTICAL header control inventory in the IDENTICAL
|
ships the IDENTICAL header control inventory in the IDENTICAL
|
||||||
order — brand, nav [Chat, Sources, Tuning], #steering-toggle,
|
order — brand, nav [Chat, Sources, Tuning], #sync-btn, #new-chat-btn,
|
||||||
#sync-btn, #new-chat-btn, Sign in, Sign out — inside the shared
|
Sign in, Sign out (the #steering-toggle was removed from the navbar
|
||||||
|
at owner request, 2026-08-28) — inside the shared
|
||||||
.header-inner row (the document viewer's row 1). Only the
|
.header-inner row (the document viewer's row 1). Only the
|
||||||
current-page is-active nav marker and the static ?next= fallback
|
current-page is-active nav marker and the static ?next= fallback
|
||||||
may differ per page (task 05's story E2E pins the rendered
|
may differ per page (task 05's story E2E pins the rendered
|
||||||
@@ -137,7 +138,6 @@ def test_all_five_pages_share_the_header_control_order() -> None:
|
|||||||
'href="/"',
|
'href="/"',
|
||||||
'id="nav-sources"',
|
'id="nav-sources"',
|
||||||
'id="nav-tuning"',
|
'id="nav-tuning"',
|
||||||
'id="steering-toggle"',
|
|
||||||
'id="sync-btn"',
|
'id="sync-btn"',
|
||||||
'id="new-chat-btn"',
|
'id="new-chat-btn"',
|
||||||
'id="sign-in-link"',
|
'id="sign-in-link"',
|
||||||
@@ -288,16 +288,16 @@ def test_login_page_carries_the_full_header() -> None:
|
|||||||
"""Phase 34 task 03 (owner confirmation 2026-08-26): the noted
|
"""Phase 34 task 03 (owner confirmation 2026-08-26): the noted
|
||||||
boundary is reversed by owner decision — the login page finally
|
boundary is reversed by owner decision — the login page finally
|
||||||
carries the FULL shared header: the nav gains the #nav-tuning link
|
carries the FULL shared header: the nav gains the #nav-tuning link
|
||||||
(same ship-hidden markup as the other pages), plus the Tuning
|
(same ship-hidden markup as the other pages), plus the admin-only
|
||||||
toggle, the admin-only Sync button, New chat, and the Sign in /
|
Sync button, New chat, and the Sign in / Sign out pair (ship hidden
|
||||||
Sign out pair (ship hidden — initSharedHeader reveals exactly one
|
— initSharedHeader reveals exactly one after whoami; the static
|
||||||
after whoami; the static ?next= fallback is the login page itself).
|
?next= fallback is the login page itself). The Tuning toggle is NOT
|
||||||
|
among them — removed from the navbar at owner request (2026-08-28).
|
||||||
No nav link is "current" on the auth page."""
|
No nav link is "current" on the auth page."""
|
||||||
text = _text(LOGIN_HTML)
|
text = _text(LOGIN_HTML)
|
||||||
for marker in (
|
for marker in (
|
||||||
'id="nav-sources"',
|
'id="nav-sources"',
|
||||||
'id="nav-tuning"',
|
'id="nav-tuning"',
|
||||||
'id="steering-toggle"',
|
|
||||||
'id="sync-btn"',
|
'id="sync-btn"',
|
||||||
'id="new-chat-btn"',
|
'id="new-chat-btn"',
|
||||||
'id="sign-in-link"',
|
'id="sign-in-link"',
|
||||||
@@ -410,12 +410,11 @@ def test_header_module_owns_the_steering_panel() -> None:
|
|||||||
"""header.js owns the steering panel behavior (moved from app.js in
|
"""header.js owns the steering panel behavior (moved from app.js in
|
||||||
phase 34 task 01): the module-level null-safe element refs, the
|
phase 34 task 01): the module-level null-safe element refs, the
|
||||||
newest-first textContent render (XSS contract), the labeled
|
newest-first textContent render (XSS contract), the labeled
|
||||||
per-note delete, the count badge, the announcer, and the toggle
|
per-note delete, and the announcer — and NO toggle wiring anymore
|
||||||
binding that runs at module import (like the sign-out binding)."""
|
(the navbar #steering-toggle was removed at owner request,
|
||||||
|
2026-08-28; note management lives on /tuning.html)."""
|
||||||
js = _text(HEADER_JS)
|
js = _text(HEADER_JS)
|
||||||
for selector in (
|
for selector in (
|
||||||
"#steering-toggle",
|
|
||||||
"#steering-count",
|
|
||||||
"#steering-panel",
|
"#steering-panel",
|
||||||
"#steering-list",
|
"#steering-list",
|
||||||
"#steering-empty",
|
"#steering-empty",
|
||||||
@@ -430,9 +429,8 @@ def test_header_module_owns_the_steering_panel() -> None:
|
|||||||
assert 'del.setAttribute("aria-label", `Delete tuning note: ${n.note}`)' in js
|
assert 'del.setAttribute("aria-label", `Delete tuning note: ${n.note}`)' in js
|
||||||
assert "async function deleteSteeringNote" in js
|
assert "async function deleteSteeringNote" in js
|
||||||
assert 'fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" })' in js
|
assert 'fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" })' in js
|
||||||
assert "steeringToggle.addEventListener" in js, "the toggle binding is module-owned"
|
assert "refreshSteering()" in js # the admin boot refresh + the form's save
|
||||||
assert 'setAttribute("aria-expanded"' in js
|
assert "steeringToggle" not in js, "the navbar toggle is gone (2026-08-28)"
|
||||||
assert "refreshSteering()" in js # re-open refreshes the list
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_module_exports_refresh_and_announce_steering() -> None:
|
def test_header_module_exports_refresh_and_announce_steering() -> None:
|
||||||
@@ -455,17 +453,18 @@ def test_header_module_exports_refresh_and_announce_steering() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_init_shared_header_gates_the_steering_surface() -> None:
|
def test_init_shared_header_gates_the_steering_surface() -> None:
|
||||||
"""Inside initSharedHeader: admin → the list refreshes (count badge
|
"""Inside initSharedHeader: admin → the list refreshes (the panel
|
||||||
right before the panel is ever opened; only when the page ships the
|
ships hidden and is only kept fresh; only when the page ships the
|
||||||
panel markup); anonymous → the toggle + panel are REMOVED from the
|
panel markup); anonymous → the panel is REMOVED from the DOM
|
||||||
DOM (phase-16 'absent, not hidden') and /api/steering is never
|
(phase-16 'absent, not hidden') and /api/steering is never fetched.
|
||||||
fetched."""
|
The navbar toggle was removed at owner request (2026-08-28) — no
|
||||||
|
unhide / remove of a toggle may be left behind."""
|
||||||
js = _text(HEADER_JS)
|
js = _text(HEADER_JS)
|
||||||
fn = js.find("function initSharedHeader")
|
fn = js.find("function initSharedHeader")
|
||||||
assert fn != -1
|
assert fn != -1
|
||||||
body = js[fn : js.find("\n}", fn)]
|
body = js[fn : js.find("\n}", fn)]
|
||||||
assert "if (steeringPanel) refreshSteering();" in body
|
assert "if (steeringPanel) refreshSteering();" in body
|
||||||
assert "steeringToggle?.remove();" in body
|
assert "steeringToggle" not in body
|
||||||
assert "steeringPanel?.remove();" in body
|
assert "steeringPanel?.remove();" in body
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Unit: the navbar steering toggle is gone (owner request, 2026-08-28).
|
||||||
|
|
||||||
|
The "Tuning" steering toggle (``#steering-toggle``) used to sit in
|
||||||
|
every page's shared header — shipped hidden (phase 40), revealed for
|
||||||
|
the admin, removed from the DOM for anonymous. The owner asked for the
|
||||||
|
button to go away entirely: note management now lives on the
|
||||||
|
standalone Tuning page (``/tuning.html``, phase 27). This file pins the
|
||||||
|
removal at source level — the toggle + count badge are ABSENT from all
|
||||||
|
six pages, the ``#steering-panel`` section still ships hidden (kept
|
||||||
|
fresh by the chat page's per-bubble Tune form through header.js), the
|
||||||
|
shared module owns no toggle wiring anymore, and the admin-only
|
||||||
|
``#nav-tuning`` link — the surviving path to the notes — still ships
|
||||||
|
hidden.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||||
|
ASSETS = FRONTEND / "assets"
|
||||||
|
|
||||||
|
HEADER_JS = ASSETS / "header.js"
|
||||||
|
|
||||||
|
#: All six pages carry the shared header block (phase 34's five pages +
|
||||||
|
#: phase 35's git-sources page).
|
||||||
|
PAGES = (
|
||||||
|
FRONTEND / "index.html",
|
||||||
|
FRONTEND / "sources.html",
|
||||||
|
FRONTEND / "document.html",
|
||||||
|
FRONTEND / "git-sources.html",
|
||||||
|
FRONTEND / "login.html",
|
||||||
|
FRONTEND / "tuning.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(path: Path) -> str:
|
||||||
|
assert path.is_file(), f"missing frontend file: {path}"
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _init_body(js: str) -> str:
|
||||||
|
"""The source of initSharedHeader in header.js."""
|
||||||
|
fn = js.find("function initSharedHeader")
|
||||||
|
assert fn != -1, "initSharedHeader must be defined"
|
||||||
|
return js[fn : js.find("\n}", fn)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- the removal: absent from every page's navbar ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_steering_toggle_removed_from_all_six_pages() -> None:
|
||||||
|
"""The #steering-toggle button (and its #steering-count badge) is
|
||||||
|
gone from the navbar of EVERY page — absent, not hidden."""
|
||||||
|
for html in PAGES:
|
||||||
|
text = _text(html)
|
||||||
|
assert 'id="steering-toggle"' not in text, (
|
||||||
|
f"{html.name}: #steering-toggle must be removed from the navbar"
|
||||||
|
)
|
||||||
|
assert 'class="steering-toggle"' not in text, (
|
||||||
|
f"{html.name}: the .steering-toggle button must be removed"
|
||||||
|
)
|
||||||
|
assert 'id="steering-count"' not in text, (
|
||||||
|
f"{html.name}: the #steering-count badge lived inside the toggle"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_steering_panel_still_ships_hidden_on_all_six_pages() -> None:
|
||||||
|
"""The #steering-panel section survives the toggle removal (the chat
|
||||||
|
page's per-bubble Tune form keeps it fresh through header.js) and
|
||||||
|
still ships hidden, with its list / empty state / announcer."""
|
||||||
|
for html in PAGES:
|
||||||
|
text = _text(html)
|
||||||
|
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', text)
|
||||||
|
assert tag, f"{html.name}: missing the #steering-panel section"
|
||||||
|
assert re.search(r"\bhidden\b", tag.group(0)), "the panel ships hidden"
|
||||||
|
assert 'id="steering-list"' in text, f"{html.name}: missing #steering-list"
|
||||||
|
assert 'id="steering-empty"' in text, f"{html.name}: missing #steering-empty"
|
||||||
|
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', text), (
|
||||||
|
f"{html.name}: missing the #steering-announcer live region"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- header.js: no toggle wiring left, panel contract intact ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_js_carries_no_toggle_wiring() -> None:
|
||||||
|
"""header.js owns no #steering-toggle reference anymore: no element
|
||||||
|
lookup, no admin unhide, no anonymous removal, no click binding, no
|
||||||
|
open/close helper, no count badge."""
|
||||||
|
js = _text(HEADER_JS)
|
||||||
|
assert 'querySelector("#steering-toggle")' not in js
|
||||||
|
assert "steeringToggle" not in js
|
||||||
|
assert "setSteeringPanel" not in js
|
||||||
|
assert 'querySelector("#steering-count")' not in js
|
||||||
|
assert "steeringCount" not in js
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_js_keeps_the_panel_contract() -> None:
|
||||||
|
"""What survives: inside initSharedHeader the admin branch still
|
||||||
|
refreshes the panel list (fire-and-forget) and the anonymous branch
|
||||||
|
still REMOVES the panel from the DOM (phase-16 "absent, not hidden")
|
||||||
|
so /api/steering is never fetched for anonymous visitors."""
|
||||||
|
body = _init_body(_text(HEADER_JS))
|
||||||
|
admin = body.find("if (admin)")
|
||||||
|
refresh = body.find("if (steeringPanel) refreshSteering();")
|
||||||
|
assert admin != -1 and refresh != -1, "the admin refresh is missing"
|
||||||
|
assert admin < refresh, "the refresh must live in the admin branch"
|
||||||
|
assert "steeringPanel?.remove();" in body, (
|
||||||
|
"the anonymous remove-from-DOM path must stay intact"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- the surviving path to the notes ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_nav_tuning_still_ships_hidden_on_all_six_pages() -> None:
|
||||||
|
"""The admin-only Tuning NAV LINK (#nav-tuning) — the surviving path
|
||||||
|
to the steering notes now that the navbar toggle is gone — still
|
||||||
|
ships hidden on every page (the phase-19/29/35 contract)."""
|
||||||
|
for html in PAGES:
|
||||||
|
tag = re.search(r'<a[^>]*id="nav-tuning"[^>]*>', _text(html))
|
||||||
|
assert tag, f"{html.name}: missing the #nav-tuning nav link"
|
||||||
|
assert re.search(r"\bhidden\b", tag.group(0)), (
|
||||||
|
f"{html.name}: #nav-tuning must ship hidden"
|
||||||
|
)
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""Unit: the tuning toggle ships hidden — the anonymous flash fix (phase 40).
|
|
||||||
|
|
||||||
The "Tuning" steering toggle (``#steering-toggle``) used to ship VISIBLE
|
|
||||||
in every page's shared header and was removed by ``assets/header.js``
|
|
||||||
only after ``/api/whoami`` resolved — so an anonymous user briefly saw
|
|
||||||
the button on every page load (``TODO.md`` L3). The fix mirrors the
|
|
||||||
admin-only NAV LINKS (phase 19/29/35 contract): the toggle now SHIPS
|
|
||||||
with the ``hidden`` attribute in all six pages and ``initSharedHeader``
|
|
||||||
unhides it only when whoami says admin. The browser behavior is
|
|
||||||
E2E-covered (``tests/e2e/test_tuning_toggle_flash.py``); here we pin
|
|
||||||
the source-level wiring — the ship-hidden markup on every page, the
|
|
||||||
admin unhide line inside ``initSharedHeader``, the intact anonymous
|
|
||||||
remove-from-DOM path (phase 16 "absent, not hidden"), and the
|
|
||||||
``#nav-tuning`` hidden contract this phase relies on — so a silent
|
|
||||||
regression is caught without a browser.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
||||||
ASSETS = FRONTEND / "assets"
|
|
||||||
|
|
||||||
HEADER_JS = ASSETS / "header.js"
|
|
||||||
|
|
||||||
#: All six pages carry the shared header block (phase 34's five pages +
|
|
||||||
#: phase 35's git-sources page).
|
|
||||||
PAGES = (
|
|
||||||
FRONTEND / "index.html",
|
|
||||||
FRONTEND / "sources.html",
|
|
||||||
FRONTEND / "document.html",
|
|
||||||
FRONTEND / "git-sources.html",
|
|
||||||
FRONTEND / "login.html",
|
|
||||||
FRONTEND / "tuning.html",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _text(path: Path) -> str:
|
|
||||||
assert path.is_file(), f"missing frontend file: {path}"
|
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _toggle_tag(html: Path) -> str:
|
|
||||||
tag = re.search(r"<button[^>]*id=\"steering-toggle\"[^>]*>", _text(html))
|
|
||||||
assert tag, f"{html.name}: missing the #steering-toggle button"
|
|
||||||
return tag.group(0)
|
|
||||||
|
|
||||||
|
|
||||||
def _toggle_body(html: Path) -> str:
|
|
||||||
"""The full <button>…</button> block, for the icon/label/badge pins."""
|
|
||||||
text = _text(html)
|
|
||||||
start = text.find('id="steering-toggle"')
|
|
||||||
assert start != -1, f"{html.name}: missing the #steering-toggle button"
|
|
||||||
return text[start : text.find("</button>", start)]
|
|
||||||
|
|
||||||
|
|
||||||
def _init_body(js: str) -> str:
|
|
||||||
"""The source of initSharedHeader in header.js."""
|
|
||||||
fn = js.find("function initSharedHeader")
|
|
||||||
assert fn != -1, "initSharedHeader must be defined"
|
|
||||||
return js[fn : js.find("\n}", fn)]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- ship-hidden markup: zero flash for anonymous ----------
|
|
||||||
|
|
||||||
|
|
||||||
def test_steering_toggle_ships_hidden_on_all_six_pages() -> None:
|
|
||||||
"""#steering-toggle carries the ``hidden`` attribute in ALL SIX
|
|
||||||
pages — the exact ship-hidden contract the admin-only nav links
|
|
||||||
use, so an anonymous user never sees the "Tuning" button for a
|
|
||||||
single frame, on any page."""
|
|
||||||
for html in PAGES:
|
|
||||||
tag = _toggle_tag(html)
|
|
||||||
assert re.search(r"\bhidden\b", tag), (
|
|
||||||
f"{html.name}: #steering-toggle must ship hidden"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_steering_toggle_keeps_its_existing_markup() -> None:
|
|
||||||
"""Only the ``hidden`` attribute was added: type, class, the
|
|
||||||
aria-expanded / aria-controls wiring, the decorative icon, the
|
|
||||||
"Tuning" label, and the #steering-count badge stay byte-identical
|
|
||||||
on every page — the admin UX is unchanged."""
|
|
||||||
for html in PAGES:
|
|
||||||
tag = _toggle_tag(html)
|
|
||||||
assert 'type="button"' in tag
|
|
||||||
assert 'class="steering-toggle"' in tag
|
|
||||||
assert 'aria-expanded="false"' in tag
|
|
||||||
assert 'aria-controls="steering-panel"' in tag
|
|
||||||
body = _toggle_body(html)
|
|
||||||
assert '<svg aria-hidden="true"' in body, f"{html.name}: icon is gone"
|
|
||||||
assert '<span class="steering-label">Tuning</span>' in body, (
|
|
||||||
f"{html.name}: label markup changed"
|
|
||||||
)
|
|
||||||
assert '<span class="steering-count" id="steering-count">0</span>' in body, (
|
|
||||||
f"{html.name}: count badge markup changed"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_steering_panel_still_ships_hidden_on_all_six_pages() -> None:
|
|
||||||
"""The #steering-panel section already shipped hidden and stays
|
|
||||||
that way (this phase never touches the panel)."""
|
|
||||||
for html in PAGES:
|
|
||||||
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', _text(html))
|
|
||||||
assert tag, f"{html.name}: missing the #steering-panel section"
|
|
||||||
assert re.search(r"\bhidden\b", tag.group(0)), "the panel ships hidden"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- header.js: reveal-for-admin, anonymous removal intact ----------
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_js_unhides_the_toggle_for_admin() -> None:
|
|
||||||
"""Inside initSharedHeader, the admin branch unhides the toggle —
|
|
||||||
and that line sits BEFORE the refreshSteering() call (the count
|
|
||||||
badge is right before the panel is ever opened)."""
|
|
||||||
body = _init_body(_text(HEADER_JS))
|
|
||||||
assert "if (steeringToggle) steeringToggle.hidden = false;" in body, (
|
|
||||||
"the admin unhide is missing from initSharedHeader"
|
|
||||||
)
|
|
||||||
admin = body.find("if (admin)")
|
|
||||||
unhide = body.find("steeringToggle.hidden = false")
|
|
||||||
refresh = body.find("if (steeringPanel) refreshSteering();")
|
|
||||||
assert -1 < admin < unhide, "the unhide must live in the admin branch"
|
|
||||||
assert unhide < refresh, "the unhide must precede the refreshSteering() call"
|
|
||||||
|
|
||||||
|
|
||||||
def test_anonymous_removal_path_is_intact() -> None:
|
|
||||||
"""The phase-16 "absent, not hidden" contract is preserved: the
|
|
||||||
anonymous branch still REMOVES the toggle + panel from the DOM —
|
|
||||||
the new hidden attribute only closes the pre-whoami flash window,
|
|
||||||
the end state (absent) is unchanged."""
|
|
||||||
body = _init_body(_text(HEADER_JS))
|
|
||||||
assert "steeringToggle?.remove();" in body
|
|
||||||
assert "steeringPanel?.remove();" in body
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- the nav contract this phase relies on ----------
|
|
||||||
|
|
||||||
|
|
||||||
def test_nav_tuning_still_ships_hidden_on_all_six_pages() -> None:
|
|
||||||
"""The admin-only Tuning NAV LINK (#nav-tuning) — the contract the
|
|
||||||
toggle now mirrors — still ships hidden on every page: one
|
|
||||||
ship-hidden / reveal-for-admin family, nav link and toggle alike."""
|
|
||||||
for html in PAGES:
|
|
||||||
tag = re.search(r'<a[^>]*id="nav-tuning"[^>]*>', _text(html))
|
|
||||||
assert tag, f"{html.name}: missing the #nav-tuning nav link"
|
|
||||||
assert re.search(r"\bhidden\b", tag.group(0)), (
|
|
||||||
f"{html.name}: #nav-tuning must ship hidden"
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user