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

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

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

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

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

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

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

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
This commit is contained in:
2026-09-07 12:39:01 -04:00
parent 495d042a98
commit 7fce6572d0
215 changed files with 10142 additions and 1643 deletions
+372 -73
View File
@@ -67,30 +67,12 @@ body {
min-height: 100dvh;
}
/* ---------- Animated background (pure CSS, zero JS — phase 08; reworked
phase 25: no movement, only fading light) ----------
Owner direction (2026-08-25, verbatim): "It should be smooth,
fluxuating, dimming and brightening, but not moving. Different bright
spots should slowly fade in and out."
- NO movement anywhere in the background: no grid drift, no
transform/scale, no background-position animation. The 44px/60s grid
drift (0.73px/s, diagonally down-right) rasterizes sub-pixel by
sub-pixel and reads as a once-per-second jitter; the 14s whole-layer
opacity+scale pulse reads as a uniform blink. Both are gone.
- Three independent soft glow spots, each fading in and out on its own
SLOW opacity-only cycle — 26s / 34s / 42s, ease-in-out, with negative
delays (-12s, -23s) so the cycles run out of phase (LCM 4641s: the
composite pattern effectively never repeats within a viewing
session). The total light fluxuates smoothly and irregularly.
- html::before / html::after join body::before / body::after as
background layers: <html> is the root stacking context, so their
z-index:-1 pseudo-elements paint ABOVE the var(--bg) canvas and
BELOW the transparent, non-stacking <body>'s content — the
no-occlusion contract (html owns the canvas, body stays
transparent) is unchanged.
- No filter (phase-08 no-blur perf anchor), no JS, no new assets;
opacity-only keyframes stay compositor-friendly.
- prefers-reduced-motion: reduce stills all four layers. */
/* ---------- Static background (pure CSS, zero JS) ----------
Phase 78 (owner direction, TODO.md L4): the animated background was
removed as too resource-intensive — the three opacity-fading glow
spots (and their keyframes) are deleted. What remains is fully
static: the 44px grid texture below — zero animation cost, zero JS,
no filter/blur. */
/* Static grid texture: 44px cells, 1px lines at 60% --line alpha, masked
with a widened radial fade (visible across most of the viewport,
@@ -112,48 +94,6 @@ body::before {
mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
}
/* Glow spot A — the phase-08 indigo (top-left): one soft radial spot
fading in and out on its own 26s opacity-only cycle. */
body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), transparent 62%);
animation: bg-glow-a 26s ease-in-out infinite;
}
/* Glow spot B — the phase-08 cyan (bottom-right): 34s cycle, -12s delay
(out of phase with spot A). */
html::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), transparent 62%);
animation: bg-glow-b 34s ease-in-out -12s infinite;
}
/* Glow spot C — a third indigo (bottom-left): 42s cycle, -23s delay
(out of phase with spots A and B). */
html::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), transparent 62%);
animation: bg-glow-c 42s ease-in-out -23s infinite;
}
/* Opacity-only fades — nothing but opacity may appear in any bg-*
keyframe (the no-movement contract, phase 25). */
@keyframes bg-glow-a { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }
@keyframes bg-glow-b { 0%, 100% { opacity: 0.20; } 50% { opacity: 1; } }
@keyframes bg-glow-c { 0%, 100% { opacity: 0.15; } 50% { opacity: 1; } }
.container {
width: 100%;
max-width: 72rem;
@@ -1347,13 +1287,6 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
@media (prefers-reduced-motion: reduce) {
.spinner { animation-duration: 2s; }
}
/* The background layers are the only other motion on the page: under
reduced motion they go static (grid + glows remain, just still) — all
four layers (phase 25: the html::before / html::after spots join). */
@media (prefers-reduced-motion: reduce) {
body::before, body::after, html::before, html::after { animation: none; }
}
/* ---------- Banners ---------- */
.kb-banner {
display: flex;
@@ -1695,6 +1628,114 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
.sources-gate-link:hover { background: #7d88f5; }
/* Phase 79 (task 05): the in-app token gate — the gate surface of the
two token-only pages (the shell + the document viewer, one shared
.auth-gate markup each). A body-level FIXED overlay (the
body-level doc-modal precedent): while visible it is the ONLY
interactive surface — assets/token-gate.js locks the root (#main,
inert) so focus and keyboard traversal stay inside the gate
(WCAG, the inert-pair contract).
Stacking: z-index 500 — above the app content (the sticky header is
20, the skip-link 100) but BELOW the doc-modal (1000), which can
only be opened from the unlocked app anyway. Solid --bg: the canvas
+ grid texture live on <html> (the body stays transparent), so the
overlay reads as the app's own surface — no blur (the phase-08
perf anchor).
The centered card reuses the #sources-gate visual language
(surface card + hairline + glyph + heading + sub + action) in a
tighter column. */
.auth-gate {
position: fixed;
inset: 0;
z-index: 500;
display: flex; /* the card is the only in-flow child — margin: auto centers it */
overflow-y: auto; /* short viewports: the card scrolls fully into view */
background: var(--bg);
}
/* Explicit (the global [hidden] rule already wins — this is the
documented, testable contract for the skeleton). */
.auth-gate[hidden] { display: none; }
.auth-gate-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.4rem;
/* margin:auto centers AND stays scrollable on overflow (the flex
centering pitfall: align/justify center would clip the top of a
taller-than-viewport card). */
margin: auto;
width: min(28rem, calc(100vw - 2rem));
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2.5rem 1.75rem;
}
.auth-gate-glyph { color: var(--brand-ink); width: 44px; height: 44px; }
.auth-gate-glyph svg { width: 44px; height: 44px; display: block; }
.auth-gate h2 { margin: 0.6rem 0 0.3rem; font-size: 1.4rem; }
.auth-gate-sub { margin: 0; max-width: 24rem; color: var(--ink-soft); }
/* The token form: mono input (the token is code — the house mono
language), a visible focus ring (WCAG — the 3px :focus-visible
outline at offset 0 + the brand border), and the house submit
button (the .tune-save language: dark ink on brand, 5.2:1). Both
targets are ≥44px (the touch-target contract). */
.auth-gate form {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 100%;
margin-top: 0.9rem;
}
.auth-gate input {
font-family: var(--mono);
font-size: 0.95rem;
color: var(--ink); /* 16.7:1 on --bg */
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.65rem 0.8rem;
min-height: 44px;
width: 100%;
}
.auth-gate input::placeholder { color: var(--ink-soft); }
.auth-gate input:focus-visible { outline-offset: 0; border-color: var(--brand); }
.auth-gate-submit {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 */
font: inherit;
font-weight: 700;
cursor: pointer;
}
.auth-gate-submit:hover:not(:disabled) { background: #7d88f5; }
.auth-gate-submit:disabled { opacity: 0.6; cursor: wait; }
/* The one-line error (role=alert) — the rose/danger family the
house alerts use (--err-ink on --err-bg, 9.3:1). */
.auth-gate-error {
margin: 0.75rem 0 0;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
}
/* The secondary "Sign in as admin" link reuses the .sources-gate-link
pill language (its own margin-top: 0.75rem applies) — the guest's
other door: the admin password login (the static ?next=/ is the
no-JS fallback). */
.table-wrap {
background: var(--surface);
border: 1px solid var(--line);
@@ -2179,6 +2220,49 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
box-shadow: var(--shadow);
}
/* ---------- History refresh button (phase 77 task 03, TODO.md L3) ----------
The #view-history page-head is a flex row: the title block left, the
actions slot right (the row wraps below 640px). Scoped to
#view-history — the other four views' page-heads are untouched.
The button reuses the .new-chat-btn visual language: solid brand
pill, --bg text on --brand (5.2:1, WCAG AA >=4.5:1), borderless,
>=44px target, hover lightens the brand fill, focus-visible via the
global 3px rule. The phase-46 auth-link convention: the label is
visible >=640px, the glyph is the whole control below (the button's
aria-label keeps the accessible name in both). */
#view-history .page-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem 1.5rem;
}
#view-history .page-head .page-head-title { min-width: 0; }
.history-refresh {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 44px;
padding: 0.5rem 0.9rem;
border-radius: 999px;
border: 0;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 700;
font-size: 0.95rem;
white-space: nowrap;
cursor: pointer;
flex-shrink: 0;
}
.history-refresh:hover { background: #f55a72; color: var(--bg); }
.history-refresh:disabled { opacity: 0.6; cursor: wait; }
/* The refresh glyph is hidden on desktop (the label carries the
pill); it is the whole control below 640px (the <=640 block
mirrors the phase-46 auth-link icon-only convention). */
.history-refresh svg { width: 16px; height: 16px; display: none; }
/* ---------- History page (phase 50) ----------
/history.html: the admin-only saved-chats list (task 04). The
FULL-WIDTH table in the 72rem frame (AGENTS.md rule 5 — no skinny
@@ -2384,6 +2468,203 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
font-style: italic;
}
/* ---------- Tokens view (phase 79 task 06) ----------
The admin-issued access tokens (generate · list · revoke). The
shell's standard frame (the .container): the page-head (h1 + sub),
the #tokens-gate (the #history-gate / .sources-gate language), the
create row (label + Generate), the shown-once block (the mono
field + Copy), and the FULL-WIDTH table (AGENTS.md rule 5 — the
.history-table language: --line hairlines, the brand-soft-tinted
thead, row hover, the scrollable .table-wrap). Every pair reuses
the Phase-08 AA palette: brand-ink on brand-soft 6.9:1, ink-soft
>=5.1:1, err 9.3:1. :focus-visible via the global 3px outline
rule. No CDN, system fonts. */
.tokens-shell {
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* Action feedback line (role=status): the .history-status shape —
the min-height holds the layout so a line never reflows the table. */
.tokens-status {
display: block;
min-height: 1.2em;
color: var(--ink-soft);
font-family: var(--mono);
font-size: 0.8rem;
padding-block: 0.25rem;
}
/* The create row: label input + Generate — flex, wraps below 640px
(the button drops under the full-width input). */
.token-create {
display: flex;
align-items: center;
gap: 0.6rem;
}
#token-label {
flex: 1;
min-width: 0;
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: 0.93rem;
}
#token-label:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* Generate: the .history-refresh brand pill language — the solid
brand fill (--bg text on --brand 5.2:1, AA), the ≥44px target, the
lightened hover fill, the dimmed :disabled (the in-flight state),
the global :focus-visible ring. */
.token-generate {
min-height: 44px;
padding: 0.4rem 1.1rem;
border: 0;
border-radius: 999px;
background: var(--brand);
color: var(--bg);
font: inherit;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
cursor: pointer;
}
.token-generate:hover:not(:disabled) { background: #f55a72; color: var(--bg); }
.token-generate:disabled { opacity: 0.6; cursor: wait; }
/* The shown-once block (owner-locked A4): a quiet brand-soft card
around the "shown once" line + the mono read-only field + Copy —
the token reads as the app's own credential surface (brand-ink on
brand-soft 12.4:1; ink on the field 13.8:1). */
.token-once {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.9rem 1rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--brand-soft);
}
.token-once-copy {
margin: 0;
color: var(--brand-ink);
font-size: 0.85rem;
font-weight: 600;
}
.token-once-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
/* The mono read-only field: the token is DATA — mono, surface fill,
--line border; it truncates with an ellipsis at narrow widths (the
full value is the text selection — the inline copy fallback). */
#token-once-value {
flex: 1;
min-width: 0;
min-height: 44px;
padding: 0.35rem 0.55rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font-family: var(--mono);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#token-once-value:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
/* Copy: the Tune/Retry-family ghost button (the .history-share-copy
language — --line border, transparent fill, ink-soft, ≥44px, the
brand hover pair). */
#token-once-copy {
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
#token-once-copy:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
/* The table is FULL-WIDTH (AGENTS.md rule 5): width 100% inside the
standard .container; the .table-wrap card + its horizontal scroll
cover narrow widths (the phase-07 responsive contract). The
.history-table language, verbatim. */
.tokens-table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
font-size: 0.93rem;
}
.tokens-table th, .tokens-table td {
text-align: left;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.tokens-table th {
background: var(--brand-soft);
color: var(--brand-ink);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tokens-table tbody tr:hover { background: var(--bg); }
.tokens-table tbody tr:last-child td { border-bottom: 0; }
/* Label: the hand-out name (the column ellipsizes, the full label
sits in the title attribute — tokens.js). */
.tokens-label-cell {
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
/* Created / Last used: locale date+time (ink-soft), the full ISO in
the title attribute; "never" for the not-yet-used stamp. */
.tokens-date-cell { color: var(--ink-soft); white-space: nowrap; }
/* Status: the Active em-dash rides the cell's ink-soft (5.1:1 on
--surface); the Revoked marker reuses the .stale-pill rose family
(err-ink on err-bg ≈9.3:1, err-line border — the stale-pill visual
language). */
.tokens-status-cell { color: var(--ink-soft); white-space: nowrap; }
/* Actions: the Revoke ghost button (the .history-delete language —
--line border, transparent fill, ink-soft, ≥44px target) + the
inline two-step confirm (the .history-confirm-* pair CSS above —
the phase-50 pattern). */
.tokens-actions { display: inline-flex; align-items: center; gap: 0.4rem; }
.token-revoke {
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.token-revoke:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.token-revoke:disabled { opacity: 0.5; cursor: wait; }
/* Empty-state row: the muted centered message at full table width
(the .history-empty-row language, inline in the table). */
.tokens-empty-row td {
padding: 2.25rem 1rem;
text-align: center;
color: var(--ink-soft);
font-style: italic;
}
/* ---------- Shared page (phase 51, task 03) ----------
/shared/<token>: the anonymous read-only conversation (owner-locked
2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered
@@ -3362,6 +3643,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
the two-step confirm pair fits the phone width. */
.history-actions-cell { white-space: normal; }
.history-actions { flex-wrap: wrap; }
/* Phase 79 task 06: the Tokens create row stacks — the label input
takes the full width, the Generate pill drops under it (full
width); the once field's row stacks the same way; the actions
cell wraps so the two-step confirm pair fits the phone width
(the table's own horizontal scroll covers the columns). */
.token-create { flex-direction: column; align-items: stretch; }
.token-generate { width: 100%; }
.token-once-row { flex-direction: column; align-items: stretch; }
#token-once-copy { width: 100%; }
.tokens-actions-cell { white-space: normal; }
.tokens-actions { flex-wrap: wrap; }
/* Phase 51: the shared page squeezes like the chat column — the
title and the note step down (the empty-state-title family); the
shell keeps its base 46rem column (the >=1500px 92rem override
@@ -3376,6 +3668,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
state on a touch screen. */
.sync-btn { padding: 0.4rem 0.3rem; }
.sync-label { display: none; }
/* Phase 77 task 03: the History refresh pill goes icon-only like
the phase-46 auth pill (the aria-label keeps the accessible
name); the page-head row above wraps the pill below the title
block when the width runs out. */
.history-refresh { padding: 0.4rem 0.3rem; }
.history-refresh-label { display: none; }
.history-refresh svg { display: block; }
.sync-result {
position: absolute !important;
width: 1px; height: 1px;