feat(sources): admin page to add and remove git sources (TODO.md L4)
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/* Brain of Reese — Git sources admin page (phase 35, task 04).
|
||||
*
|
||||
* The page module for /git-sources.html: the admin-only manager for the
|
||||
* stored git source list (git-sources table, phase 35 tasks 01/02).
|
||||
* This module is the single owner of the page's behaviour:
|
||||
*
|
||||
* • boot — initSharedHeader() (one cached whoami, shared with the
|
||||
* header toggling): anonymous → the sign-in gate shows and the
|
||||
* manager stays hidden (the exact Sources page gate pattern, and
|
||||
* NO /api/git-sources call is made); admin → gate hidden,
|
||||
* #git-sources-content revealed, loadSources().
|
||||
* • loadSources() — GET /api/git-sources → the table rows
|
||||
* (#git-sources-tbody), the env-fallback note's visibility
|
||||
* (from_env), and the empty state. URLs are ALWAYS rendered with
|
||||
* textContent — never innerHTML (they may embed user:pass@
|
||||
* credentials; phase 32's masking discipline). Non-2xx or a
|
||||
* network failure renders the role="alert" load error with a
|
||||
* retry button — never a stuck page.
|
||||
* • add — #git-source-form submit → POST /api/git-sources {url}.
|
||||
* §7.4 never-stale: the button disables + relabels "Adding…"
|
||||
* while the request is out, re-enables ("Add source") on success
|
||||
* AND failure. 201 clears the input, reloads the list, and
|
||||
* focuses the new row's Remove button (a11y); a failure (409
|
||||
* duplicate, 422 shape) shows the server detail inline under the
|
||||
* form (role="alert", 422 shape-aware like the tuning forms) and
|
||||
* keeps the input — the instruction survives.
|
||||
* • remove — a row's Remove button asks window.confirm first
|
||||
* (removal prunes the documents only on the NEXT sync — the
|
||||
* confirm says so). Cancel → nothing; ok → the row button
|
||||
* disables, DELETE /api/git-sources/{id}, loadSources(). A
|
||||
* failure shows a per-row role="alert" error and re-enables the
|
||||
* button. Env-fallback rows (id null — the list comes from
|
||||
* BOR_GIT_SOURCES, not the table) carry no Remove: nothing is
|
||||
* stored to remove — they show a "from .env" tag instead.
|
||||
* • announce(msg) — #git-sources-announcer (role=status,
|
||||
* aria-live=polite): the screen-reader confirmation for loads,
|
||||
* adds, and removals.
|
||||
*
|
||||
* Scope boundary (phase locked decisions): adding or removing a repo
|
||||
* does NOT clone, import, or prune — the header's Sync sources button
|
||||
* (module-owned in assets/header.js) performs that; the page's hint
|
||||
* box says so.
|
||||
*
|
||||
* The shared header module loads through this script's own relative
|
||||
* import ("./header.js") — a hoisted import evaluated before this body
|
||||
* runs (single-evaluation design: no direct <script> tag; esbuild
|
||||
* inlines it into the page bundle in the image build).
|
||||
*/
|
||||
|
||||
import { initSharedHeader } from "./header.js";
|
||||
|
||||
/* ---------- page elements (git-sources.html, task 04) ---------- */
|
||||
const gateEl = document.querySelector("#git-sources-gate");
|
||||
const contentEl = document.querySelector("#git-sources-content");
|
||||
const formEl = document.querySelector("#git-source-form");
|
||||
const urlInput = document.querySelector("#git-source-url");
|
||||
const addBtn = document.querySelector("#git-source-add");
|
||||
const addError = document.querySelector("#git-source-error");
|
||||
const loadErrorEl = document.querySelector("#git-sources-load-error");
|
||||
const loadErrorText = document.querySelector("#git-sources-load-error-text");
|
||||
const retryBtn = document.querySelector("#git-sources-retry");
|
||||
const tableWrap = document.querySelector("#git-sources-table-wrap");
|
||||
const tbody = document.querySelector("#git-sources-tbody");
|
||||
const emptyEl = document.querySelector("#git-sources-empty");
|
||||
const envNote = document.querySelector("#git-sources-env-note");
|
||||
const announcer = document.querySelector("#git-sources-announcer");
|
||||
|
||||
/* Polite live region: the screen-reader confirmation for loads, adds,
|
||||
and removals (the phase-15 announcer pattern). */
|
||||
function announce(message) {
|
||||
if (announcer) announcer.textContent = message;
|
||||
}
|
||||
|
||||
/* Added date — localized (toLocaleString); env-fallback rows carry
|
||||
added_at null, and a corrupt timestamp must not blank the cell. */
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
}
|
||||
|
||||
/* FastAPI error bodies: a string detail or the validation-error array
|
||||
(the first entry's msg is the human line). Same extraction as
|
||||
tuning.js — 422 shape-aware. */
|
||||
async function apiDetail(r, fallback) {
|
||||
try {
|
||||
const data = await r.json();
|
||||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||||
return String(data.detail[0].msg);
|
||||
}
|
||||
if (typeof data.detail === "string" && data.detail) return data.detail;
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/* ---------- load / render ---------- */
|
||||
|
||||
/* GET /api/git-sources → the table rows + env note + empty state.
|
||||
The load error (role="alert" + retry) is the ONLY terminal state a
|
||||
failed fetch may reach — never a stuck page. */
|
||||
async function loadSources() {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/git-sources");
|
||||
} catch {
|
||||
showLoadError("Could not reach the server — is the app running?");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showLoadError(await apiDetail(r, `The server could not list the git sources (${r.status}).`));
|
||||
return;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await r.json();
|
||||
} catch {
|
||||
showLoadError("The server sent an unreadable list — try again.");
|
||||
return;
|
||||
}
|
||||
hideLoadError();
|
||||
const sources = Array.isArray(data.sources) ? data.sources : [];
|
||||
renderSources(sources, data.from_env === true);
|
||||
announce(`${sources.length} git source${sources.length === 1 ? "" : "s"} listed.`);
|
||||
}
|
||||
|
||||
function showLoadError(message) {
|
||||
if (loadErrorText) loadErrorText.textContent = message;
|
||||
if (loadErrorEl) loadErrorEl.hidden = false;
|
||||
// The list state is unknown — hide the table AND the empty state so
|
||||
// the error is the only claim about the list's contents.
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
if (emptyEl) emptyEl.hidden = true;
|
||||
}
|
||||
|
||||
function hideLoadError() {
|
||||
if (loadErrorEl) loadErrorEl.hidden = true;
|
||||
if (loadErrorText) loadErrorText.textContent = "";
|
||||
}
|
||||
|
||||
function renderSources(sources, fromEnv) {
|
||||
if (envNote) envNote.hidden = !fromEnv;
|
||||
if (tbody) {
|
||||
tbody.replaceChildren();
|
||||
for (const s of sources) tbody.appendChild(makeRow(s));
|
||||
}
|
||||
const hasRows = sources.length > 0;
|
||||
if (tableWrap) tableWrap.hidden = !hasRows;
|
||||
if (emptyEl) emptyEl.hidden = hasRows;
|
||||
}
|
||||
|
||||
/* One row: the URL in a mono <code> (textContent only — URLs may
|
||||
contain credentials), the localized added date ("—" for env
|
||||
fallback rows), and the per-row Remove button — or the "from .env"
|
||||
tag for env-fallback rows (id null: nothing is stored to remove;
|
||||
the env note says where the active list comes from). */
|
||||
const REMOVE_ICON =
|
||||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
|
||||
|
||||
function makeRow(s) {
|
||||
const tr = document.createElement("tr");
|
||||
if (s.id) tr.dataset.id = s.id;
|
||||
|
||||
const urlTd = document.createElement("td");
|
||||
urlTd.className = "git-source-url-cell";
|
||||
urlTd.title = s.url; // full URL on hover (long URLs scroll the wrapper)
|
||||
const code = document.createElement("code");
|
||||
code.textContent = s.url; // rendered as text, never as HTML
|
||||
urlTd.appendChild(code);
|
||||
tr.appendChild(urlTd);
|
||||
|
||||
const addedTd = document.createElement("td");
|
||||
addedTd.textContent = fmtDate(s.added_at);
|
||||
tr.appendChild(addedTd);
|
||||
|
||||
const actTd = document.createElement("td");
|
||||
actTd.className = "git-source-actions-cell";
|
||||
if (s.id) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "git-source-remove";
|
||||
btn.setAttribute("aria-label", `Remove git source: ${s.url}`);
|
||||
btn.innerHTML = REMOVE_ICON + "<span>Remove</span>";
|
||||
const rowError = document.createElement("span");
|
||||
rowError.className = "git-source-row-error";
|
||||
rowError.setAttribute("role", "alert");
|
||||
rowError.hidden = true;
|
||||
btn.addEventListener("click", () => removeSource(s, btn, rowError));
|
||||
actTd.append(btn, rowError);
|
||||
} else {
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "git-source-env-tag";
|
||||
tag.textContent = "from .env";
|
||||
actTd.appendChild(tag);
|
||||
}
|
||||
tr.appendChild(actTd);
|
||||
return tr;
|
||||
}
|
||||
|
||||
/* ---------- remove (DELETE /api/git-sources/{id}) ----------
|
||||
* Removal does not prune anything immediately — the next sync does
|
||||
* (phase scope boundary), so the confirm says exactly that. Cancel →
|
||||
* nothing; a failed delete → per-row role="alert" error + re-enabled
|
||||
* button (never a stuck row); success → the list reloads. */
|
||||
async function removeSource(s, btn, rowError) {
|
||||
const ok = window.confirm(
|
||||
"Remove this git source from the list? Its documents stay indexed until the next sync prunes them.",
|
||||
);
|
||||
if (!ok) return;
|
||||
btn.disabled = true; // one delete per click
|
||||
rowError.hidden = true;
|
||||
try {
|
||||
const r = await fetch(`/api/git-sources/${encodeURIComponent(s.id)}`, { method: "DELETE" });
|
||||
if (!r.ok) {
|
||||
rowError.textContent = await apiDetail(r, "Could not remove the git source — try again.");
|
||||
rowError.hidden = false;
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
announce("Git source removed.");
|
||||
await loadSources(); // 204: the server confirmed — the list re-renders
|
||||
} catch {
|
||||
rowError.textContent = "Could not remove the git source — is the app reachable?";
|
||||
rowError.hidden = false;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- add (POST /api/git-sources) ---------- */
|
||||
|
||||
if (formEl && urlInput && addBtn) {
|
||||
formEl.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
// Client-side non-empty check (the input is `required` too — the
|
||||
// browser's native prompt is the first line, this one the second).
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) {
|
||||
if (addError) {
|
||||
addError.textContent = "Enter a git URL to add.";
|
||||
addError.hidden = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (addError) addError.hidden = true; // a new attempt starts clean
|
||||
addBtn.disabled = true; // §7.4: one POST per click
|
||||
addBtn.textContent = "Adding…";
|
||||
try {
|
||||
const r = await fetch("/api/git-sources", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
if (r.ok) {
|
||||
let createdId = null;
|
||||
try {
|
||||
createdId = (await r.json()).id ?? null;
|
||||
} catch {
|
||||
/* the 201 body is advisory — the reload is the truth */
|
||||
}
|
||||
urlInput.value = ""; // 201: the source is stored
|
||||
announce("Git source added.");
|
||||
await loadSources(); // the new row lands in the table
|
||||
focusNewRow(createdId); // a11y: land the caret on the new row
|
||||
return;
|
||||
}
|
||||
// 409 duplicate / 422 shape / anything else: the server detail
|
||||
// inline (never echoing a URL the server wouldn't), form kept —
|
||||
// the input survives so the fix is one edit, not a re-type.
|
||||
if (addError) {
|
||||
addError.textContent = await apiDetail(r, "Could not add the git source — try again.");
|
||||
addError.hidden = false;
|
||||
}
|
||||
} catch {
|
||||
if (addError) {
|
||||
addError.textContent = "Could not add the git source — is the app reachable?";
|
||||
addError.hidden = false;
|
||||
}
|
||||
} finally {
|
||||
addBtn.disabled = false; // never stale — success OR failure
|
||||
addBtn.textContent = "Add source";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* After a successful add, focus the new row's Remove button so the
|
||||
keyboard/screen-reader user lands where the new data is. The 201
|
||||
body carries the row id (tr[data-id]); without one, the first row
|
||||
is the fallback (the list is small and ordered). */
|
||||
function focusNewRow(createdId) {
|
||||
if (!tbody) return;
|
||||
const row = createdId
|
||||
? tbody.querySelector(`tr[data-id="${CSS.escape(createdId)}"]`)
|
||||
: null;
|
||||
const target = (row || tbody.querySelector("tr"))?.querySelector(".git-source-remove");
|
||||
if (target) target.focus();
|
||||
}
|
||||
|
||||
/* ---------- retry + boot ---------- */
|
||||
|
||||
if (retryBtn) retryBtn.addEventListener("click", () => loadSources());
|
||||
|
||||
(async () => {
|
||||
// The shared header FIRST (Sign in/out + the admin-only nav links +
|
||||
// the Sync button — one cached whoami), then the gate: anonymous
|
||||
// visitors get the gate and NO /api/git-sources call (the Sources
|
||||
// page gate pattern); the admin gets the manager.
|
||||
const admin = await initSharedHeader();
|
||||
if (!admin) {
|
||||
if (gateEl) gateEl.hidden = false;
|
||||
if (contentEl) contentEl.hidden = true; // ships hidden — stays hidden
|
||||
return;
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
if (contentEl) contentEl.hidden = false;
|
||||
await loadSources();
|
||||
})();
|
||||
@@ -6,8 +6,9 @@
|
||||
*
|
||||
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
|
||||
* decided by /api/whoami at load);
|
||||
* • the admin-only nav links — "Sources" (#nav-sources, phase 19)
|
||||
* and "Tuning" (#nav-tuning, phase 29) — phase 19 UX revision
|
||||
* • the admin-only nav links — "Sources" (#nav-sources, phase 19),
|
||||
* "Git sources" (#nav-git-sources, phase 35) and "Tuning"
|
||||
* (#nav-tuning, phase 29) — phase 19 UX revision
|
||||
* (owner permission 2026-08-23): hidden for anonymous on EVERY
|
||||
* page, revealed for admin. Phase 34 task 03 (owner confirmation
|
||||
* 2026-08-26): the SAME nav ships on all five pages (chat,
|
||||
@@ -109,6 +110,11 @@ export async function initSharedHeader() {
|
||||
if (signOut) signOut.hidden = !admin;
|
||||
const navSources = document.querySelector("#nav-sources");
|
||||
if (navSources) navSources.hidden = !admin;
|
||||
// Phase 35 (owner permission 2026-08-26): the Git sources nav link —
|
||||
// admin-only, the same ship-hidden / reveal-for-admin contract as
|
||||
// the Sources link above.
|
||||
const navGitSources = document.querySelector("#nav-git-sources");
|
||||
if (navGitSources) navGitSources.hidden = !admin;
|
||||
// Phase 29: the Global Tuning nav link (every page from phase 34
|
||||
// task 03) — admin-only, the same ship-hidden / reveal-for-admin
|
||||
// contract as the Sources link.
|
||||
|
||||
+274
-11
@@ -1275,6 +1275,253 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.docs-table tbody tr:hover { background: var(--bg); }
|
||||
.docs-table tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* ---------- Git sources page (phase 35) ----------
|
||||
/git-sources.html: the admin-only manager for the stored git source
|
||||
list (add / remove, git-sources table). Same full-width table
|
||||
language as the Sources page (no skinny single-column list), the
|
||||
phase-16 gate reused verbatim (.sources-gate classes), and the
|
||||
phase-27 form-card language for the add form. Every pair reuses the
|
||||
Phase-08 AA palette: dark ink on brand 5.2:1 (never white on
|
||||
brand, 3.7:1, fails), brand-ink/brand-soft 6.9:1, err 9.1:1,
|
||||
ink-soft >=6.9:1. Touch targets >=44px; :focus-visible via the
|
||||
global 3px outline rule. No filter: blur, no CDN, system fonts. */
|
||||
.git-sources-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Add form — the tuning form's surface as a single row: visible label
|
||||
+ mono URL input (the credentials case is real, so the input is
|
||||
mono) + the brand "Add source" button; wraps to a column at narrow
|
||||
widths (the <=640px block below). */
|
||||
#git-source-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
}
|
||||
#git-source-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
|
||||
#git-source-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; }
|
||||
#git-source-url {
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
min-height: 44px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.88rem;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.7rem;
|
||||
}
|
||||
#git-source-url::placeholder { color: var(--ink-soft); }
|
||||
#git-source-url:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||
#git-source-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1.2rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--brand);
|
||||
color: var(--bg); /* dark ink on brand: 5.2:1 */
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
#git-source-add:hover:not(:disabled) { background: #7d88f5; }
|
||||
#git-source-add:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
/* The add form's inline error (role=alert): the err pair (9.1:1);
|
||||
flex-basis 100% drops it onto its own row under the input. */
|
||||
.git-source-error {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Load failure (role=alert) + the retry action: a failed
|
||||
GET /api/git-sources must never leave a stuck page. The retry
|
||||
button keeps the err pair on the err surface (9.1:1). */
|
||||
.git-source-load-error {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.7rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.git-source-load-error > span { flex: 1; min-width: 12rem; }
|
||||
#git-sources-retry {
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--err-ink);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
#git-sources-retry:hover { background: rgb(239 68 68 / 0.12); }
|
||||
|
||||
/* Env-fallback note (from_env: true — the table is empty and the list
|
||||
is BOR_GIT_SOURCES): the info chip in the theme palette —
|
||||
brand-soft surface, brand-ink text (6.9:1). */
|
||||
.git-source-env-note {
|
||||
margin: 0;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
border: 1px solid var(--brand-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.git-source-env-note code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
background: var(--surface);
|
||||
color: var(--brand-ink); /* 8.7:1 on surface */
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Hint box (role=note): the page-sub styling family — ink-soft on
|
||||
surface (6.9:1), a dashed border marks it as guidance, not state.
|
||||
It names the Sync button: the action that clones the listed repos
|
||||
and prunes the removed ones (the phase scope boundary). */
|
||||
.git-source-hint {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* The list: the Sources page's table pattern — full width in the
|
||||
72rem frame, surface card, horizontally scrollable wrapper (the
|
||||
URL column never wraps or ellipsizes: long URLs, credentials
|
||||
included, scroll the wrapper instead of truncating). */
|
||||
#git-sources-table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.git-sources-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 560px;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
.git-sources-table th, .git-sources-table td {
|
||||
text-align: left;
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.git-sources-table th {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
/* URL cell: mono at the Sources-table size; the <code> is plain
|
||||
(no chip background — the cell IS the mono readout). */
|
||||
.git-sources-table td.git-source-url-cell { font-family: var(--mono); font-size: 0.82rem; }
|
||||
.git-sources-table td.git-source-url-cell code { font-family: inherit; }
|
||||
.git-sources-table tbody tr:hover { background: var(--bg); }
|
||||
.git-sources-table tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* Per-row Remove: the tuning row-action language (icon + label,
|
||||
>=44px) with the Delete hover pair (err 9.1:1). */
|
||||
.git-source-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.git-source-remove svg { width: 14px; height: 14px; display: block; }
|
||||
.git-source-remove:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.git-source-remove:disabled { opacity: 0.5; cursor: wait; }
|
||||
|
||||
/* Per-row delete failure (role=alert): the err pair, inline after the
|
||||
(re-enabled) button. */
|
||||
.git-source-row-error {
|
||||
margin-left: 0.6rem;
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Env-fallback rows carry no Remove (nothing is stored to remove) —
|
||||
the tag says where the row comes from (brand pair, 6.9:1). */
|
||||
.git-source-env-tag {
|
||||
color: var(--brand-ink);
|
||||
background: var(--brand-soft);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Empty state: the tuning page's quiet centered line at full table
|
||||
width — no stored rows AND no env fallback to show. */
|
||||
.git-sources-empty {
|
||||
margin: 0;
|
||||
padding: 1.4rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--ink-soft);
|
||||
font-style: italic;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
|
||||
/* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
|
||||
rows in one sticky <header> — row 1 reuses the standard .app-header /
|
||||
@@ -1660,10 +1907,14 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
— and let the brand wordmark (base ellipsis) absorb any remainder.
|
||||
The ≤640 block below stays tighter and wins at phone widths. */
|
||||
@media (max-width: 900px) {
|
||||
.header-inner { gap: 0.65rem; }
|
||||
.nav-link { padding: 0.4rem 0.6rem; font-size: 0.9rem; }
|
||||
.app-nav { gap: 0.2rem; }
|
||||
.new-chat-btn, .auth-link, .sync-btn, .steering-toggle { padding: 0.45rem 0.6rem; }
|
||||
.header-inner { gap: 0.6rem; }
|
||||
/* Phase 35: the admin-only "Git sources" link joins the nav — a
|
||||
fourth text pill on the admin bar — so the tablet squeeze tightens
|
||||
once more to hold 768px in the admin state (brand already the
|
||||
designated clip target, pills squeeze next). */
|
||||
.nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; }
|
||||
.app-nav { gap: 0.15rem; }
|
||||
.new-chat-btn, .auth-link, .sync-btn, .steering-toggle { padding: 0.45rem 0.5rem; }
|
||||
}
|
||||
|
||||
/* ---------- Responsive (mobile-first adjustments) ---------- */
|
||||
@@ -1687,7 +1938,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
18px brand mark) to hold 375px with the brand mark intact and
|
||||
360px with the brand clipped clean (overflow:hidden — the mark
|
||||
never overlaps the nav). */
|
||||
.header-inner { gap: 0.3rem; }
|
||||
/* Phase 35: the admin-only "Git sources" link joins the nav — a
|
||||
fourth text pill on the admin bar — so the mobile squeeze tightens
|
||||
once more (0.25rem inner gap, 0.72rem nav pills, 0.3rem pill
|
||||
padding, 0.05rem nav gap) to hold 375px — and 360px, the brand
|
||||
fully clipped — in the admin state without horizontal overflow. */
|
||||
.header-inner { gap: 0.25rem; }
|
||||
.brand { min-width: 0; overflow: hidden; }
|
||||
.brand-mark { width: 18px; height: 18px; }
|
||||
.brand-text {
|
||||
@@ -1698,20 +1954,20 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-link { padding: 0.35rem 0.3rem; font-size: 0.78rem; }
|
||||
.app-nav { gap: 0.1rem; }
|
||||
.new-chat-btn { padding: 0.4rem 0.35rem; }
|
||||
.nav-link { padding: 0.3rem 0.25rem; font-size: 0.72rem; }
|
||||
.app-nav { gap: 0.05rem; }
|
||||
.new-chat-btn { padding: 0.4rem 0.3rem; }
|
||||
.new-chat-label { display: none; }
|
||||
.new-chat-btn svg { display: block; }
|
||||
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
||||
ellipsizes as the designated squeeze target, no bar overflow. */
|
||||
.auth-link { padding: 0.4rem 0.35rem; }
|
||||
.auth-link { padding: 0.4rem 0.3rem; }
|
||||
.auth-label { display: none; }
|
||||
.auth-link svg { display: block; }
|
||||
/* Phase 32: the sync pill goes icon-only like the other pills (the
|
||||
aria-label keeps the accessible name); the spinning icon is the
|
||||
visible running state on a touch screen. */
|
||||
.sync-btn { padding: 0.4rem 0.35rem; }
|
||||
.sync-btn { padding: 0.4rem 0.3rem; }
|
||||
.sync-label { display: none; }
|
||||
/* The last-result counts stay ANNOUNCED (aria-live is untouched) but
|
||||
go visually hidden — the 58px bar has no room for the text; the
|
||||
@@ -1725,7 +1981,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.steering-toggle { padding: 0.4rem 0.35rem; }
|
||||
.steering-toggle { padding: 0.4rem 0.3rem; }
|
||||
/* Visually hidden, NOT display:none — the accessible name keeps the
|
||||
word "Tuning" next to the count badge. */
|
||||
.steering-label {
|
||||
@@ -1767,6 +2023,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.doc-modal-meta { padding-inline: 0.9rem; }
|
||||
.doc-modal-content { padding: 0.75rem 0.9rem 1.25rem; }
|
||||
.composer { padding: 0.5rem; }
|
||||
/* Phase 35: the git sources add form stacks like the other cards —
|
||||
label, full-width mono input, full-width button; the table
|
||||
wrapper's horizontal scroll already covers long URLs. */
|
||||
#git-source-form { flex-direction: column; align-items: stretch; }
|
||||
#git-source-form > label { white-space: normal; }
|
||||
#git-source-url { min-width: 0; }
|
||||
#git-source-add { width: 100%; }
|
||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).">
|
||||
<title>Git sources · Brain of Reese</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<!-- Phase 35: the SAME full header block every other page ships
|
||||
(phase 34, owner confirmation 2026-08-26) — one shared owner of
|
||||
the controls (assets/header.js via git-sources.js's relative
|
||||
import). The admin-only "Git sources" nav link (#nav-git-sources)
|
||||
joins this nav in phase 35 task 05, so it is NOT in this file
|
||||
yet — the page lands without it, exactly like the other pages
|
||||
land without the links task 05 adds to them. -->
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<span class="brand">
|
||||
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<nav class="app-nav" aria-label="Primary">
|
||||
<a href="/" class="nav-link">Chat</a>
|
||||
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Sources link is admin-only (owner
|
||||
permission 2026-08-23) — hidden by default, header.js
|
||||
reveals it once whoami says admin. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above; this page IS the current one, so the
|
||||
link carries is-active + aria-current like Tuning on
|
||||
tuning.html. -->
|
||||
<a href="/git-sources.html" class="nav-link is-active" aria-current="page" id="nav-git-sources" hidden>Git sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
reveals it once whoami says admin. -->
|
||||
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||
</nav>
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): open the tuning-notes panel (stored in
|
||||
Postgres, read into every system prompt). The behavior is
|
||||
owned by the shared header module (assets/header.js); the
|
||||
#steering-panel section ships in every page's <main>. -->
|
||||
<button type="button" class="steering-toggle" id="steering-toggle"
|
||||
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
|
||||
out is visible; /api/whoami decides at load (the shared
|
||||
header module). Icon-only below 640px (aria-labels keep the
|
||||
accessible names). -->
|
||||
<a href="/login.html?next=/git-sources.html" class="auth-link" id="sign-in-link" 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="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||
<span class="auth-label">Sign in</span>
|
||||
</a>
|
||||
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the tuning-notes panel — first child of <main>
|
||||
on the non-chat pages, rendered + driven by assets/header.js
|
||||
(shared), not the page script. -->
|
||||
<section class="steering-panel" id="steering-panel" role="region"
|
||||
aria-label="Tuning notes" hidden>
|
||||
<div class="steering-panel-head">
|
||||
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||
</div>
|
||||
<ul class="steering-list" id="steering-list"></ul>
|
||||
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||
</section>
|
||||
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||
<div class="container git-sources-shell">
|
||||
<!-- Phase 35: anonymous sign-in gate — the EXACT #sources-gate
|
||||
pattern (phase 16) and the same .sources-gate visual
|
||||
language: the page is the same shape as Sources. Visible
|
||||
for anonymous, hidden for the admin (git-sources.js). The
|
||||
catalog of git sources is what the login locks — chat stays
|
||||
open to everyone (the soft rule). -->
|
||||
<section class="sources-gate" id="git-sources-gate" aria-labelledby="git-sources-gate-title" hidden>
|
||||
<div class="sources-gate-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
|
||||
</div>
|
||||
<h2 id="git-sources-gate-title">Sign in to manage the git sources</h2>
|
||||
<p class="sources-gate-sub">
|
||||
The list of repositories the <strong>Sync sources</strong> button
|
||||
clones and indexes is admin-only. Chat — and any document an
|
||||
answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/git-sources.html">Sign in</a>
|
||||
</section>
|
||||
|
||||
<!-- Phase 35: the manager — SHIPS hidden (anonymous-safe; the
|
||||
gate is what anonymous visitors see). git-sources.js
|
||||
reveals it once the cached whoami says admin, then loads
|
||||
the list. Full-width table on the 72rem frame — the
|
||||
Sources-page pattern, no skinny single-column list. -->
|
||||
<div id="git-sources-content" hidden>
|
||||
<div class="page-head">
|
||||
<h1>Git sources</h1>
|
||||
<p class="page-sub">
|
||||
The repositories the Sync button clones and indexes. Add or
|
||||
remove them here — no <code>.env</code>, no restart.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Load failure (role=alert) with a retry — a GET /api/git-sources
|
||||
non-2xx or network failure must never leave a stuck page.
|
||||
git-sources.js fills #git-sources-load-error-text. -->
|
||||
<div class="git-source-load-error" id="git-sources-load-error" role="alert" hidden>
|
||||
<span id="git-sources-load-error-text"></span>
|
||||
<button type="button" id="git-sources-retry">Try again</button>
|
||||
</div>
|
||||
|
||||
<!-- Env-fallback note (phase locked decision): while the
|
||||
git_sources table is EMPTY the list above comes from
|
||||
BOR_GIT_SOURCES in .env (from_env: true) — the note says
|
||||
so, and that adding or removing here switches management
|
||||
to the database. Hidden by default; git-sources.js shows
|
||||
it off the API's from_env flag. -->
|
||||
<p class="git-source-env-note" id="git-sources-env-note" role="note" hidden>
|
||||
These sources currently come from <code>BOR_GIT_SOURCES</code> in
|
||||
<code>.env</code> — adding or removing one here switches management
|
||||
to the database.
|
||||
</p>
|
||||
|
||||
<!-- Add form: visible label + mono URL input + brand button
|
||||
(dark ink on brand 5.2:1). §7.4 never-stale: the button
|
||||
disables + relabels "Adding…" while the POST is in flight
|
||||
and re-enables on success AND failure (the input is kept
|
||||
on failure, same as the tuning forms). -->
|
||||
<form id="git-source-form">
|
||||
<label for="git-source-url">Add a git source</label>
|
||||
<input
|
||||
id="git-source-url"
|
||||
name="url"
|
||||
type="text"
|
||||
maxlength="500"
|
||||
autocomplete="off"
|
||||
placeholder="https://github.com/you/homelab.git"
|
||||
required
|
||||
>
|
||||
<button type="submit" id="git-source-add">Add source</button>
|
||||
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
|
||||
</form>
|
||||
|
||||
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Git sources" tabindex="0">
|
||||
<table class="git-sources-table" id="git-sources-table">
|
||||
<caption class="visually-hidden">Git repositories the Sync button clones and indexes</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col">Added</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="git-sources-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state — no stored rows AND no env fallback. With
|
||||
from_env, the env note above already explains where the
|
||||
active list comes from. -->
|
||||
<p class="git-sources-empty" id="git-sources-empty" hidden>No git sources stored yet.</p>
|
||||
|
||||
<!-- Scope boundary (phase locked decision): adding/removing a
|
||||
repo does NOT clone or prune — the Sync button performs
|
||||
that. The hint says so. -->
|
||||
<p class="git-source-hint" id="git-sources-hint" role="note">
|
||||
Use the <strong>Sync sources</strong> button in the header (or on
|
||||
the Sources page) to clone the repos and refresh the index —
|
||||
removing a repository prunes its documents from the index on the
|
||||
next sync.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Polite live region: the screen-reader confirmation for list
|
||||
loads, adds, and removals (git-sources.js owns the text). -->
|
||||
<p class="visually-hidden" id="git-sources-announcer" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span>Powered by Reese's self-hosted models</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Phase 35: the page module loads the shared header through its
|
||||
own `import "./header.js"` — a hoisted import evaluated before
|
||||
this body runs (the single-evaluation design: no direct
|
||||
header.js <script> tag; esbuild inlines it into the page
|
||||
bundle in the image build). -->
|
||||
<script type="module" src="/assets/git-sources.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -25,6 +25,11 @@
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. The phase-34 identical-header contract
|
||||
requires it here too: every page's nav carries the same
|
||||
four links (Chat, Sources, Git sources, Tuning). -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
reveals it once whoami says admin. The soft-gated page
|
||||
itself is unchanged. -->
|
||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||
link is admin-only — hidden by default, header.js
|
||||
reveals it once whoami says admin, exactly like the
|
||||
Sources link above. -->
|
||||
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||
2026-08-26): the Global Tuning link is admin-only (owner
|
||||
permission 2026-08-25) — hidden by default, header.js
|
||||
|
||||
Reference in New Issue
Block a user