feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
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:
+217
-5
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user