phase: 168_admin_settings_panel
Build and Push Containers / build-and-push-app (push) Successful in 4m4s
Build and Push Containers / build-and-push-db (push) Successful in 15s

**Phase 168 — Admin Settings panel — final verification: PASS**

1. **Implemented/verified:** `/settings.html` (admin-only) with the 75-field catalogue (`app/core/settings_catalog.py`), DB-over-env overrides in Postgres (`app_settings`, migration 0028 at head, `settings_overlay.py` resolver, `app/api/admin_settings.py` — 4 admin-only routes), live-apply on save with no restart + restart-required badges, Reset/Clear all, write-only secrets, `.env`-never-written, `BOR_SETTINGS_DB=0` kill switch.
2. **Phase E2E: 12/12** — run with `E2E_APP_PORT=8200` (ports 8123/8124 are held by an unrelated "undercooked" project; no process killed, no code change — the documented env override).
3. **Regression set: all 7 suites green in isolation** (theme tab, API tokens, configurable brand, global tuning, admin auth, cache busting, wide desktop column — 38 tests).
4. **Full suite: unit+integration exit 0 (4066 passed), app/ coverage 99%** (gate >90%); **ruff clean, pyright 0 errors / 0 warnings.**
5. **Log contract (rule 10) verified live:** per-turn + per-response byte-budget lines present; settings write lines carry key + action, **never the value**.
6. **Byte-budget audit: all endpoints under the 262,144 B budget**; settings catalogue 23,513 B (gzip 3,326).
7. **Defect fixed (one):** criterion 5 had no integration pin — added `test_bad_row_inserted_directly_never_bricks_boot` to `tests/integration/test_admin_settings_api.py` (malformed row inserted directly → fresh `create_app()` boots, row skipped with logged reason, rest of table applies, `/api/health` 200) — 12/12 green.
8. **Docs pass:** `README.md` and `AGENTS.md` reviewed in full — both consistent with the as-built feature (Settings page table, Settings section, Configuration-reference note, `.env.example` block); **no changes required**.
9. **Cleanup:** audit app killed (by PID); `app_settings` left empty (0 rows); all changes left in the working tree (no commit, per protocol).
10. **Completion criteria:** all 7 checkboxes verified green (criterion 7's commit is the harness's).
11. **Next pending phase:** `170_edit_bubble_sizing`.
This commit is contained in:
2026-10-10 01:05:14 -04:00
parent abe7388dda
commit 1200437bf8
79 changed files with 7148 additions and 378 deletions
+7
View File
@@ -226,6 +226,13 @@ export async function initSharedHeader() {
// no-op. A token user (role "user") never sees it.
const navTheme = document.querySelector("#nav-theme");
if (navTheme) navTheme.hidden = !admin;
// Phase 168 (task 05): the Settings nav link (the shell's eighth view
// — the phase-34 one-bar contract ships it on every page's nav) —
// admin-only, the same ship-hidden / reveal-for-admin contract as
// the Theme link above. Null-safe: a page without the link is a
// no-op. A token user (role "user") never sees it.
const navSettings = document.querySelector("#nav-settings");
if (navSettings) navSettings.hidden = !admin;
// Phase 34: the steering panel (phase 15) is module-owned. The
// navbar #steering-toggle was removed at owner request (2026-08-28)
// — the panel ships hidden and is only kept fresh. Admin: refresh
+5
View File
@@ -82,6 +82,7 @@ const VIEW = {
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
"/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens)
"/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette + branding)
"/settings.html": "settings", // phase 168 task 05: the Settings view (admin settings panel)
};
/* The nav-link href the router stamps active for each view (the
@@ -94,6 +95,7 @@ const VIEW_PATH = {
history: "/history.html",
tokens: "/tokens.html",
theme: "/theme.html", // phase 91 task 04: the Theme view (admin-only)
settings: "/settings.html", // phase 168 task 05: the Settings view (admin-only)
};
/* The lazy view modules — ONLY the non-chat views (chat needs no
@@ -108,6 +110,7 @@ const VIEW_MODULES = {
history: () => import("./history.js"), // phase 76 task 03
tokens: () => import("./tokens.js"), // phase 79 task 06
theme: () => import("./theme.js"), // phase 91 task 04 (wiring) + task 05 (editor)
settings: () => import("./settings.js"), // phase 168 task 05 (the settings panel)
};
/* Per-view document.head values, carried over from the old pages'
@@ -122,6 +125,7 @@ const TITLES = {
history: "Saved chats · Brain of Reese", // old history.html <title>
tokens: "Access tokens · Brain of Reese",
theme: "Theme · Brain of Reese", // phase 91 task 04: no old page — the view is new
settings: "Settings · Brain of Reese", // phase 168 task 05: no old page — the view is new
};
const DESCRIPTIONS = {
chat:
@@ -135,6 +139,7 @@ const DESCRIPTIONS = {
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
tokens: "Generate and revoke the API tokens that let people use the app.",
theme: "Set the palette and branding — the theme is baked into every served page, live on the first paint.",
settings: "Edit any application setting — a saved value wins over the environment, live on save; reset hands control back.",
};
/* The brand-resolved display name (phase 39 — brand.js is the single
+642
View File
@@ -0,0 +1,642 @@
/* Brain of Reese — Settings view module (phase 168, task 05): the
* admin settings panel — "edit anything in .env and save it to the
* database. The database value should override the env var. The
* change should take effect on clicking 'save'" (TODO.md L3).
*
* The phase-76 shell-view-module contract (the theme.js / tokens.js
* shape): the router (assets/router.js) lazy-imports this module on
* FIRST show of #view-settings and calls mount(root) ONCE (mount-once,
* hide-forever — root is the view's <section id="view-settings">,
* every DOM lookup scoped to it). The initSharedHeader() call never
* happens here: in the shell the shared header boots exactly once,
* via the chat module (app.js) at shell boot.
*
* What the panel does:
*
* • gate — fetchIsAdmin() (the SAME cached /api/whoami promise
* header.js exports, zero extra requests): admin hides
* #settings-gate and reveals #settings-content; anonymous /
* token-user keeps the gate (the #nav-settings link is already
* hidden by header.js — the gate is the DIRECT-URL case, the
* #tokens-gate pattern). No /api/admin/settings request is ever
* made outside the admin branch (the router 403s them — the
* phase-79 contract).
* • load — GET /api/admin/settings → render the 75-row catalogue
* in the SERVER's order (GROUPS order — the stable order the
* E2E pins against): one <tr> per setting (the group band rows
* are created per group) + the ship-rebuilt no-match row, all
* through createElement + textContent (this file never builds
* HTML — the XSS-safe-by-construction house rule). A failed
* fetch keeps the last rendered catalogue (never a blanked panel)
* and shows the house error line.
* • rows — the Setting cell (the label in a .cell-clip ellipsis
* — the phase-156 contract with the full text in the control's
* aria-label — + the BOR_* name in <code class="settings-env">
* + the literal-text `restart required` .settings-flag for the
* boot-wired fields (text, never color alone, B5, with the
* title/aria-describedby copy explaining when it applies)),
* the Value cell (the control by the server's `type`: int/float
* → <input type=number> (step="any" for float), bool → a
* true/false <select>, json → a spellcheck=false <textarea>
* (the one list[str] field, K2), str → <input type=text> (a
* <textarea rows=2> for the CSV/path fields whose value exceeds
* 40 chars), secret → <input type=password
* autocomplete=new-password placeholder="•••••• (stored)"> with
* an aria-label naming the env var — an EMPTY secret input
* means "leave unchanged" (never an accidental wipe); every
* control is id=set-<key> with a <label for>), the Source cell
* (the literal-text DB / env / default badge — the color is
* decoration, B5), and the Actions cell (the per-row Reset —
* disabled with a title when there is nothing to reset or the
* field is read-only, LOCKED A3).
* • dirty tracking — module-state Map<key, value>: an input/
* change marks the row (.is-dirty + the "N unsaved changes"
* hint) and enables #settings-save; a row that returns to its
* served value un-marks.
* • Save — the §7.4 never-stale lifecycle (disabled + aria-busy +
* "Saving…", re-enabled on success AND failure — a click can
* never leave the button stuck): for each dirty row in DOM
* order PUT /api/admin/settings/<key> with { value }; STOP AT
* THE FIRST FAILURE (K4) — its server detail lands in
* #settings-error (role=alert), the catalogue re-GETs so the
* badges show the truth, the remaining rows' edits are
* re-applied to the re-rendered controls (they stay in the form
* for correction), and focus goes to the offending input. All
* good → one #settings-status line `Saved N settings.` (then
* role=status) — a saved restart-required row appends "—
* applies on the next restart" (the owner is never told
* "saved" and then sees nothing change) — + a re-GET so every
* badge/value is the settled server state.
* • Reset — per row: DELETE /api/admin/settings/<key> → the
* status line `BOR_X is back to the environment value.` + a
* re-GET.
* • Clear all overrides — the house two-step inline confirm
* (the history-confirm-* classes, focus moves to the confirm
* button, Escape / No cancels) → DELETE /api/admin/settings →
* the status line `Cleared N overrides.` (N from the
* response) + a re-GET.
* • search — #settings-filter: a case-insensitive substring over
* the label + BOR_* name cells, applied on input with NO fetch
* (the phase-101 tokens D4 contract) and RE-APPLIED after every
* load (the query lives in the input — it survives re-shows,
* the phase-77 contract). A group with no visible rows hides
* its group row; zero matches with a non-empty query → one
* no-match row `No settings match "<query>"` (textContent).
* • the kill-switch banner — while overlay_enabled is false
* (BOR_SETTINGS_DB=0, D6) the #settings-disabled alert explains
* that saving stores the value but it will not take effect
* (the mechanism is off, not the panel).
*
* Every value is rendered with textContent — this file never builds
* HTML (the XSS-safe-by-construction house rule; a full-file source
* pin enforces it).
*/
import { fetchIsAdmin } from "./header.js";
export async function mount(root) {
/* ---------- view elements (the view's section, scoped to root) ---------- */
const gateEl = root.querySelector("#settings-gate");
const contentEl = root.querySelector("#settings-content");
const disabledBanner = root.querySelector("#settings-disabled");
const statusEl = root.querySelector("#settings-status");
const errorEl = root.querySelector("#settings-error");
const filter = root.querySelector("#settings-filter");
const tbody = root.querySelector("#settings-tbody");
const saveBtn = root.querySelector("#settings-save");
const clearAllBtn = root.querySelector("#settings-clear-all");
const dirtyHint = root.querySelector("#settings-dirty");
const SAVE_LABEL = "Save settings";
/* Module state (the phase-76 mount-once contract — it survives
every switch): the last settled catalogue (the row meta for the
save's restart check), the pending edits (key → the value being
edited), and the in-flight save flag (the §7.4 guard). */
let lastItems = [];
const dirty = new Map();
let saveInFlight = false;
let clearAllEsc = null; // the armed Escape handler (null = disarmed)
/* The no-match row (rebuilt into the tbody on every render — the
<td> text is JS-filled; it is never a data row). */
const noMatchRow = document.createElement("tr");
noMatchRow.className = "settings-no-match";
noMatchRow.hidden = true;
const noMatchTd = document.createElement("td");
noMatchTd.colSpan = 4;
noMatchRow.appendChild(noMatchTd);
/* ---------- feedback lines (never stale, §7.4) ---------- */
function announce(message) {
if (statusEl) statusEl.textContent = message;
}
function showError(message) {
if (!errorEl) return;
errorEl.textContent = message; // textContent — the server detail is data
errorEl.hidden = false;
}
function clearError() {
if (!errorEl) return;
errorEl.textContent = "";
errorEl.hidden = true;
}
/* FastAPI error bodies: a string detail (the house 422s — the
detail names the offending field) or the validation-error array
(the first entry's msg). Same extraction as theme.js. */
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;
}
/* ---------- catalogue render (createElement + textContent only) ---------- */
const dataRows = () =>
tbody ? [...tbody.querySelectorAll("tr.settings-row")] : [];
function makeControl(item) {
let control;
if (item.secret) {
/* LOCKED A4: the secret is write-only — the input starts
EMPTY (the plaintext never leaves the server); an empty
value means "leave unchanged" (never an accidental wipe). */
control = document.createElement("input");
control.type = "password";
control.setAttribute("autocomplete", "new-password");
control.placeholder = "•••••• (stored)";
} else if (item.type === "int") {
control = document.createElement("input");
control.type = "number";
control.value = item.value;
} else if (item.type === "float") {
control = document.createElement("input");
control.type = "number";
control.step = "any";
control.value = item.value;
} else if (item.type === "bool") {
control = document.createElement("select");
for (const opt of ["true", "false"]) {
const option = document.createElement("option");
option.value = opt;
option.textContent = opt;
control.appendChild(option);
}
control.value = item.value;
} else if (item.type === "json") {
/* The one list[str] field (K2) — edited as a JSON array in a
textarea (the server's env-native string form, verbatim). */
control = document.createElement("textarea");
control.spellcheck = false;
control.rows = 2;
control.value = item.value;
} else if (item.type === "str" && (item.value || "").length > 40) {
/* A CSV / path field with a long value — the two-row
textarea (no single-line clip for 200-char lists). */
control = document.createElement("textarea");
control.rows = 2;
control.value = item.value;
} else {
control = document.createElement("input");
control.type = "text";
control.setAttribute("autocomplete", "off");
control.value = item.value;
}
control.id = "set-" + item.key;
control.classList.add("settings-control");
/* The full (unclipped) label stays reachable: the .cell-clip
ellipsis is visual only (the phase-156 contract), and the
secret's aria-label names the env var (the house pattern for
table-hosted inputs — the Setting cell's label is the
visible <label for>). */
control.setAttribute(
"aria-label",
item.secret ? item.env_var : `${item.label} — ${item.env_var}`,
);
return control;
}
function makeRow(item) {
const tr = document.createElement("tr");
tr.className = "settings-row";
tr.dataset.key = item.key;
/* The Setting cell: the label (the .cell-clip ellipsis — the
full text is the label's own textContent + the control's
aria-label), the BOR_* name (presentation only, D1), and —
when boot-wired (LOCKED A4) — the literal-text flag with
the when-it-applies copy (title + the shared
aria-describedby target). */
const nameTd = document.createElement("td");
nameTd.className = "settings-name-cell";
const label = document.createElement("label");
label.htmlFor = "set-" + item.key;
const clip = document.createElement("span");
clip.className = "cell-clip";
clip.textContent = item.label;
label.appendChild(clip);
const env = document.createElement("code");
env.className = "settings-env";
env.textContent = item.env_var;
nameTd.append(label, env);
if (item.restart_required) {
const flag = document.createElement("span");
flag.className = "settings-flag";
flag.textContent = "restart required"; // text, never color alone (B5)
flag.title =
"Applies on the next restart — the app reads this setting once at boot";
flag.setAttribute("aria-describedby", "settings-restart-desc");
nameTd.appendChild(flag);
}
tr.appendChild(nameTd);
/* The Value cell: the control by type (see makeControl). */
const valueTd = document.createElement("td");
valueTd.className = "settings-value-cell";
const control = makeControl(item);
valueTd.appendChild(control);
tr.appendChild(valueTd);
/* The Source cell: the literal-text badge (db → "DB", env,
default — the color is decoration, B5). */
const srcTd = document.createElement("td");
const src = document.createElement("span");
src.className =
"settings-src " +
({ db: "is-db", env: "is-env", default: "is-default" })[item.source];
src.textContent = item.source === "db" ? "DB" : item.source;
srcTd.appendChild(src);
tr.appendChild(srcTd);
/* The Actions cell: the per-row Reset — disabled with a title
when there is NOTHING to reset (no saved override) or the
field is READ-ONLY (LOCKED A3: the engine is built from it /
the dim is fixed at table creation). */
const actTd = document.createElement("td");
actTd.className = "settings-actions-cell";
const reset = document.createElement("button");
reset.type = "button";
reset.className = "settings-reset";
reset.textContent = "Reset";
reset.setAttribute("aria-label", `Reset ${item.env_var}`);
if (!item.overridable) {
reset.disabled = true;
reset.title = `read-only — ${item.env_var} is not settable from the database`;
} else if (!item.has_override) {
reset.disabled = true;
reset.title = "no saved override — the environment value is in effect";
}
reset.addEventListener("click", () => void resetRow(item.key, item.env_var));
actTd.appendChild(reset);
tr.appendChild(actTd);
/* Dirty tracking (input + change — the select/change pair). */
control.addEventListener("input", () => markDirty(item, control));
control.addEventListener("change", () => markDirty(item, control));
return tr;
}
/* GET /api/admin/settings → render the groups in the server's
order (re-entrant: the tbody is REPLACED — never a duplicate
set — and the no-match row is re-appended). A FAILED load
returns before the render (the last catalogue stays) and shows
the house error line. */
function render(items) {
if (!tbody) return;
const rows = [];
let currentGroup = null;
for (const item of items) {
if (item.group !== currentGroup) {
currentGroup = item.group;
const groupRow = document.createElement("tr");
groupRow.className = "settings-group-row";
const th = document.createElement("th");
th.colSpan = 4;
th.scope = "colgroup";
th.textContent = item.group;
groupRow.appendChild(th);
rows.push(groupRow);
}
rows.push(makeRow(item));
}
rows.push(noMatchRow);
tbody.replaceChildren(...rows);
}
async function loadSettings() {
let r;
try {
r = await fetch("/api/admin/settings");
} catch {
showError("Couldn't load the settings — is the app reachable?");
return false;
}
if (!r.ok) {
showError("Couldn't load the settings — try again.");
return false;
}
let data;
try {
data = await r.json();
} catch {
showError("Couldn't load the settings — try again.");
return false;
}
clearError();
lastItems = data.items;
/* The kill-switch banner (D6): the mechanism is off, not the
panel — a save stores the value but it will not take effect. */
if (disabledBanner) disabledBanner.hidden = data.overlay_enabled;
render(data.items);
/* A failed batch keeps the remaining edits in the form (K4):
re-apply every pending value to the re-rendered control and
re-mark the row. */
for (const [key, value] of dirty) {
const control = root.querySelector("#set-" + key);
if (!control) continue;
control.value = value;
const row = control.closest("tr");
if (row) row.classList.add("is-dirty");
}
applyFilter();
updateDirtyUI();
return true;
}
/* ---------- dirty tracking (module state) ---------- */
function markDirty(item, control) {
/* The served value: the effective value in env-native string
form — for a MASKED field it is "" (an empty input means
"leave unchanged", never an accidental wipe). */
const served = item.secret ? "" : item.value ?? "";
const value = control.value;
if (value === served) {
dirty.delete(item.key);
} else {
dirty.set(item.key, value);
}
updateDirtyUI();
}
function updateDirtyUI() {
for (const row of dataRows()) {
row.classList.toggle("is-dirty", dirty.has(row.dataset.key));
}
if (dirtyHint) {
dirtyHint.textContent = dirty.size
? `${dirty.size} unsaved changes`
: "";
}
if (saveBtn && !saveInFlight) saveBtn.disabled = dirty.size === 0;
}
/* ---------- the client-side search (phase-101 D4 contract) ----------
* Case-insensitive substring over the label + BOR_* name cells,
* applied on `input` with NO fetch and re-applied after every
* load (the query lives in the input — it survives re-shows).
* A group with no visible rows hides its group row; zero matches
* with a non-empty query → the one no-match row (textContent). */
function applyFilter() {
const query = (filter ? filter.value : "").trim().toLowerCase();
const children = tbody ? [...tbody.children] : [];
let visible = 0;
let currentGroupRow = null;
const groupVisible = new Map();
for (const tr of children) {
if (tr === noMatchRow) continue; // the state row is never a data row
if (tr.classList.contains("settings-group-row")) {
currentGroupRow = tr;
groupVisible.set(tr, false);
continue;
}
const nameCell = tr.querySelector(".settings-name-cell");
const labelEl = nameCell ? nameCell.querySelector("label") : null;
const envEl = nameCell
? nameCell.querySelector(".settings-env")
: null;
const haystack =
((labelEl ? labelEl.textContent : "") +
" " +
(envEl ? envEl.textContent : "")).toLowerCase();
const show = query === "" || haystack.includes(query);
tr.hidden = !show;
if (show) visible += 1;
if (currentGroupRow) groupVisible.set(currentGroupRow, true);
}
for (const [groupRow, hasVisible] of groupVisible) {
groupRow.hidden = !hasVisible;
}
const noMatch = query !== "" && visible === 0;
noMatchRow.hidden = !noMatch;
if (noMatch) {
noMatchTd.textContent = `No settings match "${
filter ? filter.value.trim() : ""
}".`;
}
}
/* ---------- Save (dirty rows only — stop at the first failure,
K4; the §7.4 never-stale lifecycle) ---------- */
async function saveSettings() {
if (!dirty.size || saveInFlight) return;
saveInFlight = true;
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.setAttribute("aria-busy", "true");
saveBtn.textContent = "Saving…";
}
clearError();
const saved = [];
let failure = null;
for (const row of dataRows()) {
const key = row.dataset.key;
if (!key || !dirty.has(key)) continue;
const value = dirty.get(key);
try {
const r = await fetch(`/api/admin/settings/${key}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value }),
});
if (!r.ok) {
failure = { key, r };
break; // stop at the first failure (K4)
}
saved.push(key);
dirty.delete(key);
} catch {
failure = { key, r: null };
break; // the app is unreachable — stop
}
}
if (failure) {
/* The 422 detail (it names the field) or the house network
line; the catalogue re-GETs first (the badges show the
truth, the remaining edits are re-applied — K4), and the
error is shown AFTER the re-GET (the re-GET's clearError
must not wipe it — the previous success line is cleared
with it, so the stale "Saved N settings." never lingers,
the §7.4 never-stale contract). Focus goes to the
offending input. */
await loadSettings();
if (statusEl) statusEl.textContent = "";
showError(
failure.r
? await apiDetail(failure.r, "Couldn't save the settings — try again.")
: "Couldn't save the settings — is the app reachable?",
);
const input = root.querySelector("#set-" + failure.key);
if (input) input.focus();
} else {
/* One status line (role=status) — a saved restart-required
row says so (the owner is never told "saved" and then sees
nothing change). */
const anyRestart = saved.some(
(key) => (lastItems.find((i) => i.key === key) || {}).restart_required,
);
announce(
`Saved ${saved.length} settings.` +
(anyRestart ? " — applies on the next restart" : ""),
);
await loadSettings(); // every badge/value is the settled state
}
saveInFlight = false;
if (saveBtn) {
saveBtn.removeAttribute("aria-busy");
saveBtn.textContent = SAVE_LABEL;
saveBtn.disabled = dirty.size === 0;
}
}
/* ---------- the per-row Reset (DELETE the row → env wins) ---------- */
async function resetRow(key, envVar) {
let r;
try {
r = await fetch(`/api/admin/settings/${key}`, { method: "DELETE" });
} catch {
announce(`Couldn't reset ${envVar} — is the app reachable?`);
return;
}
if (!r.ok) {
announce(`Couldn't reset ${envVar} — try again.`);
return;
}
announce(`${envVar} is back to the environment value.`);
await loadSettings(); // the badge falls back to env/default
}
/* ---------- Clear all overrides (the house two-step inline
confirm — the history-confirm-* classes; focus to Yes; Escape
/ No cancels) ---------- */
function restoreClearAll() {
if (clearAllEsc) {
document.removeEventListener("keydown", clearAllEsc, true);
clearAllEsc = null;
}
const wrap = root.querySelector(".settings-confirm");
if (wrap) wrap.replaceWith(clearAllBtn);
if (clearAllBtn) clearAllBtn.focus();
}
async function confirmClearAll(yesBtn, restore) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let cleared = null;
try {
const r = await fetch("/api/admin/settings", { method: "DELETE" });
if (!r.ok) {
announce("Couldn't clear the overrides — try again.");
restore();
return;
}
const data = await r.json();
cleared = data.cleared;
} catch {
announce("Couldn't clear the overrides — is the app reachable?");
restore();
return;
}
restore();
announce(`Cleared ${cleared} overrides.`);
await loadSettings(); // every badge falls back to env/default
}
function showClearAllConfirm() {
if (!clearAllBtn) return;
const wrap = document.createElement("span");
wrap.className = "settings-confirm";
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Clear all overrides?";
const yes = document.createElement("button");
yes.type = "button";
yes.className = "history-confirm-yes";
yes.textContent = "Yes";
const no = document.createElement("button");
no.type = "button";
no.className = "history-confirm-no";
no.textContent = "No";
yes.addEventListener("click", () => void confirmClearAll(yes, restoreClearAll));
no.addEventListener("click", restoreClearAll);
wrap.append(label, yes, no);
clearAllBtn.replaceWith(wrap);
yes.focus(); // the confirm pair takes over the focus
/* Escape cancels (the task contract — the capture phase so the
page's own Esc handlers never race it). */
clearAllEsc = (e) => {
if (e.key === "Escape") {
e.stopPropagation();
restoreClearAll();
}
};
document.addEventListener("keydown", clearAllEsc, true);
}
/* ---------- view boot (phase 168 task 05) ----------
* The shared header is NOT booted here — in the shell it runs
* exactly once, via the chat module (app.js) at shell boot. The
* gate reads fetchIsAdmin() — the SAME cached whoami promise the
* header uses (zero extra requests). Anonymous / token-user: the
* gate in, the content out — and NO /api/admin/settings request
* at all (the router 403s them — the phase-79 contract). */
if (!(await fetchIsAdmin())) {
if (gateEl) gateEl.hidden = false;
if (contentEl) contentEl.hidden = true;
return;
}
if (gateEl) gateEl.hidden = true;
if (contentEl) contentEl.hidden = false;
/* Bindings — armed in the ADMIN branch only (like the refresh
listener): Save (the §7.4 lifecycle), Clear all (the two-step
confirm), the client-side search (NO fetch). */
if (saveBtn) saveBtn.addEventListener("click", () => void saveSettings());
if (clearAllBtn) clearAllBtn.addEventListener("click", showClearAllConfirm);
if (filter) filter.addEventListener("input", () => applyFilter());
/* Phase 77 (the re-show refresh contract): a user-initiated
re-show of this already-mounted view makes the router dispatch
bor:view-refresh on the section — re-run the load then (the
tab always shows the settled server state when re-shown; the
search query survives — it lives in the input). Armed ONLY
here, after the whoami gate passed. `started` flips true once
the first loadSettings() is made, so the listener can only
ever re-run a load the mount already did. */
let started = false;
root.addEventListener("bor:view-refresh", () => {
if (started) void loadSettings();
});
started = true;
void loadSettings(); // the first load (the catalogue)
}
+238
View File
@@ -4646,6 +4646,232 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
padding: 0.45rem 0.8rem;
}
/* ---------- Settings view (phase 168) ----------
The admin settings panel: ONE .table-wrap card (the phase-156
bounded scroll region — max-height: min(70vh, 64rem) — and the
sticky thead via the .table-wrap rule) holding the 75-row
catalogue (grouped, type-correct controls, literal-text source
badges — B5: text + color, never color alone), the client-side
search row, and the Save / Clear-all action row. Every color is
an EXISTING AA token (B3: no new palette variable, no new :root
token — the phase-156 rule): the badges are TEXT and the color
is decoration. Nothing animates, so prefers-reduced-motion needs
no new rule. */
.settings-shell {
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* The catalogue: FULL-WIDTH (AGENTS.md rule 5 — the .git-sources-
table language: no skinny list), one cell per column, the
.table-wrap card + its bounded scroll + the sticky thead carry
the phase-156 contract. */
.settings-table {
width: 100%;
border-collapse: collapse;
min-width: 560px;
font-size: 0.93rem;
}
.settings-table th, .settings-table td {
text-align: left;
vertical-align: top;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--line);
}
.settings-table td { white-space: nowrap; }
/* The group band: the --surface row, ink text (15.3:1, AA). */
.settings-group-row th {
background: var(--surface);
color: var(--ink);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.settings-table tbody tr.settings-row:hover td { background: var(--bg); }
/* The Setting cell: the label (bold) over the BOR_* name (mono,
ink-soft 8.2:1 on --surface, AA). */
.settings-name-cell label { margin: 0; font-weight: 600; min-width: 0; }
.settings-env {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--ink-soft);
}
/* The restart-required flag (LOCKED A4, boot-wired): the accent
STATE family (--accent-ink on --accent-bg 9.5:1, AA) — text +
color, never color alone (B5); the title / aria-describedby
carry the when-it-applies copy. */
.settings-flag {
display: inline-block;
margin-top: 0.25rem;
padding: 0.06rem 0.5rem;
border: 1px solid var(--accent-line);
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
background: var(--accent-bg);
color: var(--accent-ink);
}
/* The Value cell: the type-correct control (number / select /
textarea / text / password) — the .token-label input language
(--line hairline, --surface fill, ink text 15.3:1, AA; the
values are data — mono) + the ≥44px target + the global
:focus-visible ring. */
.settings-control {
width: 100%;
min-width: 0;
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font-family: var(--mono);
font-size: 0.85rem;
}
.settings-control:disabled { opacity: 0.6; }
/* The Source cell: the literal-text badge — the three is-*
variants are SOLID existing tokens (B3): DB is brand-ink on
brand-soft (9.0:1, AA), env is ink-soft on surface (8.2:1, AA;
the --line border keeps it visible on the card), default is
ink-soft on line (7.1:1, AA). The badge is TEXT — the color
is decoration (B5). */
.settings-src {
display: inline-block;
padding: 0.12rem 0.55rem;
border-radius: 999px;
font-family: var(--mono);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.settings-src.is-db { background: var(--brand-soft); color: var(--brand-ink); }
.settings-src.is-env { background: var(--surface); color: var(--ink-soft); border: 1px solid var(--line); }
.settings-src.is-default { background: var(--line); color: var(--ink-soft); }
/* A dirty row: the --brand left marker on the row's first cell
(an inset box-shadow — no layout shift) so "unsaved" survives
grayscale + the N-unsaved-changes hint (text, B5). */
.settings-row.is-dirty > td:first-child { box-shadow: inset 3px 0 0 var(--brand); }
/* The per-row Reset: the .token-revoke language (≥44px, --line
hairline, transparent fill, ink-soft, the err hover pair — it
hands control back to the environment) + the dimmed :disabled
(nothing to reset / read-only). */
.settings-reset {
min-height: 44px;
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;
}
.settings-reset:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.settings-reset:disabled { opacity: 0.5; cursor: default; }
/* The search row: the .token-create flex (wraps below 640px via
the media query below) + the #token-label input language. */
.settings-search {
display: flex;
align-items: center;
gap: 0.6rem;
}
.settings-search label { white-space: nowrap; }
#settings-filter {
flex: 1;
min-width: 0;
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: 0.93rem;
}
#settings-filter:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* The action row: Save (the .token-generate brand pill — the
§7.4 in-flight state is the dimmed :disabled + aria-busy) and
Clear all (the .token-revoke ghost — the destructive err hover
pair; the two-step confirm reuses the .history-confirm-* rules
unchanged). */
.settings-actions {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
#settings-save {
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: 999px;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
#settings-save:hover:not(:disabled) { background: var(--brand-hover); color: var(--bg); }
#settings-save:disabled { opacity: 0.6; cursor: wait; }
#settings-clear-all {
min-height: 44px;
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;
}
#settings-clear-all:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
/* The "N unsaved changes" hint (textContent only, aria-hidden):
the .tokens-status voice (mono, ink-soft). */
.settings-dirty {
font-family: var(--mono);
font-size: 0.8rem;
color: var(--brand-ink);
}
/* The ONE status region (the .tokens-status mirror — the
min-height holds the layout so a line never reflows the table).
The error line (the .theme-error mirror). */
.settings-status {
display: block;
min-height: 1.2em;
color: var(--ink-soft);
font-family: var(--mono);
font-size: 0.8rem;
padding-block: 0.25rem;
}
.settings-error {
display: block;
margin: 0;
min-height: 1.2em;
font-family: var(--mono);
font-size: 0.8rem;
padding-block: 0.25rem;
color: var(--err-ink);
}
/* The no-match row (the .tokens-empty-row language). */
.settings-no-match td {
padding: 0.9rem 1rem;
color: var(--ink-soft);
font-family: var(--mono);
font-size: 0.85rem;
white-space: normal;
}
/* ---------- Shared page (phase 51, task 03) ----------
/shared/<token>: the anonymous read-only conversation (owner-locked
2026-08-29, TODO.md L6). The shared page reads exactly like the
@@ -6160,6 +6386,18 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
.theme-actions { flex-direction: column; align-items: stretch; }
#theme-save { width: 100%; }
.theme-reset { width: 100%; }
/* Phase 168 (task 05): the Settings panel squeezes — the search
row stacks (the label over the full-width input) and the action
row stacks (Save full width, Clear all under it; the ≥44px
targets are the global rules above — the stack keeps them on
phones). The actions cell wraps so the Reset control fits the
phone width (the table's own horizontal scroll covers the
columns). */
.settings-search { flex-direction: column; align-items: stretch; }
.settings-actions { flex-direction: column; align-items: stretch; }
#settings-save { width: 100%; }
#settings-clear-all { width: 100%; }
.settings-actions-cell { white-space: normal; }
/* Phase 51: the shared page squeezes like the chat column — the
title and the note step down (the empty-state-title family); the
shell is full-width here (the 72rem cap never binds on a phone —
+7
View File
@@ -72,6 +72,13 @@
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 168 (task 05): the Settings link is admin-only —
hidden by default, header.js reveals it once whoami says
admin, exactly like the Theme link above (the phase-34
one-bar contract: the SAME nav ships on every page —
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/settings.html" class="nav-link" id="nav-settings" hidden>Settings</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
+134
View File
@@ -60,6 +60,13 @@
view). No mobile dropdown copy is needed: the link lives
in the SAME #app-nav element the hamburger opens. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 168 (task 05): the Settings link is admin-only — hidden
by default, header.js reveals it once whoami says admin,
exactly like the Theme link above (the phase-34 one-bar
contract; the shell's eighth view). No mobile dropdown copy
is needed: the link lives in the SAME #app-nav element the
hamburger opens. A token user never sees it. -->
<a href="/settings.html" class="nav-link" id="nav-settings" hidden>Settings</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
@@ -1478,6 +1485,133 @@
</div>
</section>
<!-- Phase 168 (task 05): the Settings view — the shell's eighth
view (the #view-tokens skeleton, verbatim): the 72rem
.container, the anonymous sign-in gate (the #tokens-gate
shape), and the ship-hidden #settings-content (settings.js
reveals it for admin only). The catalogue's <tbody> elements
are created by settings.js (one per group, each opening with
a <tr class="settings-group-row">); the .table-wrap +
sticky-thead rules in styles.css carry the bounded-scroll +
sticky-header contract (the phase-156 catalogue language).
No <script> tag: the router lazy-imports settings.js
(rule 6 — everything local). -->
<section class="view" id="view-settings" hidden inert aria-label="Settings" tabindex="-1">
<div class="container settings-shell">
<div class="page-head">
<h1>Settings</h1>
<p class="page-sub">
Values saved here are stored in the database and win over
the server's environment variables; clearing one hands
control back to the environment. <code>.env</code> is never
written — the environment stays the base, the database row
wins.
</p>
</div>
<!-- Phase 168 (task 05): anonymous sign-in gate — the EXACT
#tokens-gate shape (phase 79) and the same .sources-gate
visual language: every setting is what the login locks.
Visible for anonymous, hidden for the admin (settings.js).
The gate's Sign in returns to the Settings view (the
header's ?next= convention; the static href is the no-JS
fallback). -->
<section class="sources-gate" id="settings-gate" aria-labelledby="settings-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="settings-gate-title">Sign in to change settings</h2>
<p class="sources-gate-sub">
Editing application settings is admin-only. Chat — and any
document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/settings.html">Sign in</a>
</section>
<!-- Phase 168 (task 05): the panel — SHIPS hidden
(anonymous-safe; the gate is what anonymous sees).
settings.js reveals it once the cached whoami says admin
(the #theme-content pattern) and renders the catalogue's
groups/rows from GET /api/admin/settings. -->
<div id="settings-content" hidden>
<!-- Shown when the server's overlay_enabled is false
(the BOR_SETTINGS_DB=0 kill switch — D6): the panel
stays editable, but a save cannot take effect. -->
<div class="kb-banner is-error" id="settings-disabled" role="alert" hidden>
DB overrides are switched off on the server
(<code>BOR_SETTINGS_DB=0</code>) — saving stores the
value but it will not take effect.
</div>
<!-- The ONE status region (the #tokens-status precedent):
settings.js sets textContent here — a save's outcome,
a reset's outcome, a clear-all's outcome, nothing else
(the error line has its own role=alert below). -->
<span class="settings-status" id="settings-status" role="status" aria-live="polite"></span>
<!-- The error line (the #theme-error precedent): the
server's 422 detail (it names the offending field)
and the house load-failure lines; the rows keep their
edits on failure. -->
<p class="settings-error" id="settings-error" role="alert" hidden></p>
<!-- The client-side filter (the phase-101 tokens D4
contract): a case-insensitive substring over the label
+ BOR_* name cells, applied on input with NO fetch,
re-applied after every re-load (the query survives
re-shows — the phase-77 contract). -->
<div class="settings-search">
<label for="settings-filter">Search settings</label>
<input
id="settings-filter"
type="search"
autocomplete="off"
>
</div>
<!-- The scroll card: the .table-wrap + sticky-th rules in
styles.css are already the bounded-scroll + sticky-
header contract (the phase-156 catalogue language).
The <tbody> elements are created by settings.js (one
per group, each opening with a settings-group-row). -->
<div class="table-wrap">
<table class="settings-table">
<caption class="visually-hidden">Application settings — the value in effect and where it comes from</caption>
<thead>
<tr>
<th scope="col">Setting</th>
<th scope="col">Value</th>
<th scope="col">Source</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="settings-tbody"></tbody>
</table>
</div>
<!-- The action row: Save (dirty rows only — ships disabled
until a row is edited; settings.js runs the §7.4
never-stale lifecycle), Clear all overrides (the house
two-step inline confirm, settings.js), and the
"N unsaved changes" hint (textContent only, aria-hidden
— the rows' .is-dirty state + the status line carry
the information). -->
<div class="settings-actions">
<button type="button" class="btn primary" id="settings-save" disabled>Save settings</button>
<button type="button" class="btn ghost" id="settings-clear-all">Clear all overrides</button>
<span class="settings-dirty" id="settings-dirty" aria-hidden="true"></span>
</div>
<!-- The shared description target for every
restart-required row's flag (aria-describedby). -->
<p class="visually-hidden" id="settings-restart-desc">
Applies on the next restart — the app reads this setting
once at boot, so the saved value takes effect when the
app next starts.
</p>
</div>
</div>
</section>
</main>
<!-- Phase 79 (task 05): the in-app token gate — a body-level
+7
View File
@@ -65,6 +65,13 @@
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 168 (task 05): the Settings link is admin-only —
hidden by default, header.js reveals it once whoami says
admin, exactly like the Theme link above (the phase-34
one-bar contract: the SAME nav ships on every page —
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/settings.html" class="nav-link" id="nav-settings" hidden>Settings</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
+7
View File
@@ -66,6 +66,13 @@
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
<!-- Phase 168 (task 05): the Settings link is admin-only —
hidden by default, header.js reveals it once whoami says
admin, exactly like the Theme link above (the phase-34
one-bar contract: the SAME nav ships on every page —
test_nav_consistency pins the inventory parity).
Null-safe: header.js is a no-op on a page without it. -->
<a href="/settings.html" class="nav-link" id="nav-settings" hidden>Settings</a>
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
outside the nav; see styles.css .sign-in-mobile rules). -->
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>