Files
brain-of-reese/frontend/index.html
T
ducoterra e29d68d9f0 phase: 82_security_headers
All completion criteria verified green — no defects found, nothing to fix. Final report:

**Phase 82 (security headers) — final verification pass: all green**

- Verified prior-run implementation: `app/core/security_headers.py` (pure-ASGI, header-only, exact A1 CSP), registration in `app/main.py` after `configure_caching` (outermost), unit/integration/E2E suites.
- Deviation confirmed sound: `data:`-URI favicon (blocked by locked CSP) → static `frontend/assets/favicon.svg` in 5 templates + Containerfile `cp`; SVG element byte-identical to the old data-URI (verified programmatically); serves 200 with all three headers.
- Curl check (server booted like e2e conftest, log: `/tmp/curl_security_headers_final.log`): `/`, `/api/health`, `/assets/styles.css`, `/nope` (404) → all three headers, CSP exactly `default-src 'self'; base-uri 'none'; frame-ancestors 'none'`.
- `uv run pytest tests/unit/test_security_headers.py tests/integration/test_security_headers.py -v --no-cov` → 13 passed (incl. SSE byte-identity pin).
- `uv run pytest tests/e2e/test_security_headers.py -v --no-cov` (isolated) → 2 passed (headers + zero CSP violations + painted page).
- SSE tripwire `uv run pytest tests/e2e/test_chat_rag.py -v --no-cov` → 3 passed.
- `uv run pytest --cov=app --cov-report=term-missing` → 1665 passed, app/ 99% (>90%); `uv run ruff check . && uv run pyright` → clean (0 errors).
- `git diff --stat` limited to phase-82 files + the two documented deviations (favicon set, `tests/unit/__init__.py`); no `pyproject.toml`/`uv.lock`/JS diffs.
- Commit + phase-dir move left to the harness per pipeline rules (not executed by me).

Next pending phase: `83_chat_save_payload_limits`.
2026-09-07 23:54:41 -04:00

1009 lines
59 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="Ask anything about your indexed documents — every answer cites the exact doc.">
<title>Brain of Reese</title>
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<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="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span>
</span>
<!-- Phase 46 (owner permission 2026-08-27, `TODO.md` L9): the
mobile hamburger — visible ≤640px only (CSS); opens the nav as
an animated dropdown. Behavior: assets/header.js. -->
<button type="button" class="nav-toggle" id="nav-toggle"
aria-expanded="false" aria-controls="app-nav" aria-label="Menu">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
<nav class="app-nav" id="app-nav" aria-label="Primary">
<a href="/" class="nav-link is-active" aria-current="page">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. The soft-gated page
itself is unchanged. -->
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</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>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, exactly like the
Sources link above. -->
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
<!-- Phase 50 (owner permission 2026-08-29, `TODO.md` L5): the
History link is admin-only — hidden by default, header.js
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>
<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>
<!-- Phase 46 (mobile dropdown copy — desktop bar copy is
outside the nav; see styles.css .sign-out-mobile rules). -->
<button type="button" class="auth-link sign-out-btn sign-out-mobile" id="sign-out-btn-mobile" 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>
</nav>
<!-- Phase 15: the tuning-notes panel (stored in Postgres, read
into every system prompt) — owned by the shared header
module (assets/header.js); the #steering-panel section
ships in every page's <main>. The navbar toggle was
removed at owner request (2026-08-28): note management
lives on /tuning.html. -->
<!-- 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=/sources.html" class="auth-link sign-in-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 sign-out-btn" 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>
<!-- Phase 76 (task 01): the shell's single <main> holds the navbar
views as <section class="view"> blocks — only the active one is
shown (the others carry hidden + inert, so focus and keyboard
traversal never enter them). A navbar click is a client-side
view switch (assets/router.js — pushState + show/hide), never a
document load; the in-flight chat stream in the hidden view
keeps streaming through any switch. Each folded page's own
<main class="app-main"> wrapper (identical on all five pages —
the layout CSS is class-based) is dropped with the move, and
the per-view copies of the header-owned steering panel are
dropped too (this shell's ONE panel — the chat one, inside
#view-chat — is the instance header.js drives). -->
<main id="main" class="app-main" tabindex="-1">
<section class="view" id="view-chat" aria-label="Chat" tabindex="-1">
<div class="container chat-shell" data-state="empty">
<div class="kb-banner" id="kb-banner" role="status" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
<span id="kb-banner-text"></span>
</div>
<!-- Phase 53 (task 05): the stale-saved-chat banner. The /?chat=<id>
boot load reveals it ONLY when the fetched row reports
stale: true (the server computes it — the row's sources stamp
is behind the current generation, task 03; the client never
does staleness math). Regenerate (the exact brand-pill family
of the Save/Share pair; the redo glyph is the phase-49 Retry
asset) re-asks the last question against the new index via
retryLastTurn and re-saves the linked row (the server
re-stamps sources_version → stale: false), clearing the
banner. A stale chat with no brain answer is revealed
text-only — app.js removes the button, so retryLastTurn is
never called. Stacks directly below #kb-banner when both are
visible (kb-banner keeps the top slot; the .chat-shell flex
gap spaces them). -->
<section class="kb-banner stale-banner" id="stale-banner" role="status" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>The sources have been updated since this chat was saved.</span>
<button type="button" class="stale-regenerate" id="stale-regenerate">
<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="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>Regenerate</span>
</button>
</section>
<!-- Phase 15 (now every page — phase 34, owner confirmation
2026-08-26): the tuning-notes panel (stored notes, newest
first) — rendered + driven by assets/header.js (shared), not
the page script. First child of <main> on the non-chat
pages; the chat page keeps it after #kb-banner. -->
<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>
<!-- Phase 49 (2026-08-29, TODO.md L4): the meta row under a brain
bubble can carry JS-injected actions (app.js) — Tune (admin
only, phase 15) and Retry (every visitor; the LAST brain
bubble only, redo-in-place). No static markup: both are
injected like the source chips. -->
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese">
<div class="empty-state" id="empty-state">
<div class="empty-state-glyph" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M10 8h28a4 4 0 0 1 4 4v18a4 4 0 0 1-4 4H24l-9 8v-8h-5a4 4 0 0 1-4-4V12a4 4 0 0 1 4-4Z"/><path d="m15 17 5 4-5 4"/><path d="M24 25h8"/></svg>
</div>
<h1 class="empty-state-title">Hey! I'm Brain of Reese.</h1>
<p class="empty-state-sub">
I've read through your documents — ask me anything,
and I'll point you at the exact doc. You've got this.
</p>
<div class="suggestions" id="suggestions" role="list" aria-label="Suggested questions">
<!-- suggestion chips rendered by app.js from /api/suggestions -->
</div>
</div>
</section>
<!-- Phase 55 (task 05, 2026-08-31, TODO.md L6, owner-locked A5):
the chat-actions row — New chat + Share share ONE
.chat-actions wrapper (a normal .chat-shell column child,
replacing the two pills as its direct children): a
HORIZONTAL row on desktop (side by side, left-aligned, each
pill at its intrinsic width — the row's align-items: center
beats the column's stretch) and a VERTICAL stack at the
existing ≤640px breakpoint (New chat above Share,
full-width). DOM order New chat → Share in both
orientations; the 46rem column contract is untouched
(PLAN §7). The kb-banner / stale-banner / steering /
announcer structure around the row is unchanged.
Phase 14 (module-owned since phase 34 task 02): "New chat"
resets the local (localStorage) conversation and clears the
rendered list. The binding lives in header.js — dispatches
"bor:new-chat" which app.js acts on (it owns the
in-flight-turn guard + list reset).
Phase 65 (task 01, 2026-09-01, `TODO.md` L3, owner
confirmation 2026-09-01): the row was relocated from
the top of the column to the bottom (above the
composer), so the cluster sits where the
tune/retry/save-doc meta actions live; the top of the
column is now banner → steering → announcer →
messages.
Phase 65 (task 02, 2026-09-01, `TODO.md` L3, owner-locked
A1): the row + the composer below are wrapped in ONE
.chat-bottom unit (the LAST child of .chat-shell) —
position: sticky; bottom:
env(safe-area-inset-bottom, 0), NO z-index (the sticky
header stays on top — the phase-46 stacking pinned by
tests/e2e/test_pinned_composer.py). The composer's OWN
sticky declaration is kept: it is redundant inside the
wrapper (its containing block is the wrapper, so it cannot
shift) but tests/e2e/test_pinned_composer.py asserts the
computed style sticky on #composer. -->
<div class="chat-bottom">
<div class="chat-actions">
<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 55 (owner-locked A2, 2026-08-31): there is NO Save
control — every conversation auto-saves itself (app.js
persistConversation at the phase-14/20 save points). The
History page is the visible proof; a failed auto-save leaves
only a one-line status note (never a button, never a banner).
Phase 55 (owner-locked A1, 2026-08-31): the "Share" pill is
STATIC, ALWAYS-VISIBLE markup — there is no reveal step
(phase 51's admin-only ship-hidden gate is gone with it; task
01 opened the save/share write surface to every visitor).
Phase 51 (owner-locked 2026-08-29, `TODO.md` L6): "Share"
turns the current conversation into a PUBLIC read-only link —
/shared/<token> (a 128-bit uuid4 on the saved_chats row,
migration 0009). The save-then-share contract: an UNSAVED
(unlinked) conversation is saved AND shared in ONE action —
app.js POSTs /api/chats with { messages, share: true } (the
server sets the token in the same commit) and links the
conversation to the created row; a saved (linked) one just
POSTs /api/chats/<id>/share (idempotent — the existing token
comes back unchanged). On success the ABSOLUTE link is
copied to the clipboard; a non-secure (http) homelab origin
that rejects the clipboard gets the inline link-field
fallback instead (owner-locked — app.js renders
.share-link-fallback near the status line). app.js binds the
click to shareCurrentChat. Unsharing lives on the History
page's Share column (admin-only). -->
<button type="button" class="share-chat-btn" id="share-chat-btn" aria-label="Share chat">
<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 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<span class="share-chat-label">Share</span>
</button>
</div>
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
roles — #send-btn reads "Send" when idle and morphs into the
enabled "Stop" control (.is-stop, rose treatment) while a turn
is in flight; a click or Enter in flight aborts the fetch
(AbortController in app.js) and the partial is kept + persisted
with the optional `stopped` marker, rendered as the .stopped-note
meta-row note by app.js — live and on restore. The spinner span
stays in the markup (contract marker, reduced-motion pin) but
never shows: the Stop label + treatment carry the in-flight
state.
`novalidate`: the input is cleared after send, so a `required`
constraint would silently block the Stop click/Enter — the
browser's constraint validation runs before the `submit` event
and would never reach handleSend's in-flight guard. The
`!text` guard in app.js is the real empty-input check (same
precedent as the tuning form's noValidate). -->
<form class="composer" id="composer" novalidate>
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
<textarea
id="message-input"
name="message"
rows="1"
placeholder="Ask me anything…"
autocomplete="off"
></textarea>
<button type="submit" class="send-btn" id="send-btn">
<span class="spinner" aria-hidden="true" hidden></span>
<span class="btn-label" id="send-label">Send</span>
<span class="visually-hidden" aria-live="polite" id="send-status"></span>
</button>
</form>
</div>
</div>
</section>
<!-- Phase 76 (task 01): the Global Tuning view — the content of
frontend/tuning.html's <main> (wrapper dropped), folded into
the shell. /tuning.html now serves THIS document (the shell
route in app/main.py); the router shows this section for that
pathname. Its own header / steering-panel copies lived in the
old page's <header>/<main> and are dropped — the shell's
single header + chat-view panel stand in for them. 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 tuning.js on first
show only (mount-once, hide-forever). -->
<section class="view" id="view-tuning" hidden inert aria-label="Global Tuning" tabindex="-1">
<div class="container tuning-shell">
<div class="page-head">
<h1>Global Tuning</h1>
<p class="page-sub">
Every note below is read into the system prompt of
<strong>every</strong> chat turn. Add, edit, or remove them here —
no conversation required.
</p>
</div>
<!-- Phase 27: create a note without a chat. The label is
visually-hidden (the heading + placeholder carry the visible
context); the 1–2000-char contract mirrors the chat-page tune
form — the server re-validates (422). -->
<form id="tune-form">
<label class="visually-hidden" for="tune-note">Add a global tuning note</label>
<textarea
id="tune-note"
name="note"
rows="3"
maxlength="2000"
placeholder="e.g. be more concise — or: assume I'm on NixOS"
required
></textarea>
<button type="submit" id="tune-save">Add note</button>
</form>
<!-- Live announcer for create / edit / delete — tuning.js (phase 27,
task 03) owns the message text. -->
<p class="visually-hidden" id="tune-announcer" role="status" aria-live="polite"></p>
<!-- Phase 27: the note list — the phase-15 steering panel's
language, full column width. tuning.js fills it newest-first;
each row is an <li class="tuning-note"> with a
.tuning-note-text span + an Edit and a Delete button (styles:
styles.css "Global tuning page"). The empty state toggles with
the list. -->
<section class="tuning-panel" aria-labelledby="tuning-panel-title">
<h2 id="tuning-panel-title" class="tuning-panel-title">Tuning notes</h2>
<ul id="tune-list" class="tuning-list" role="list"></ul>
<p id="tune-empty">No tuning notes yet — add one above.</p>
</section>
</div>
</section>
<!-- Phase 76 (task 02): the RAG view (the Knowledge base catalog)
— the content of frontend/sources.html's <main> (wrapper
dropped), folded into the shell. /sources.html now serves THIS
document (the shell route in app/main.py); the router shows
this section for that pathname. The per-view copies of the
header-owned steering panel + announcer are dropped (the
shell's ONE panel — the chat one, inside #view-chat — is the
instance header.js drives), and the old page's SECOND
doc-modal-* skeleton copy is dropped too: the shell keeps
EXACTLY ONE (the chat's, body level), which BOTH app.js (chat
chips) and sources.js (RAG rows) open through
openDocumentModal(...). The per-page footer does not move
(body-level — the shell's single footer stands in). 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 sources.js on first
show only (mount-once, hide-forever). -->
<section class="view" id="view-rag" hidden inert aria-label="RAG" tabindex="-1">
<div class="container sources-shell">
<div class="page-head">
<div class="page-head-row">
<h1>Knowledge base</h1>
<button type="button" class="sync-btn" id="sync-btn" aria-label="Sync sources" hidden>
<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>
</div>
<p class="page-sub">
Every file indexed from your configured sources — git repositories,
local directories, and uploaded archives. Press <strong>Sync sources</strong>
to pull the latest and re-import.
</p>
</div>
<!-- #sync-result is the aria-live announcer: the last sync
result ("N added · …") when a sync settles, and — phase 64 —
the LIVE file label while either job runs ("Syncing… <file>
(n/m)" / "Importing <file> (n/m)"), UNTRUNCATED (the button's
label span ellipsizes; screen readers hear the full
source/relative path, which also rides the button title).
After an upload settles it stays empty — the upload's counts
live on the Sources page (A3). -->
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
<!-- Sync failure banner — role="alert" so a failed sync is announced. -->
<div class="kb-banner is-error" id="sync-error-banner" role="alert" hidden>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.6 22.2 20.4H1.8Z"/><path d="M12 9.5v4.6"/><path d="M12 17.4h.01"/></svg>
<span id="sync-error-text"></span>
</div>
<!-- Phase 16: anonymous sign-in gate. The catalog is what the
login locks — the document viewer itself stays public (soft
rule), so the copy says what stays open. -->
<section class="sources-gate" id="sources-gate" aria-labelledby="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="sources-gate-title">Sign in to view the full catalog</h2>
<p class="sources-gate-sub">
The complete list of indexed documents is admin-only. Chat — and
any document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/sources.html">Sign in</a>
</section>
<div class="stat-cards" id="stat-cards">
<div class="stat-card" role="group" aria-label="Document statistics">
<span class="stat-value" id="stat-docs">–</span>
<span class="stat-label">documents</span>
</div>
<div class="stat-card" role="group" aria-label="Chunk statistics">
<span class="stat-value" id="stat-chunks">–</span>
<span class="stat-label">chunks</span>
</div>
<div class="stat-card" role="group" aria-label="Last indexed">
<span class="stat-value stat-value-sm" id="stat-last">–</span>
<span class="stat-label">last indexed</span>
</div>
</div>
<div class="table-wrap" role="region" aria-label="Indexed documents" tabindex="0">
<table class="docs-table" id="docs-table">
<caption class="visually-hidden">Indexed markdown documents</caption>
<thead>
<tr>
<th scope="col">Source</th>
<th scope="col">Path</th>
<th scope="col">Title</th>
<th scope="col">Chunks</th>
<th scope="col">Indexed</th>
</tr>
</thead>
<tbody id="docs-tbody"></tbody>
</table>
</div>
<div class="empty-state" id="sources-empty" hidden>
<div class="empty-state-glyph" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12a4 4 0 0 1 4-4h10l4 5h14a4 4 0 0 1 4 4v17a4 4 0 0 1-4 4H10a4 4 0 0 1-4-4Z"/><path d="M6 20h36"/><path d="M15 28h9M15 33h14"/></svg>
</div>
<h2 class="empty-state-title">Nothing indexed yet</h2>
<p class="empty-state-sub">
Run the import to pull in the markdown docs:
<code>uv run python -m scripts.import_docs</code>
</p>
</div>
</div>
</section>
<!-- Phase 76 (task 02): the Sources view (the git-sources manager
+ archive uploads) — the content of frontend/git-sources.html's
<main> (wrapper dropped), folded into the shell.
/git-sources.html now serves THIS document (the shell route
in app/main.py); the router shows this section for that
pathname. The per-view copies of the header-owned steering
panel + announcer are dropped (same reasoning as the RAG
view above), and the per-page footer does not move. The
upload-progress state machine (phase 64/65) is mounted ONCE
(mount-once, hide-forever) and keeps running across view
switches in this one document: its poller is a self-chaining
setTimeout started when an upload begins — never at boot — so
progress continues while the user is on another view, and
nothing refetches on re-show. The hidden + inert pair is the
WCAG contract (AGENTS.md rule 5). Mounted lazily —
assets/router.js imports git-sources.js on first show only. -->
<section class="view" id="view-git-sources" hidden inert aria-label="Sources" tabindex="-1">
<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 cloned and indexed by the sync service
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 git repositories and local directories the Sync button
imports. 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/your-repo.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>
<!-- Phase 49 (owner permission 2026-08-28): the archive upload
form replaces the phase-38 local-directory form — an
uploaded .tar/.tar.gz/.tgz/.zip is unpacked under
BOR_UPLOAD_DIR and scanned; the same filename replaces the
source in place (no new folder, no duplicate row). The file
control is labeled (visible <label for=…> — WCAG
input-label rule); the button runs the §7.4 never-stale
lifecycle ("Uploading…" while the POST is out). Phase 64
(task 05) reworks the rest to the 202 contract (the
phase-49 synchronous 200 paragraph is superseded): the 202
arrives the moment the archive is safely on disk (A1) — a
JS-created "Successfully uploaded — <file>" toast fires
then (A2 — the phase-55 .toast node, no markup here; safe
to navigate away) and the button settles into the live
"Processing… <file> (n/m)" label (A4 — the full path rides
the button title) driven by the 2 s poll of
GET /api/git-sources/upload/status, until the success line
(role=status) or the sanitized error banner (role=alert)
lands; 409 re-attaches to the in-flight run — no error
banner; the other non-2xx still show the server detail
inline. -->
<form id="archive-upload-form">
<label for="archive-upload-file">Upload a source archive (.tar, .tar.gz, .tgz, .zip)</label>
<input id="archive-upload-file" name="file" type="file"
accept=".tar,.tar.gz,.tgz,.zip" required>
<button type="submit" id="archive-upload-btn">Upload &amp; scan</button>
<p class="git-source-error" id="archive-upload-error" role="alert" hidden></p>
<p class="git-source-result" id="archive-upload-result" role="status"
aria-live="polite" hidden></p>
</form>
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Sources" tabindex="0">
<table class="git-sources-table" id="git-sources-table">
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones, local directories it walks, and uploaded archives (unpacked under the upload directory)</caption>
<thead>
<tr>
<th scope="col">Source</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 sources stored yet.</p>
<!-- Phase 69 (owner request 2026-09-02): removal is a TOTAL
removal — the row, the source's indexed documents, and —
for git clones and uploaded archives — the files on the
server's disk, all immediately (the confirmation modal
below spells it out; foreign local directories are never
touched). Adding still does not clone — the Sync button
mirrors the remaining sources (upstream file churn is
pruned on that run); the phase-49 upload is the
in-place exception (it unpacks and scans, and a
same-name re-upload replaces the source in place). -->
<p class="git-source-hint" id="git-sources-hint" role="note">
Removing a source is a total removal, done immediately: its
entry, its indexed documents, and — for git clones and
uploaded archives — its files on the server's disk (the
confirmation modal spells out exactly what will be deleted;
files in your own local directories are never touched).
Uploads unpack and scan immediately — re-uploading the same
filename replaces that source in place (no new folder, no
duplicate row). The Sync button still mirrors the remaining
sources (files removed upstream are pruned on that run).
</p>
<!-- Phase 69 (owner request 2026-09-02): the remove
confirmation — a real in-app alertdialog (the native
confirm() retired): a row's Remove button opens it
(git-sources.js).
It names the source (#remove-confirm-source — ALWAYS
populated via textContent: URLs may embed user:pass@
credentials, the phase-32 masking discipline) and states
the full-removal policy. Focus lands on Cancel (the safe
default for a destructive action); Escape, the Cancel
button, and the dim backdrop all close as cancel (no
request — focus returns to the row's Remove button); only
"Remove source" sends the DELETE, in the §7.4 "Removing…"
in-flight state. The .doc-modal overlay contract: a fixed
full-viewport dim backdrop + a centered panel (no blur).
Static markup so the E2E suite gets stable selectors (the
#git-sources-hint / gate convention). -->
<div class="remove-confirm" id="remove-confirm-dialog" role="alertdialog"
aria-modal="true" aria-labelledby="remove-confirm-title"
aria-describedby="remove-confirm-copy" hidden>
<div class="remove-confirm-backdrop" aria-hidden="true"></div>
<div class="remove-confirm-panel">
<h2 class="remove-confirm-title" id="remove-confirm-title">Remove this source?</h2>
<code class="remove-confirm-source" id="remove-confirm-source"></code>
<p class="remove-confirm-copy" id="remove-confirm-copy">
This permanently removes the source entry, all of its
indexed documents from the knowledge base, and — for git
clones and uploaded archives — the files on the server's
disk. Files in your own local directories are never
touched. This cannot be undone.
</p>
<p class="remove-confirm-error" id="remove-confirm-error" role="alert" hidden></p>
<div class="remove-confirm-actions">
<button type="button" class="remove-confirm-btn remove-confirm-cancel"
id="remove-confirm-cancel">Cancel</button>
<button type="button" class="remove-confirm-btn remove-confirm-remove"
id="remove-confirm-remove">Remove source</button>
</div>
</div>
</div>
</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>
</section>
<!-- Phase 76 (task 03): the History view (saved chats) — the
content of frontend/history.html's <main> (wrapper dropped),
folded into the shell. /history.html now serves THIS document
(the shell route in app/main.py); the router shows this
section for that pathname. The per-view copies of the
header-owned steering panel + announcer are dropped (the
shell's ONE panel — the chat one, inside #view-chat — is the
instance header.js drives), and the page's footer is dropped
too (the history page's #app-version span would DUPLICATE the
shell's single (chat) footer). The row actions stay REAL
navigations: the Open link (?chat=<id>) and the copy-link
field are plain anchor/document-load targets — opening a
saved chat is a chat-view concern handled by app.js at boot
via ?chat= (out of scope for the router). 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 history.js on first show
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">
<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 (visible at every width — below 640px
the pill goes full width and the glyph joins the
label; 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
same .sources-gate visual language (phase 35, git-sources):
the saved-chat list is what the login locks. Visible for
anonymous, hidden for the admin (history.js) — and the
view never fetches /api/chats for an anonymous visitor
(the router 403s them; the story E2E pins the request
log). -->
<section class="sources-gate" id="history-gate" aria-labelledby="history-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="history-gate-title">Sign in to view your saved chats</h2>
<p class="sources-gate-sub">
Saved conversations are admin-only. Chat — and any document an
answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/history.html">Sign in</a>
</section>
<!-- Live-region feedback for row actions (the "never stale"
contract): history.js sets textContent here — a delete's
outcome, its error line, nothing else. -->
<span class="history-status" id="history-status" role="status" aria-live="polite"></span>
<!-- Phase 50: the full-width table (AGENTS.md rule 5 — no skinny
list): Title (the Open link → /?chat=<id>) | Messages |
Updated | Stale (phase 53: the READ-ONLY staleness marker —
the rose pill when the row predates the last KB-changing
sync; the Regenerate action lives on the chat-page banner,
task 05) | Share (phase 51: Create link / Copy / Unshare —
the row's share_url comes from GET /api/chats itself, no
second fetch) | Actions (Delete, inline two-step confirm).
history.js fills #history-tbody; #history-empty-row ships
hidden and is revealed by a 0-row fetch. The Actions column
header is visually-hidden — the row buttons carry their own
aria-labels. -->
<div class="table-wrap history-table-wrap" id="history-table-wrap" role="region" aria-label="Saved chats" tabindex="0">
<table class="history-table">
<caption class="visually-hidden">Saved chats — click a title to return to that conversation</caption>
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Messages</th>
<th scope="col">Updated</th>
<th scope="col">Stale</th>
<th scope="col">Share</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="history-tbody">
<tr class="history-empty-row" id="history-empty-row" hidden>
<td colspan="6">No saved chats yet — start a conversation and it will be saved automatically.</td>
</tr>
</tbody>
</table>
</div>
</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>
<span class="footer-version" id="app-version"></span>
</div>
</footer>
<!-- Phase 39: the brand layer — a CLASSIC script, first on every
page: window.BOR_BRAND is set at parse time (before the module
scripts evaluate) and refreshed from /api/config (a byte-
identical no-op for the default name). -->
<script src="assets/brand.js"></script>
<script src="assets/markdown.js"></script>
<!-- Phase 19: the shared header module loads through the page script's
own `import "./header.js"` — a hoisted import that is evaluated
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 →
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,
no new tab) instead of navigating to /document.html — that page
stays as the no-JS / direct-link fallback, unchanged. The page
scripts fetch /api/documents/content and render into
#doc-modal-content; the hidden attribute keeps the skeleton inert
until JS opens it. #doc-modal-open points at the same
/document.html?source=…&path=… URL the modal builds, so the
dedicated page is always one click away. -->
<div class="doc-modal" id="doc-modal" hidden>
<div class="doc-modal-backdrop" id="doc-modal-backdrop" aria-hidden="true"></div>
<div class="doc-modal-panel" id="doc-modal-panel" role="dialog" aria-modal="true" aria-labelledby="doc-modal-title" aria-describedby="doc-modal-desc">
<header class="doc-modal-header">
<h2 class="doc-modal-title" id="doc-modal-title">Loading…</h2>
<div class="doc-modal-actions">
<a class="doc-modal-open" id="doc-modal-open" target="_blank" rel="noopener" hidden aria-label="Open in full page">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>
<span>Full page</span>
</a>
<button type="button" class="doc-modal-close" id="doc-modal-close" aria-label="Close document">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
</header>
<div class="doc-modal-meta" id="doc-modal-meta" aria-live="polite"></div>
<p class="visually-hidden" id="doc-modal-desc" role="status">Document content is loading.</p>
<main class="doc-modal-content" id="doc-modal-content" tabindex="-1">
<p class="doc-modal-loading" role="status">Loading document…</p>
</main>
</div>
</div>
</body>
</html>