Files
brain-of-reese/frontend/index.html
T
ducoterra 2ac3fc89c2
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat(ui): swap the in-turn loader for a brain-wave sweep left of the send button
Phase 117 (owner request, live-mockup-confirmed): the phase-109 3-dot
cue becomes a compact ECG trace (49px, P/QRS/T) with a brand sweep
traveling the path (bwdraw, 42/140 dash segment, 0.9s loop), the
loader repositioned left of the button so its appearance never shifts
it. setUiState stays the sole owner of the loader's hidden attribute;
the reduced-motion variant stills the sweep. Unit + lifecycle-E2E pins
updated for the new contract.
2026-09-15 11:55:59 -04:00

1468 lines
85 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" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" 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 91 (task 04): the Theme link is admin-only — hidden
by default, header.js reveals it once whoami says admin,
exactly like the Tokens link above (the shell's seventh
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 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>
<!-- Phase 111 (task 01): Retry button — revealed only for failed chat turns;
hidden for every other banner caller (share, save-doc, stale chat, …). -->
<button type="button" class="banner-retry" id="banner-retry" 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="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>Retry</span>
</button>
</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>
<!-- Phase 104 (owner 2026-09-12): the question-length counter —
hidden until 80% of the 4,000-char cap (app.js
updateCharCount); the .is-max state uses the --err-* family
PLUS a copy change (B3: text + color, never color alone). NOT
a live region: per-keystroke feedback is decorative — the
over-cap failure path announces through the error banner
(role=alert). -->
<p class="char-count" id="char-count" hidden></p>
<!-- 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>
<!-- maxlength=4000 mirrors ChatRequest.message max_length=4000
(app/schemas.py) — the server 422s beyond; the #char-count
line makes the cap visible (app.js updateCharCount). -->
<textarea
id="message-input"
name="message"
rows="1"
placeholder="Ask me anything…"
autocomplete="off"
maxlength="4000"
></textarea>
<!-- Phase 117 (owner request 2026-09-15, live-mockup-confirmed):
the persistent in-turn loader — a BRAIN-WAVE: a compact ECG
trace (P bump, QRS spike, T bump — 49px wide, 8px flat
tails; the owner picked it over the first equalizer mock).
.bw-ghost is the always-visible dim trace; .bw-pulse is the
brand sweep — a stroke-dasharray segment that travels the
path (styles.css bwdraw, pathLength-normalized for a
seamless loop). Positioned LEFT of the button on purpose:
the row is flex-end aligned with a flex:1 textarea, so the
loader's appearance and disappearance eat the textarea's
space and the button NEVER shifts (the owner's no-move
requirement). The phase-109 D16 contract stands: DECORATIVE
(aria-hidden) — #send-status stays the sole a11y announcer,
so the state's meaning never moves; hidden by default (idle
on load); app.js's setUiState is the SOLE writer of its
hidden attribute — shown iff the turn is in flight, hidden
by construction in every terminal state (PLAN §7.4 never-
stale, no per-handler cleanup). Never constructed in JS: the
createElement/textContent house rule — the SVG is static
markup too. -->
<div id="turn-loader" class="turn-loader" aria-hidden="true" hidden>
<svg class="bw" viewBox="0 0 49 32">
<path class="bw-ghost" d="M0 16 H8 L15 12 L18 16 L21 18 L24 3 L27 29 L30 16 H35 L38 11 L41 16 H49"></path>
<path class="bw-pulse" d="M0 16 H8 L15 12 L18 16 L21 18 L24 3 L27 29 L30 16 H35 L38 11 L41 16 H49" pathLength="140"></path>
</svg>
</div>
<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 a sync runs ("Syncing… <file>
(n/m)"), UNTRUNCATED (the button's label span ellipsizes;
screen readers hear the full source/relative path, which
also rides the button title). Phase 90: an in-flight upload
run adopts the button with the BARE "Importing…" label (the
run is unpack + register only — no scan, so its status
never carries a file or counts). After an upload settles it
stays empty — the upload's result line lives 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>
<!-- Phase 97 (task 04): the drill-down catalog tree — the RAG view
lists the KB the way the agent's `ls` sees it (the phase-94
concept, one end to end): the SOURCES at the top (the ONE
#folders-table below lists them exactly like folders — name,
recursive count, the stored (source, "") description), then
per level the subfolders (count + stored description) and the
level's files (the existing #docs-table further below — its
columns and row links are UNCHANGED; it holds the current
level's direct files only, hidden at the top level). #kb-crumb
(the location breadcrumb) and #kb-level (the current
directory's stored description — the block the agent's level
line shows) ship HIDDEN: assets/sources.js fills all three with
createElement + textContent (never innerHTML with
document-derived data). -->
<nav id="kb-crumb" class="kb-crumb" aria-label="Catalog location" hidden></nav>
<section id="kb-level" class="kb-level" aria-labelledby="kb-level-title" hidden>
<h2 id="kb-level-title"></h2>
<!-- Phase 97 (task 05): the current directory's description +
its Edit button (the phase-57 affordance, task 05) live in
the .kb-level-body — the editor swaps INSIDE it, the <h2>
stays put. The button ships in the static HTML (the row
cells build theirs); it is visible whenever the block is
(the description can be edited or cleared from here). -->
<div class="kb-level-body">
<p id="kb-level-summary"></p>
<button type="button" class="kb-summary-edit" id="kb-level-edit">Edit</button>
</div>
</section>
<div id="folders-wrap" class="table-wrap" role="region" aria-label="Folders" tabindex="0" hidden>
<table class="docs-table kb-folders-table" id="folders-table">
<caption class="visually-hidden">Catalog sources and folders</caption>
<thead>
<tr>
<th scope="col">Folder</th>
<th scope="col">Documents</th>
<!-- Phase 106 (task 08, D8): the Updated column — the
subtree's MAX document created_at (the tree API
derives it — D9), BETWEEN Documents and
Description (the owner's verbatim position). The
"–" when a source has no documents (the statLast
null idiom, sources.js). -->
<th scope="col">Updated</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody id="folders-tbody"></tbody>
</table>
</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>
<!-- Phase 106 (task 08, D8): the Created column — the
document's sourced creation date, BEFORE Indexed
(the owner's verbatim position). sources.js builds
the cell: locale date (fmtDate) + the full ISO
value on the cell's title (hover precision). -->
<th scope="col">Created</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; 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 ("Upload") runs the §7.4 never-stale
lifecycle ("Uploading…" while the POST is out). Phase 64
(task 05) reworked 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 bare "Processing…" label 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. Phase 90 (owner-locked A1/A2/A3): the background
run is UNPACK + REGISTER ONLY — no model check, no import,
no overview refresh — so the processing state covers
unpack only (no file, no "(n/m)" counts, no title) and
the success line points at the next step: "Uploaded
<name> — press Sync sources to import it." The scan is
the RAG page's Sync button's job (edit the source's
ignore paths first, if you want files excluded). -->
<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</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 (uploads unpack and register in place here; the Sync button scans them)</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>
<!-- Phase 105: the per-row "Hidden" toggle's error line — the
checkbox is a table-cell control (no dialog of its own), so
its failure announces here (role=alert; git-sources.js
showHiddenError). Hidden until a PATCH fails. -->
<p class="git-source-error" id="git-sources-hidden-error" role="alert" hidden></p>
<!-- 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 registers — the scan
is the Sync button's job, phase 90 — 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 register the source only — re-uploading
the same filename replaces that source in place (no new
folder, no duplicate row). Press <strong>Sync sources</strong>
on the RAG page to scan it; edit the source's ignore paths
first if you want files excluded. 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>
<!-- Phase 89: the per-source ignore-paths editor — the EXACT
#remove-confirm-dialog pattern (phase 69): page-local
alertdialog, dim backdrop, focus on Cancel (the safe
default), Escape/backdrop close as CANCEL, focus returns
to the row's trigger button. The box itself: one path
per line (prefix semantics — the phase-89 A1 rule,
stated in plain words in the helper line); Save runs the
§7.4 never-stale lifecycle (git-sources.js: "Saving…"
while the PATCH is out). Stored rows only (A3) — the
per-row button lives in git-sources.js's makeRow; this
is the page-local dialog it opens. -->
<div class="ignore-editor" id="ignore-editor-dialog" role="alertdialog"
aria-modal="true" aria-labelledby="ignore-editor-title"
aria-describedby="ignore-editor-copy" hidden>
<div class="ignore-editor-backdrop" aria-hidden="true"></div>
<div class="ignore-editor-panel">
<h2 class="ignore-editor-title" id="ignore-editor-title">Ignored files and folders</h2>
<code class="ignore-editor-source" id="ignore-editor-source"></code>
<p class="ignore-editor-copy" id="ignore-editor-copy">
One path per line. A file is ignored when its path in the
source starts with any of these — <code>/my/files/</code>,
<code>my/files/</code> and <code>my/files</code> mean the same
thing. No wildcards, no matching in the middle of a path.
</p>
<label class="ignore-editor-label" for="ignore-editor-textarea">Ignored paths</label>
<textarea class="ignore-editor-textarea" id="ignore-editor-textarea"
rows="6" spellcheck="false"
placeholder="my/files/"></textarea>
<p class="ignore-editor-error" id="ignore-editor-error" role="alert" hidden></p>
<div class="ignore-editor-actions">
<button type="button" class="ignore-editor-btn ignore-editor-cancel"
id="ignore-editor-cancel">Cancel</button>
<button type="button" class="ignore-editor-btn ignore-editor-save"
id="ignore-editor-save">Save</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: name (1–120 — the server re-validates; a
BLANK name is REFUSED client-side — tokens.js announces
"Give the token a name first." and sends NOTHING, phase
101 D3; the server's 422 is defense in depth) +
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 — required"
aria-label="Token name"
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>
<!-- Phase 101 (task 02, D4): the ACTIVE table's live label
search — a client-side, case-insensitive SUBSTRING filter
over the rendered rows (no fetch). SHIPS hidden with the
create row (tokens.js reveals it in the admin branch);
the query lives in module state and is RE-APPLIED after
every loadTokens() — a re-render never loses it. -->
<input
type="search"
id="token-search-active"
aria-label="Search active tokens"
placeholder="Search active tokens…"
class="token-search"
hidden
>
<!-- The full-width ACTIVE table (AGENTS.md rule 5 — no skinny
list): Label | Created (locale date+time, full ISO on
hover) | Last used (locale or "never") | Actions (Revoke
— the inline two-step confirm; phase 101 task 03 adds
Regenerate before it). The Status column is GONE (phase
101 D1 — a table that is all-active needs no status; the
table's position IS the status). tokens.js fills
#tokens-tbody with the revoked === false rows only;
#tokens-empty-row ships hidden and is revealed by a
0-active-row fetch (and a failed load); #tokens-no-match-row
(its text JS-filled) is the D4 search no-match state. 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"><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="4">No tokens yet — generate one above and hand it out.</td>
</tr>
<tr class="tokens-empty-row" id="tokens-no-match-row" hidden>
<td colspan="4"></td>
</tr>
</tbody>
</table>
</div>
<!-- Phase 101 (task 02, D1 + D4): the REVOKED section — BELOW
the active table: a visible sub-heading + its own live
label search + its own full-width table (Label | Created
| Last used | Revoked — the revoked_at date, locale
date+time, full ISO on hover). SHIPS hidden — tokens.js
shows it when ≥ 1 token is revoked (an empty table is
noise). The table IS the status: the em-dash /
.stale-pill column is gone from BOTH tables. Revoked rows
carry NO actions (nothing left to revoke or rotate —
task 03's Regenerate lives in the active table only). -->
<h2 id="tokens-revoked-heading" class="tokens-revoked-heading" hidden>Revoked tokens</h2>
<input
type="search"
id="token-search-revoked"
aria-label="Search revoked tokens"
placeholder="Search revoked tokens…"
class="token-search"
hidden
>
<div class="table-wrap tokens-table-wrap" id="tokens-revoked-wrap" role="region" aria-label="Revoked tokens" tabindex="0" hidden>
<table class="tokens-table" id="tokens-revoked-table">
<caption class="visually-hidden">Revoked tokens — newest first</caption>
<thead>
<tr>
<th scope="col">Label</th>
<th scope="col">Created</th>
<th scope="col">Last used</th>
<th scope="col">Revoked</th>
</tr>
</thead>
<tbody id="tokens-revoked-tbody">
<tr class="tokens-empty-row" id="tokens-revoked-no-match-row" hidden>
<td colspan="4"></td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Phase 91 (task 04): the Theme view — the shell's seventh
folded view (the phase-76 fold pattern, the phase-79 Tokens
view as the most recent precedent): the admin-only palette +
branding editor. /theme.html serves THIS document (the shell
route in app/main.py); the router shows this section for that
pathname. The CSS-file theming (BOR_THEME, phase 62) is
retired (task 03): the effective theme is injected into every
served page's <head> server-side (app/core/caching.py +
app/core/theming.py), so it paints on the FIRST paint — no
red flash, no pop-in. SHIPS hidden (anonymous-safe — the gate
is what anonymous sees; theme.js reveals the content for
admin only). 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
theme.js on first show only (mount-once, hide-forever; the
editor lands in task 05). The form is STATIC markup (the
E2E-stable-selectors house convention) — the onsubmit
binding + live preview + Save/Reset lifecycle land in task 05
(theme.js); there is no real submit (every button is
type="button"). -->
<section class="view" id="view-theme" hidden inert aria-label="Theme" tabindex="-1">
<div class="container theme-shell">
<!-- Phase 91 (task 04): anonymous sign-in gate — the EXACT
#sources-gate pattern (phase 16) and the same .sources-gate
visual language: the palette + branding is what the login
locks (B5, owner-locked 2026-09-09). Visible for
anonymous, hidden for the admin (theme.js). The gate's
Sign in returns to the Theme view (the header's ?next=
convention; the static href is the no-JS fallback). -->
<section class="sources-gate" id="theme-gate" aria-labelledby="theme-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="theme-gate-title">Sign in to change the theme</h2>
<p class="sources-gate-sub">
The palette and branding are admin-only. Chat — and any
document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/theme.html">Sign in</a>
</section>
<!-- Phase 91 (task 04): the editor — SHIPS hidden
(anonymous-safe; the gate is what anonymous sees). theme.js
reveals it once the cached whoami says admin (the
#git-sources-content pattern). -->
<div id="theme-content" hidden>
<div class="page-head">
<h1>Theme</h1>
<p class="page-sub">
Changes preview live as you pick;
<strong>Save theme</strong> bakes the palette into every
page it is served on — it applies on the first paint, no
pop-in.
</p>
</div>
<!-- Static form skeleton (E2E-stable selectors; task 05 wires
the bindings — effective-value populate, the live preview,
the §7.4 Save/Reset lifecycle, the WCAG contrast warnings
in #theme-contrast). No real submit: both buttons are
type="button"; maxlength=300 mirrors the server's 300-char
limit (the server re-validates — 422 naming the field).
The color inputs ship the BUILT-IN values (app/core/
theming.py BUILTIN_COLORS) — task 05 re-populates them
with the EFFECTIVE values on mount. -->
<form id="theme-form" novalidate>
<fieldset class="theme-group">
<legend class="theme-group-title">Branding</legend>
<!-- B4 (owner-locked): the strings keep the brand.js
runtime application — they apply via the /api/config
boot fetch on the NEXT page load; the live preview
covers the palette only. An empty field restores the
default (the env value). -->
<p class="theme-note">
These strings apply on the next page load — the live
preview covers the palette only. Leaving a field empty
restores its default.
</p>
<label for="theme-app-name">App name</label>
<input
id="theme-app-name"
name="app_name"
type="text"
maxlength="300"
autocomplete="off"
>
<label for="theme-placeholder">Chat input placeholder</label>
<input
id="theme-placeholder"
name="input_placeholder"
type="text"
maxlength="300"
autocomplete="off"
>
<label for="theme-footer">Footer line</label>
<input
id="theme-footer"
name="footer_text"
type="text"
maxlength="300"
autocomplete="off"
>
</fieldset>
<fieldset class="theme-group">
<!-- The eight text/background pairs (the five identity
pairs + the three semantic ink-on-bg pairs from the
State colors fieldset below) are checked against
WCAG 2.1 AA (4.5:1) as the owner picks (theme.js —
the app/core/theming.py docstring is the
authoritative pair table); failures list in
#theme-contrast as a warning and never block a save. -->
<legend class="theme-group-title">
Palette — eight pairs checked against WCAG 2.1 AA (4.5:1)
</legend>
<div class="theme-colors">
<div class="theme-color">
<label for="theme-bg">Background (--bg)</label>
<input id="theme-bg" name="bg" type="color" value="#0f0a0a">
</div>
<div class="theme-color">
<label for="theme-surface">Surface (--surface)</label>
<input id="theme-surface" name="surface" type="color" value="#1a0f0f">
</div>
<div class="theme-color">
<label for="theme-ink">Text (--ink)</label>
<input id="theme-ink" name="ink" type="color" value="#f0e6e6">
</div>
<div class="theme-color">
<label for="theme-ink-soft">Secondary text (--ink-soft)</label>
<input id="theme-ink-soft" name="ink_soft" type="color" value="#b8a8a8">
</div>
<div class="theme-color">
<label for="theme-line">Border (--line)</label>
<input id="theme-line" name="line" type="color" value="#2d1a1a">
</div>
<div class="theme-color">
<label for="theme-grid-line">Grid lines (--grid-line)</label>
<input id="theme-grid-line" name="grid_line" type="color" value="#4a2626">
</div>
<div class="theme-color">
<label for="theme-brand">Brand accent (--brand) — buttons, links</label>
<input id="theme-brand" name="brand" type="color" value="#f43f5e">
</div>
<div class="theme-color">
<label for="theme-brand-soft">Brand tint (--brand-soft)</label>
<input id="theme-brand-soft" name="brand_soft" type="color" value="#2d0a0a">
</div>
<div class="theme-color">
<label for="theme-brand-ink">Brand text (--brand-ink)</label>
<input id="theme-brand-ink" name="brand_ink" type="color" value="#fca5a5">
</div>
</div>
</fieldset>
<!-- Phase 93 (task 02): the 8 semantic state colors (B3
revised — owner permission 2026-09-10, TODO.md L3):
the success / error / notice families join the
storable palette (17 variables total). Their three
ink-on-bg pairs are part of the same WCAG 2.1 AA
(4.5:1) check as the identity pairs (theme.js PAIRS);
the two *_line borders are decorative (no contrast
duty — the same rule as --line / --grid-line). Every
state element keeps its TEXT label, so a monochrome
theme conveys state by words, not color (B5). The
inputs ship the BUILT-IN values (app/core/theming.py
BUILTIN_COLORS) — theme.js re-populates them with the
EFFECTIVE values on mount. -->
<fieldset class="theme-group">
<legend class="theme-group-title">State colors</legend>
<p class="theme-note">
The success, error, and notice states the interface
paints with — state text + color, never color alone.
</p>
<div class="theme-colors">
<div class="theme-color">
<label for="theme-ok-bg">Success background (--ok-bg)</label>
<input id="theme-ok-bg" name="ok_bg" type="color" value="#10241b">
</div>
<div class="theme-color">
<label for="theme-ok-ink">Success text (--ok-ink)</label>
<input id="theme-ok-ink" name="ok_ink" type="color" value="#6ee7a8">
</div>
<div class="theme-color">
<label for="theme-err-bg">Error pill background (--err-bg)</label>
<input id="theme-err-bg" name="err_bg" type="color" value="#2d0a0a">
</div>
<div class="theme-color">
<label for="theme-err-ink">Error pill text (--err-ink)</label>
<input id="theme-err-ink" name="err_ink" type="color" value="#fca5a5">
</div>
<div class="theme-color">
<label for="theme-err-line">Error pill border (--err-line)</label>
<input id="theme-err-line" name="err_line" type="color" value="#ef4444">
</div>
<div class="theme-color">
<label for="theme-accent-bg">Notice background (--accent-bg)</label>
<input id="theme-accent-bg" name="accent_bg" type="color" value="#2b2110">
</div>
<div class="theme-color">
<label for="theme-accent-ink">Notice text (--accent-ink)</label>
<input id="theme-accent-ink" name="accent_ink" type="color" value="#fbbf24">
</div>
<div class="theme-color">
<label for="theme-accent-line">Notice border (--accent-line)</label>
<input id="theme-accent-line" name="accent_line" type="color" value="#f59e0b">
</div>
</div>
</fieldset>
<!-- Save (primary) + Reset to defaults (secondary). Both
type="button" (no real submit); task 05 runs the §7.4
never-stale lifecycle ("Saving…" while the PUT is out,
re-enabled on success AND failure). -->
<div class="theme-actions">
<button type="button" id="theme-save">Save theme</button>
<button type="button" class="theme-reset" id="theme-reset">Reset to defaults</button>
</div>
</form>
<!-- task 05 owns all three lines: #theme-error (the server's
422 detail — the fields are kept on failure), #theme-result
("Theme saved." / "Reset to the built-in theme."),
#theme-contrast (the WCAG warnings for the eight pairs —
warning-only, the owner can still save). -->
<p class="theme-error" id="theme-error" role="alert" hidden></p>
<p class="theme-result" id="theme-result" role="status" aria-live="polite" hidden></p>
<p class="theme-contrast" id="theme-contrast" role="alert" hidden></p>
</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 CONTENT-level 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). Phase 85 (TODO.md L3): the overlay sits
BELOW the sticky header (z-index 15 < 20) — it covers #main's
content region visually, but the header (hamburger + menu)
stays reachable for the unauthenticated visitor. 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>