feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s

Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
This commit is contained in:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
+217 -5
View File
@@ -48,6 +48,12 @@
reveals it once whoami says admin, exactly like the
Tuning link above. -->
<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>
<!-- Phase 79 (task 06): the Tokens link is admin-only — hidden
by default, header.js reveals it once whoami says admin,
exactly like the History link above (the shell's sixth
view). No mobile dropdown copy is needed: the link lives
in the SAME #app-nav element the hamburger opens. -->
<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</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>
@@ -668,11 +674,33 @@
only (mount-once, hide-forever). -->
<section class="view" id="view-history" hidden inert aria-label="History" tabindex="-1">
<div class="container history-shell">
<!-- Phase 77 (task 03, TODO.md L3): the page-head becomes a
flex row — the title block left, the actions slot right
(wraps below 640px). The other four views' page-heads are
untouched (the flex layout is scoped to #view-history in
styles.css). -->
<div class="page-head">
<h1>Saved chats</h1>
<p class="page-sub">
Every conversation is saved automatically — newest activity first. Click a title to return to that chat.
</p>
<div class="page-head-title">
<h1>Saved chats</h1>
<p class="page-sub">
Every conversation is saved automatically — newest activity first. Click a title to return to that chat.
</p>
</div>
<!-- The explicit Refresh control (TODO.md L3: "The history page
should also have a refresh button."). It sits in the
page-head — OUTSIDE the table wrap — so it stays
reachable while the empty state is showing. The house
inline-SVG refresh glyph (aria-hidden) + the visible
"Refresh" label (the phase-46 auth-link convention:
label visible >=640px, icon-only below; the aria-label
keeps the accessible name in both). history.js binds it
in the admin branch (the view is admin-gated; anonymous
sees the gate instead) and announces the outcome in
#history-status. -->
<button type="button" class="history-refresh" id="history-refresh" aria-label="Refresh saved chats">
<svg class="history-refresh-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="history-refresh-label">Refresh</span>
</button>
</div>
<!-- Phase 50 (owner permission 2026-08-29): anonymous sign-in
gate — the EXACT #sources-gate pattern (phase 16) and the
@@ -734,8 +762,181 @@
</div>
</section>
<!-- Phase 79 (task 06): the Tokens view — the sixth folded navbar
view (the phase-76 fold pattern): admin-issued access tokens
(generate · list · revoke). /tokens.html now serves THIS
document (the shell route in app/main.py); the router shows
this section for that pathname. The plaintext is shown
EXACTLY ONCE (owner-locked A4): the #token-once block reveals
it in the mono read-only field after a create, and it hides
itself on the next loadTokens() / re-show — the plaintext is
never stored client-side (no localStorage, no data attribute),
so a re-render can never re-show it. The create row + table
SHIP hidden (anonymous-safe — the gate is what anonymous sees;
tokens.js reveals them for admin only, and an anonymous
visitor never fetches /api/tokens). The hidden + inert pair is
the WCAG contract: a hidden view must not receive focus or
keyboard traversal (AGENTS.md rule 5). Mounted lazily —
assets/router.js imports tokens.js on first show only
(mount-once, hide-forever). -->
<section class="view" id="view-tokens" hidden inert aria-label="Tokens" tabindex="-1">
<div class="container tokens-shell">
<div class="page-head">
<h1>Access tokens</h1>
<p class="page-sub">
Generate a token and hand it out — it opens chat, the answers,
and the documents they cite. Shared chats stay open.
</p>
</div>
<!-- Phase 79 (task 06): anonymous sign-in gate — the EXACT
#history-gate pattern (phase 50) and the same .sources-gate
visual language: the token list is what the login locks.
Visible for anonymous, hidden for the admin (tokens.js).
The gate's Sign in returns to the Tokens view (the
header's ?next= convention; the static href is the no-JS
fallback). -->
<section class="sources-gate" id="tokens-gate" aria-labelledby="tokens-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="tokens-gate-title">Sign in to manage access tokens</h2>
<p class="sources-gate-sub">
Issuing and revoking access tokens is admin-only. Chat — and
any document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/tokens.html">Sign in</a>
</section>
<!-- Live-region feedback for every action (the "never stale"
contract): tokens.js sets textContent here — a create's
outcome, a copy's outcome, a revoke's outcome, its error
lines, nothing else. -->
<span class="tokens-status" id="tokens-status" role="status" aria-live="polite"></span>
<!-- The create row: label (1–120 — the server re-validates; a
BLANK label sends "token", the placeholder documents the
fallback) + Generate. SHIPS hidden (anonymous-safe — no
dead control beside the gate; tokens.js reveals it for
admin only). The button is type="button" (no form submit)
and runs the §7.4 never-stale lifecycle ("Generating…"
while the POST is in flight, re-enabled on success AND
failure). -->
<div class="token-create" id="token-create" hidden>
<input
id="token-label"
type="text"
maxlength="120"
placeholder="e.g. alice"
aria-label="Token label"
autocomplete="off"
>
<button type="button" class="token-generate" id="token-generate">Generate</button>
</div>
<!-- The shown-once block (owner-locked A4): after a 201, the
plaintext appears EXACTLY ONCE — in the mono read-only
field, with a Copy (the clipboard; a non-secure http
origin that rejects it gets the inline fallback — the
field selects itself for Ctrl/Cmd+C). tokens.js hides it
on the next loadTokens() / re-show and wipes the field —
it is NEVER re-shown (the plaintext is gone). -->
<div class="token-once" id="token-once" hidden>
<p class="token-once-copy" role="note">
Copy this token now — it is shown once and can never be
retrieved again.
</p>
<div class="token-once-row">
<input
id="token-once-value"
type="text"
readonly
aria-label="New token (shown once)"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
>
<button type="button" id="token-once-copy" aria-label="Copy token">Copy</button>
</div>
</div>
<!-- The full-width table (AGENTS.md rule 5 — no skinny list):
Label | Created (locale date+time, full ISO on hover) |
Last used (locale or "never") | Status (Active em-dash vs
the rose Revoked pill — the stale-pill language) | Actions
(Revoke — the inline two-step confirm; Revoked rows carry
NO action — nothing left to revoke). tokens.js fills
#tokens-tbody; #tokens-empty-row ships hidden and is
revealed by a 0-row fetch (and a failed load). The Actions
column header is visually-hidden — the row buttons carry
their own aria-labels (the history-table convention). -->
<div class="table-wrap tokens-table-wrap" id="tokens-table-wrap" role="region" aria-label="Access tokens" tabindex="0">
<table class="tokens-table" id="tokens-table">
<caption class="visually-hidden">Access tokens issued to people who use the app — newest first</caption>
<thead>
<tr>
<th scope="col">Label</th>
<th scope="col">Created</th>
<th scope="col">Last used</th>
<th scope="col">Status</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="tokens-tbody">
<tr class="tokens-empty-row" id="tokens-empty-row" hidden>
<td colspan="5">No tokens yet — generate one above and hand it out.</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
<!-- Phase 79 (task 05): the in-app token gate — a body-level
position:fixed; inset:0 overlay (the body-level doc-modal
precedent) that is the ONLY interactive surface while visible:
assets/token-gate.js locks #main (inert) so focus and keyboard
traversal stay in the gate (WCAG, the inert-pair contract). It
SHIPS hidden + inert (the phase-16 ship-hidden pattern) — an
authenticated boot (admin, or a valid cached token re-authed
silently at mount) never shows it for a frame. The gate is the
token holders' login: the entered token is cached in
localStorage["bor.token"] so the next visit re-auths silently;
sign out (the header binding) drops the key. The static
"Sign in as admin" href is the no-JS fallback — the header's
?next= convention (the app root). -->
<section class="auth-gate" id="auth-gate" hidden inert aria-labelledby="auth-gate-title">
<div class="auth-gate-card">
<div class="auth-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="auth-gate-title">Enter your access token</h2>
<p class="auth-gate-sub">
Ask the admin for a token — it opens chat, the answers, and the
documents they cite. Shared chats stay open.
</p>
<form id="auth-gate-form">
<label class="visually-hidden" for="auth-gate-input">Access token</label>
<input
id="auth-gate-input"
name="token"
type="text"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
placeholder="bor_…"
required
>
<button type="submit" class="auth-gate-submit">Sign in</button>
</form>
<p class="auth-gate-error" id="auth-gate-error" role="alert" hidden>
That token isn’t valid — check it with the admin.
</p>
<a class="sources-gate-link" href="/login.html?next=/">Sign in as admin</a>
</div>
</section>
<footer class="app-footer">
<div class="container footer-inner">
<span class="footer-text">Powered by self-hosted models</span>
@@ -754,11 +955,22 @@
before the page script body calls initSharedHeader() at boot. -->
<script type="module" src="/assets/app.js"></script>
<!-- Phase 76 (task 01): the shell router — AFTER app.js (boot order:
brand.js classic → app.js module → router.js module). It reads
brand.js classic → app.js module → router.js module →
token-gate.js module, phase 79 task 05). It reads
location.pathname, shows the matching view, and lazy-imports the
non-chat view modules on first show only (mount-once). The chat
view needs no module import: app.js already ran at shell boot. -->
<script type="module" src="/assets/router.js"></script>
<!-- Phase 79 (task 05): the in-app token gate module — AFTER app.js
and router.js. Its boot call (mountGate on #main) is AWAITED by
app.js's boot IIFE BEFORE initSharedHeader, so a cached token's
silent re-auth lands before the first whoami fires — the header
sees the post-auth role deterministically. The tag + import pair
is safe: ES modules evaluate ONCE (the module cache) and
token-gate.js has no import-time side effects; the Containerfile
bundles it (the parity pin in
tests/integration/test_containerfile_assets.py). -->
<script type="module" src="/assets/token-gate.js"></script>
<!-- Phase 26: the almost-fullscreen document modal. Source chips and
Sources-table path links open documents here (same-page overlay,