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
+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