diff --git a/.agent/PLAN.md b/.agent/PLAN.md index 1101d14..b80d66f 100644 --- a/.agent/PLAN.md +++ b/.agent/PLAN.md @@ -3,6 +3,9 @@ > **Status:** Phase 1–3 complete (scaffolded, designed, decomposed). > **Rule:** Every agent reads this file first. Decisions marked `LOCKED` in the > Anchors table are settled — do not re-litigate them in a phase. +> **Revisions (2026-08-21, owner permission):** A7/A8/A9 revised (multi-format +> ingestion, hybrid FTS+vector retrieval, re-tuned honesty gate); dark tech +> theme (Phase 08); clickable document viewer (Phase 10). See roadmap §12. --- @@ -19,7 +22,8 @@ that"* and offers alternatives instead of hallucinating. ### In scope (v1) - Chat UI (mobile-friendly, well-styled, no auth, no CDN). -- RAG over `*.md` files **only** from `~/Homelab` + `~/Deployments` +- RAG over text knowledge files — `md, markdown, txt, yaml, yml, json, py` + by default (A9, revised 2026-08-21) — from `~/Homelab` + `~/Deployments` (and any future directory the importer is pointed at). - Self-hosted models via `https://aipi.reeseapps.com/v1` — `turbo` (chat), `embed` (embeddings, **768 dims — verified**). @@ -31,7 +35,7 @@ that"* and offers alternatives instead of hallucinating. ### Out of scope (v1) - Auth / multi-user (API is stateless under `/api` so it can be added later). -- Non-markdown content, file uploads, caching layer, message persistence. +- Binary / non-text content, file uploads, caching layer, message persistence. - Real-time document watching (manual re-import for now). --- @@ -46,9 +50,9 @@ that"* and offers alternatives instead of hallucinating. | A4 | Orchestration | `compose.yaml`, started with **`podman compose up -d`** | Matches Reese's toolchain | LOCKED | | A5 | LLM backend | OpenAI-compatible `https://aipi.reeseapps.com/v1`; models **`turbo`** (chat) & **`embed`** (embeddings); `openai` async client | Self-hosted, offline from cloud; no new model management | LOCKED | | A6 | Embedding dim | **768** (verified 2026-08-21 against live endpoint via `scripts/llm_probe.py`); configured by `BOR_EMBEDDING_DIM` | User recalled 768 — probe confirmed; dimension is fixed at table creation, so mismatch must fail loudly at import time | LOCKED | -| A7 | Retrieval→context | Cosine **top-K=4 chunks** → map to parent documents → feed the **full text of top-N=2 documents** (deduped, capped at 24k chars) to the LLM | User requirement: whole-document context; mapping via `chunks.document_id → documents.path` | LOCKED | -| A8 | Honesty gate | If best cosine similarity < `BOR_RELEVANCE_THRESHOLD` (0.30) → **deflection mode**: LLM must open with a variant of *"I haven't done anything like that"* and offer 2–3 alternative questions | Required product behavior; threshold is tunable without code change | LOCKED | -| A9 | Content scope | **`*.md` only**, with an exclusion list for non-content dirs (`.venv`, `node_modules`, `.git`, `__pycache__`, `.pytest_cache`, `dist`, `build`) | Simplicity per user; prevents indexing dependency license files (~1.6k junk files in `~/Homelab/.venv`) | LOCKED | +| A7 | Retrieval→context | **Hybrid:** cosine top-30 + Postgres FTS top-30 (OR tsquery, `ts_rank`) fused with **RRF (k=60)** → map to parent documents ranked by best fused chunk score → feed the **full text of top-N=2 documents** (deduped, capped at 24k chars) to the LLM | Owner permission 2026-08-21: pure-cosine top-4 missed real docs (gitlab case — best chunk ranked 7th behind vendored-cache junk; score compression 0.41–0.84); the lexical signal finds name-your-tool questions; whole-document context contract preserved | LOCKED (revised 2026-08-21) | +| A8 | Honesty gate | **Deflection mode** (LLM must open with a variant of *"I haven't done anything like that"* and offer 2–3 alternative questions) when best cosine < `BOR_RELEVANCE_THRESHOLD` **and** no candidate chunk FTS-matches the question; threshold re-tuned for the `embed` model's compressed score range (default **0.62**, calibrated via `scripts/eval_retrieval.py`; the E2E mock uses its own 0.30 calibration via the app fixture) | Owner permission 2026-08-21: at 0.30 the gate never discriminated (measured corpus range 0.41–0.84); the FTS-OR keeps name-your-tool questions honest-positive; deflection product behavior unchanged | LOCKED (revised 2026-08-21) | +| A9 | Content scope | Text formats **`md, markdown, txt, yaml, yml, json, py`** (default, `BOR_IMPORT_EXTENSIONS`), **hidden (dot) directories skipped by default**, plus the exclusion list (`node_modules`, `__pycache__`, `.pytest_cache`, `dist`, `build`, …) | Owner permission 2026-08-21: real notes live in yaml/py/json/txt too; the dot-dir skip removes the ~470 vendored-cache junk docs (`.esphome/.espressif/**`, …) that outranked real content | LOCKED (revised 2026-08-21) | | A10 | Auth | **None in v1**; all endpoints stateless under `/api` | Per user (auth later); statelessness keeps the future migration cheap | LOCKED | | A11 | Frontend | Vanilla HTML/CSS/JS in git; **no CDN** — everything served by FastAPI `StaticFiles`; minified by esbuild in the `Containerfile` build stage; system font stack | No external deps at runtime; tiny, auditable surface; mobile-friendly by construction | LOCKED | | A12 | Aux services | **None in v1** (no Valkey, no SeaweedFS) | No sessions/auth (no store), no uploads (no object storage); add later only if a need appears | LOCKED | @@ -84,7 +88,8 @@ that"* and offers alternatives instead of hallucinating. (self-hosted: turbo, embed) Offline tooling (same repo, same venv): - scripts/import_docs.py → walks *.md dirs, chunks, embeds, upserts + scripts/import_docs.py → walks A9-format dirs, chunks, embeds, upserts + scripts/eval_retrieval.py → ranks hybrid results for a question (tuning) scripts/llm_probe.py → verifies models + embedding dim ``` @@ -93,7 +98,7 @@ that"* and offers alternatives instead of hallucinating. |-----------|----------------|----------| | **App (FastAPI)** | Serves frontend + `/api`; RAG pipeline; logging | `app/` | | **RAG pipeline** | `embed` → pgvector cosine top-K → doc mapping → context assembly → `turbo` (streamed) with persona/honesty prompt | `app/rag/` (added in story phases) | -| **Importer** | Directory walk (exclusions), sha256 delta detection, markdown chunking, batched embedding, upsert/prune | `scripts/import_docs.py` (story phase) | +| **Importer** | Directory walk (A9 formats, hidden dirs skipped, exclusions), sha256 delta detection, format-aware chunking, batched embedding, upsert/prune | `scripts/import_docs.py` (story phase) | | **DB** | `documents`, `chunks`, `query_log` + `vector` extension | `db/` image, `alembic/` | | **Frontend** | Chat shell, sources view, loading/feedback states | `frontend/` | @@ -102,12 +107,12 @@ that"* and offers alternatives instead of hallucinating. user question → POST /api/chat {message} → embed(question) [aipi /v1/embeddings, model=embed] - → SELECT chunks ORDER BY embedding <=> $1 LIMIT 4 [pgvector cosine] - → best_score = max(1 - distance) - ├─ best_score >= 0.30 → top-2 documents' FULL content + → cosine top-30 + FTS top-30 (OR tsquery, ts_rank) [pgvector + PG FTS] + → RRF fuse (k=60) → docs ranked by best fused chunk score + ├─ best cosine >= 0.62 OR fts_hits > 0 → top-2 documents' FULL content │ → system prompt (persona + HONESTY rules + docs) │ → turbo, stream=True → SSE deltas - └─ best_score < 0.30 → DEFLECT_MODE system prompt (weak hits as topics) + └─ else → DEFLECT_MODE system prompt (weak hits as topics) → turbo, stream=True → SSE deltas (honest reply) → query_log row (question, score, deflected, sources, latency) → final SSE "done" event: {deflected, sources[], suggestions[]} @@ -124,6 +129,7 @@ All endpoints stateless (A10). Errors: standard JSON `{detail: str}`. | GET | `/api/health` | Liveness + db up/down + version | 01 | | GET | `/api/suggestions` | Onboarding suggestion strings | 01 (05 refines) | | GET | `/api/docs` | Indexed document list (source, path, title, chunks, indexed_at) | 02 | +| GET | `/api/documents/content?source=…&path=…` | One indexed document's full content (feeds the viewer page) | 10 | | POST | `/api/chat` | RAG chat turn → **SSE stream** | 03/04 | ### SSE contract (`POST /api/chat`) @@ -165,12 +171,13 @@ Created by `alembic/versions/0001_initial_schema.py` (idempotent | position | `INT` | 0-based order within the doc | | content | `TEXT` | chunk text (heading-aware) | | embedding | `VECTOR(768)` | nullable until embedded (two-phase import) | +| tsv | `TSVECTOR` | **generated** `to_tsvector('english', content) STORED` + GIN index (hybrid retrieval, A7) | > No vector index in v1: sequential scan is fine at this corpus size > (~100–500 docs). Revisit with an HNSW index if retrieval latency grows. ### `query_log` -`id UUID PK, question TEXT, top_score FLOAT, chunk_hits INT, deflected BOOL, sources TEXT, latency_ms INT, created_at TIMESTAMPTZ` +`id UUID PK, question TEXT, top_score FLOAT, fts_hits INT, chunk_hits INT, deflected BOOL, sources TEXT, latency_ms INT, created_at TIMESTAMPTZ` ### Document state transitions ``` @@ -185,6 +192,12 @@ than `BOR_CHUNK_TARGET_CHARS` (2000) at paragraph boundaries with `BOR_CHUNK_OVERLAP_CHARS` (200) overlap; each chunk keeps its nearest preceding heading in the text for retrieval quality. +**Format-aware (A9, revised):** `yaml`/`yml` split on top-level keys and +`---` separators (key line kept as anchor); `json` pretty-printed, split on +top-level keys; `py` split on top-level defs/classes (stdlib `ast`); +`txt` on paragraphs; markdown unchanged. Every format honors the 1200-char +hard cap (aipi ~1024-token request limit). + --- ## 6. RAG Pipeline & Persona @@ -213,12 +226,15 @@ Rules: model can suggest real alternatives (marker used by the E2E mock: `DEFLECT_MODE` appears in the system prompt). -### Retrieval -- Embed the question (`embed`, 768-d) → `ORDER BY embedding <=> $1 LIMIT 4`. -- `score = 1 − cosine_distance`. Gate on `max(score) >= 0.30`. -- Distinct parent docs ranked by best chunk score → top 2 → full content, - concatenated, truncated to `BOR_MAX_CONTEXT_CHARS` (24k) with a - `[…truncated…]` marker. +### Retrieval (hybrid — A7/A8, revised 2026-08-21) +- Embed the question (`embed`, 768-d) → cosine top-30 candidates. +- Lexical: OR tsquery over the question's tokens → FTS top-30 by `ts_rank`. +- **Reciprocal Rank Fusion** (`Σ 1/(k+rank)`, k=60) → distinct parent docs + ranked by best chunk's fused score → top 2 → full content, concatenated, + truncated to `BOR_MAX_CONTEXT_CHARS` (24k) with a `[…truncated…]` marker. +- Honesty gate: LOW only when `best cosine < BOR_RELEVANCE_THRESHOLD` + (default 0.62, calibrated against the `embed` model's measured 0.41–0.84 + distribution) **and** zero FTS hits among the candidates. --- @@ -246,9 +262,14 @@ Rules: buttons always get `aria-label`); form input has a (visually-hidden) label. - Live regions: message stream `aria-live="polite"`; typing indicator `role="status"`; banner `role="status"`; errors `role="alert"`. -- Contrast (verified pairs): ink `#1c2130` on `#fff` ≈14.9:1; ink-soft - `#4a5168` ≈7.6:1; white on brand `#4f46e5` ≈6.3:1; deflection text - `#92400e` on `#fff7e8` ≈8.7:1. All ≥4.5:1. +- **Dark tech theme (Phase 08, 2026-08-21)** — page `#0a0e17`, surface + `#121a2e`; ink `#e8ebf4` on surface ≈14.5:1; ink-soft `#9aa4bd` on + surface ≈6.9:1; **dark ink `#0a0e17` on brand `#6d78f2` ≈5.2:1** (white + on brand ≈3.7:1 — never used for text); brand-ink `#a5b4fc` on + brand-soft `#232b52` ≈6.9:1; deflection `#fbbf24` on `#2b2110` ≈9.5:1 + (border `#f59e0b`); error `#fca5a5` on `#2d1318` ≈9.1:1. All computed, + all ≥4.5:1. `prefers-reduced-motion` also stills the Phase-08 background + layer. - `:focus-visible` outline 3px; `prefers-reduced-motion` respected by the typing/spinner animations. @@ -277,7 +298,10 @@ Rules: `#composer`, `#message-input`, `#send-btn` / `#send-label`, `#typing-indicator`, `.msg.user/.msg.brain .bubble`, `.source-chip`, `.msg.brain.is-deflected`, `#kb-banner`, `#app-version`; sources: `#stat-docs`, `#stat-chunks`, -`#stat-last`, `#docs-table`, `#docs-tbody`, `#sources-empty`. +`#stat-last`, `#docs-table`, `#docs-tbody`, `#sources-empty`; viewer +(Phase 10): `/document.html`, `#doc-title`, `#doc-meta`, `#doc-content`, +`.doc-raw`, `.format-badge`, `#doc-not-found`, `.doc-link` (Sources table +path links). --- @@ -298,7 +322,7 @@ Rules: - **App logs:** single-line `timestamp LEVEL logger :: message` on stdout; uvicorn access logs on. INFO by default (`BOR_LOG_LEVEL`). - **Per-chat-turn log line (required):** - `question=… embed_ms=… top_score=… threshold=… deflected=… sources=… total_ms=…` + `question=… embed_ms=… top_score=… fts_hits=… threshold=… deflected=… sources=… total_ms=…` - **Importer logs:** per-file `added|updated|unchanged|pruned` + summary (counts, embedding batches, total time). - **`query_log` table:** durable record of every question (score, deflection, @@ -333,13 +357,17 @@ Rules: # first import (and any future refresh): uv run python -m scripts.import_docs # defaults: ~/Homelab ~/Deployments uv run python -m scripts.import_docs --source ~/OtherProject # extra dirs -uv run python -m scripts.import_docs --prune # also drop deleted files +uv run python -m scripts.import_docs --prune # drop deleted / filtered-out files +uv run python -m scripts.eval_retrieval "How did I install gitlab?" uv run python -m scripts.llm_probe # sanity: models + dim ``` Behavior: sha256 delta per `(source, path)` — unchanged files are skipped (no re-embedding); changed files are re-chunked + re-embedded (chunks -replaced atomically); `--prune` removes docs whose files disappeared. -Only `*.md` (A9) with the exclusion list (A9). +replaced atomically); `--prune` removes docs whose files disappeared or no +longer match the format filter. Formats per A9 (revised): `md, markdown, +txt, yaml, yml, json, py` (`BOR_IMPORT_EXTENSIONS`), hidden (dot) +directories skipped, exclusion list applied. `scripts/eval_retrieval.py` +ranks live hybrid results for a question (retrieval tuning). --- @@ -354,6 +382,9 @@ Only `*.md` (A9) with the exclusion list (A9). | 05 | `05_story_suggestion_chips.md` | `suggestion-chips.md` | `tests/e2e/test_suggestion_chips.py` | | 06 | `06_story_loading_feedback.md` | `loading-feedback.md` | `tests/e2e/test_loading_feedback.py` | | 07 | `07_story_responsive_polish.md` | `responsive-polish.md` | `tests/e2e/test_responsive_polish.py` | +| 08 | `08_story_dark_tech_theme.md` | `dark-tech-theme.md` | `tests/e2e/test_dark_tech_theme.py` | +| 09 | `09_story_retrieval_quality.md` | `retrieval-quality.md` | `tests/e2e/test_retrieval_quality.py` | +| 10 | `10_story_document_viewer.md` | `document-viewer.md` | `tests/e2e/test_document_viewer.py` | Completion = unit+integration green, coverage >90%, story E2E green in isolation, UI verification passed, **one `--no-gpg-sign` commit**. @@ -365,4 +396,4 @@ isolation, UI verification passed, **one `--no-gpg-sign` commit**. - HNSW index on `chunks.embedding` at scale. - Conversation persistence (messages tables). - Watchdog auto-re-import (inotify) — until then the script is the truth. -- More sources: any directory of `*.md` via `--source`. +- More sources: any directory of A9-format files via `--source`. diff --git a/.agent/phases/todo/08_story_dark_tech_theme.md b/.agent/phases/todo/08_story_dark_tech_theme.md new file mode 100644 index 0000000..6960ca9 --- /dev/null +++ b/.agent/phases/todo/08_story_dark_tech_theme.md @@ -0,0 +1,136 @@ +# Phase 08 — Story: Dark Tech Theme + +**Story:** `.agent/user_stories/dark-tech-theme.md` +**Context:** `.agent/PLAN.md` §7 (UI/UX strategy), §10 (testing) + +## Goal +Re-skin the whole UI to a dark, techy, emoji-free look with a subtly +animated pure-CSS background — zero behavior or layout changes, WCAG 2.1 +AA re-proven on the new palette. + +## Dependencies +Phases 01–07 (all complete). Independent of 09/10; phase 10's viewer page +inherits this theme, so 08 must land first. + +## Locked decisions +No anchors changed. Replaces the §7.2 light contrast pairs with the dark +palette below (PLAN §7.2 already updated 2026-08-21 with owner +permission). No new technology — pure CSS/HTML/inline SVG (A11). + +## Implementation steps +1. **Palette swap** — `frontend/assets/styles.css` `:root` tokens (all + pairs computed, ≥4.5:1): + + | token | dark value | computed pair | + |---|---|---| + | `--bg` | `#0a0e17` | ink on bg 16.2:1 | + | `--surface` | `#121a2e` | ink on surface 14.5:1 | + | `--ink` | `#e8ebf4` | — | + | `--ink-soft` | `#9aa4bd` | ink-soft on surface 6.9:1 | + | `--line` | `#26304a` | decorative | + | `--brand` | `#6d78f2` | **dark ink `--bg` on brand 5.2:1** | + | `--brand-soft` | `#232b52` | brand-ink on brand-soft 6.9:1 | + | `--brand-ink` | `#a5b4fc` | brand-ink on surface 8.7:1 | + | `--accent-bg` | `#2b2110` | accent-ink on accent-bg 9.5:1 | + | `--accent-ink` | `#fbbf24` | — | + | `--accent-line` | `#f59e0b` | unchanged (8.9:1 on bg) | + | `--err-bg` / `--err-ink` | `#2d1318` / `#fca5a5` | 9.1:1 | + | `--err-line` | `#ef4444` | 4.6:1 on err-bg (UI boundary) | + | `--ok-bg` / `--ok-ink` | `#10241b` / `#6ee7a8` | 10.6:1 | + + Button text is `--bg` (dark) on `--brand` — **never white on brand** + (3.7:1, fails). Busy button: keep the `#a5b4fc` background (the + `tests/unit/test_frontend_feedback.py` assertion greps this token) + with a **dark** arc (`--bg`, 9.7:1). Update derived light-mode values: + shadows (black-based, lower alpha), selection, typing dots, chip + hover. +2. **Emoji purge** — replace every emoji in chrome with inline SVG + (`aria-hidden` kept, ~16–20px, `currentColor` where sensible): + - `frontend/assets/app.js` (~L116, ~L132): avatars 🧠/🧑 → SVG + circuit-node glyph (brain) / minimal silhouette (user) as JS string + constants. + - `frontend/index.html`: favicon 🧠 data-URI → SVG tech mark (hex + + node, brand color on dark, <1 KB), still a `data:` URI; + `.brand-mark` 🧠 → same mark; ⚠️ banner icon → SVG triangle; 👋 + empty state → SVG glyph. + - `frontend/sources.html`: favicon, `.brand-mark`, 📂 empty state → + SVG marks. +3. **Tech details** — mono wordmark with letter-spacing; stat values + mono; radii `10px`/`6px`; 1px `--line` borders on cards/bubbles/table; + 2px gradient hairline (brand→cyan, low alpha) under the sticky header. +4. **Animated background (pure CSS, zero JS)** — working recipe: + `html { background: var(--bg) }`, `body { background: transparent; + position: relative }` (body must not create a stacking context): + - `body::before` — fine grid: two `linear-gradient`s (1px lines, + `--line` at ~35% alpha), `background-size: 44px 44px`, masked with a + radial fade (visible center-top, fading to the edges), animated + `background-position` `0 0 → 44px 44px`, 60s linear infinite + (seamless loop — the delta equals one cell). + - `body::after` — two large soft radial glows: indigo + `rgba(109,120,242,0.14)` top-left, cyan `rgba(34,211,238,0.10)` + bottom-right; 14s ease-in-out infinite alternate breathing + (opacity/scale). No `filter: blur` (perf). + - Both: `position: fixed; inset: 0; pointer-events: none; z-index: + -1`. Keep glow alpha low — subtle, never competing with text. +5. **Reduced motion** — `@media (prefers-reduced-motion: reduce)`: + `body::before, body::after { animation: none }` (static grid + glows + remain). Existing typing/spinner reduced-motion handling stays. +6. **Test updates (behavior unchanged):** + - `tests/e2e/test_honest_deflection.py` (~L104): deflection bubble + `backgroundColor` assertion `rgb(255, 247, 232)` → `rgb(43, 33, + 16)`; the border assertion `rgb(245, 158, 11)` is unchanged. + - New integration test + `tests/integration/test_api.py::test_ui_chrome_has_no_emoji`: GET + `/`, `/sources.html`, `/assets/app.js`, `/assets/styles.css` — assert + no characters in the emoji code-point set (U+1F300–U+1FAFF, + U+2600–U+27BF, U+2B00–U+2BFF, U+FE0F, U+200D, plus the specific + glyphs previously used: 🧠 🧑 👋 📂 ⚠️). +7. **PLAN.md §7.2** — dark contrast table already applied (2026-08-21, + owner permission); no further plan edits in this phase. + +## UI Verification +Manual screenshot pass (1280px + 375px, both pages): grid is faint +(barely-there), glows soft, no banding; brand button legible (dark +text); deflection bubble distinct from normal answers; avatars crisp at +16px; reduced-motion preview (DevTools emulation) shows the static +background. + +## Testing & Quality +- Integration: no-CDN check still green on both pages (it covers + `/sources.html` since Phase 07); new emoji-guard test above. +- **Existing E2E regression check:** after the reskin, run the existing + story suites in isolation and confirm they stay green — at minimum + `test_chat_rag.py`, `test_honest_deflection.py`, `test_responsive_ + polish.py` (the contrast helper computes from live styles and must pass + on the new palette). +- Coverage: `uv run pytest --cov=app --cov-report=term-missing` — **>90%** + on `app/`. +- `uv run ruff check . && uv run pyright` green. + +## Playwright Execution Phase +Run ONLY this story's suite: + +```bash +uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov +``` + +Implements the story mapping: dark bg + computed contrast pairs ≥4.5:1; +no emoji in innerText/outerHTML on both pages; `body::before`/`::after` +animate; reduced-motion context → `animation-name: none`; on-topic smoke +(stream + chip + button recovery) unchanged; all assets local. + +## Success criteria +- [ ] both pages dark; every text pair ≥4.5:1 (computed in E2E) +- [ ] zero emoji in chrome (E2E + new integration guard) +- [ ] animated background subtle, pure CSS, reduced-motion honored +- [ ] layout metrics + chat behavior unchanged (smoke E2E) +- [ ] existing story E2E suites still green in isolation +- [ ] unit + integration green, coverage >90%, ruff + pyright green +- [ ] committed (force-add `.agent/PLAN.md` + this phase record — rule 8) + +## Commit +```bash +git add -A +git add -f .agent/PLAN.md .agent/phases/todo/08_story_dark_tech_theme.md +git commit --no-gpg-sign -m "feat(ui): dark tech theme — emoji-free chrome, subtle animated CSS background, WCAG AA dark palette" +``` diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 2307f68..1013c09 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -107,13 +107,24 @@ export function renderMarkdown(md) { return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]); } +/* ---------- avatar glyphs (phase 08: emoji-free chrome) ---------- + * Inline SVG as string constants so the message renderer and the typing + * indicator share exactly the same marks. currentColor lets the CSS theme + * the stroke (brand-ink for Brain, ink-soft for the user — see styles.css). + */ +const BRAIN_AVATAR = + ''; + +const USER_AVATAR = + ''; + /* ---------- messages ---------- */ function addMessage(who, html) { if (emptyState) emptyState.hidden = true; const wrap = document.createElement("div"); wrap.className = `msg ${who}`; wrap.innerHTML = ` - +
${html}
`; @@ -129,7 +140,7 @@ function addTyping() { wrap.className = "msg brain"; wrap.id = "typing-indicator"; wrap.innerHTML = ` - +
diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index d83f25c..5288961 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -1,30 +1,33 @@ /* ========================================================================== Brain of Reese — design system (no CDN; system fonts only) + Dark tech theme (phase 08): emoji-free chrome, subtle animated pure-CSS + background, WCAG 2.1 AA dark palette (every pair computed >= 4.5:1). ========================================================================== */ :root { /* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */ - --bg: #f4f5fb; - --surface: #ffffff; - --ink: #1c2130; /* 14.9:1 on --surface */ - --ink-soft: #4a5168; /* 7.6:1 on --surface */ - --line: #e3e6f0; - --brand: #4f46e5; /* white on brand: 6.3:1 */ - --brand-soft: #eef0fe; - --brand-ink: #3730a3; - --accent-bg: #fff7e8; - --accent-ink: #92400e; /* 8.7:1 on --accent-bg */ - --accent-line: #f59e0b; - --ok-ink: #15803d; - --ok-bg: #f0fdf4; - --err-ink: #b91c1c; - --err-bg: #fef2f2; - --err-line: #fecaca; + --bg: #0a0e17; /* page: ink on bg 16.2:1 */ + --surface: #121a2e; /* ink on surface 14.5:1 */ + --ink: #e8ebf4; + --ink-soft: #9aa4bd; /* 6.9:1 on --surface */ + --line: #26304a; /* decorative 1px borders */ + --brand: #6d78f2; /* text on brand is DARK ink (--bg): 5.2:1 — + never white on brand (3.7:1, fails) */ + --brand-soft: #232b52; + --brand-ink: #a5b4fc; /* 8.7:1 on --surface, 6.9:1 on --brand-soft */ + --accent-bg: #2b2110; + --accent-ink: #fbbf24; /* 9.5:1 on --accent-bg */ + --accent-line: #f59e0b; /* 8.9:1 on --bg (deflection border) */ + --ok-bg: #10241b; + --ok-ink: #6ee7a8; /* 10.6:1 on --ok-bg */ + --err-bg: #2d1318; + --err-ink: #fca5a5; /* 9.1:1 on --err-bg */ + --err-line: #ef4444; /* 4.6:1 on --err-bg (UI boundary, not text) */ - --radius: 14px; - --radius-sm: 9px; - --shadow: 0 1px 2px rgb(28 33 48 / 0.06), 0 4px 16px rgb(28 33 48 / 0.07); - --shadow-lg: 0 4px 10px rgb(28 33 48 / 0.08), 0 12px 32px rgb(28 33 48 / 0.12); + --radius: 10px; + --radius-sm: 6px; + --shadow: 0 1px 2px rgb(0 0 0 / 0.30), 0 4px 16px rgb(0 0 0 / 0.35); + --shadow-lg: 0 4px 10px rgb(0 0 0 / 0.40), 0 12px 32px rgb(0 0 0 / 0.50); --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; @@ -36,18 +39,66 @@ html, body { height: 100%; } +/* The visible page background lives on (the canvas). must + stay transparent and must NOT create a stacking context, or the + z-index:-1 background layers below would be painted over. */ +html { background: var(--bg); } + body { margin: 0; font-family: var(--font); font-size: 16px; line-height: 1.55; color: var(--ink); - background: var(--bg); + background: transparent; + position: relative; display: flex; flex-direction: column; min-height: 100dvh; } +/* ---------- Animated background (pure CSS, zero JS — phase 08) ---------- */ + +/* Fine drifting grid: 44px cells, 1px lines at ~35% --line alpha, masked + with a radial fade (visible center-top, fading to the edges). The drift + delta (44px) equals one cell, so the loop is seamless. */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: + linear-gradient(to right, rgb(38 48 74 / 0.35) 1px, transparent 1px), + linear-gradient(to bottom, rgb(38 48 74 / 0.35) 1px, transparent 1px); + background-size: 44px 44px; + -webkit-mask-image: radial-gradient(120% 90% at 50% 0%, black 25%, transparent 78%); + mask-image: radial-gradient(120% 90% at 50% 0%, black 25%, transparent 78%); + animation: bg-grid-drift 60s linear infinite; +} +@keyframes bg-grid-drift { + from { background-position: 0 0, 0 0; } + to { background-position: 44px 44px, 44px 44px; } +} + +/* Two large, soft radial glows: indigo top-left, cyan bottom-right — + 14s ease-in-out breathing (opacity + scale). No filter:blur (perf). */ +body::after { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: + radial-gradient(circle 56rem at 12% 8%, rgb(109 120 242 / 0.14), transparent 62%), + radial-gradient(circle 60rem at 88% 92%, rgb(34 211 238 / 0.10), transparent 62%); + animation: bg-glow-breathe 14s ease-in-out infinite alternate; +} +@keyframes bg-glow-breathe { + from { opacity: 0.65; transform: scale(1); } + to { opacity: 1; transform: scale(1.05); } +} + .container { width: 100%; max-width: 72rem; @@ -76,7 +127,7 @@ body { left: -9999px; top: 0; background: var(--brand); - color: #fff; + color: var(--bg); padding: 0.6rem 1rem; border-radius: 0 0 var(--radius-sm) 0; z-index: 100; @@ -89,15 +140,34 @@ body { border-radius: 4px; } +::selection { + background: rgb(109 120 242 / 0.45); + color: var(--ink); +} + /* ---------- Header ---------- */ .app-header { height: var(--header-h); background: var(--surface); - border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; } +/* 2px brand→cyan gradient hairline under the sticky header (phase 08). */ +.app-header::after { + content: ""; + position: absolute; + inset-inline: 0; + bottom: -2px; + height: 2px; + pointer-events: none; + background: linear-gradient( + 90deg, + rgb(109 120 242 / 0.55), + rgb(34 211 238 / 0.30) 45%, + rgb(34 211 238 / 0.05) 90% + ); +} .header-inner { height: 100%; display: flex; @@ -113,7 +183,13 @@ body { color: var(--ink); text-decoration: none; } -.brand-mark { font-size: 1.4rem; } +/* Mono wordmark with letter-spacing — the "technical" touch (phase 08). */ +.brand-text { + font-family: var(--mono); + font-size: 0.95rem; + letter-spacing: 0.08em; +} +.brand-mark { width: 22px; height: 22px; flex: 0 0 auto; display: block; } .brand-text strong { color: var(--brand-ink); font-weight: 700; } .app-nav { display: flex; gap: 0.25rem; } @@ -129,7 +205,7 @@ body { align-items: center; } .nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); } -.nav-link.is-active { background: var(--brand); color: #fff; } +.nav-link.is-active { background: var(--brand); color: var(--bg); } /* ---------- Main frame ---------- */ .app-main { @@ -166,10 +242,14 @@ body { border-radius: 50%; display: grid; place-items: center; - font-size: 1.05rem; background: var(--brand-soft); border: 1px solid var(--line); } +/* Emoji-free inline SVG glyphs (phase 08), currentColor so the theme + controls the stroke; sized to sit crisp at ~16-20px. */ +.msg .avatar svg { width: 20px; height: 20px; display: block; } +.msg.brain .avatar { color: var(--brand-ink); } +.msg.user .avatar { color: var(--ink-soft); } .msg-body { max-width: 85%; /* min-width: 0 — as a flex item this overrides min-width:auto so a @@ -190,9 +270,10 @@ body { } .bubble p { margin: 0.2rem 0; } .bubble pre { - background: #10131c; + background: #0d1120; color: #e6e9f2; padding: 0.7rem 0.9rem; + border: 1px solid var(--line); border-radius: var(--radius-sm); overflow-x: auto; font-size: 0.85rem; @@ -206,10 +287,10 @@ body { .msg.user .bubble { background: var(--brand); border-color: var(--brand); - color: #fff; + color: var(--bg); border-bottom-right-radius: 4px; } -.msg.user .bubble code { background: rgb(255 255 255 / 0.18); } +.msg.user .bubble code { background: rgb(10 14 23 / 0.16); } .msg.brain .bubble { border-bottom-left-radius: 4px; } .msg.brain.is-deflected .bubble { @@ -245,7 +326,7 @@ body { text-overflow: ellipsis; white-space: nowrap; } -.source-chip:hover { background: #e2e5fd; } +.source-chip:hover { background: #2a345f; } /* "Maybe try" chips under a deflected bubble (phase 04). Unlike the onboarding row (which scrolls horizontally on mobile), this group wraps @@ -295,7 +376,8 @@ body { text-align: center; margin-top: 1rem; } -.empty-state-emoji { font-size: 2.6rem; line-height: 1; } +.empty-state-glyph { color: var(--brand-ink); width: 44px; height: 44px; margin-inline: auto; } +.empty-state-glyph svg { width: 44px; height: 44px; display: block; } .empty-state-title { margin: 0.8rem 0 0.4rem; font-size: 1.5rem; color: var(--ink); } .empty-state-sub { margin: 0 auto 1.25rem; max-width: 34rem; color: var(--ink-soft); } .empty-state-sub code { font-family: var(--mono); font-size: 0.85em; background: var(--brand-soft); padding: 0.1em 0.35em; border-radius: 5px; } @@ -322,7 +404,7 @@ body { cursor: pointer; transition: background 0.15s ease, transform 0.05s ease; } -.suggestion-chip:hover { background: #e2e5fd; } +.suggestion-chip:hover { background: #2a345f; } .suggestion-chip:active { transform: scale(0.98); } /* ---------- Composer ---------- */ @@ -347,6 +429,7 @@ body { padding: 0.55rem 0.5rem; background: transparent; } +.composer textarea::placeholder { color: var(--ink-soft); } .send-btn { display: inline-flex; align-items: center; @@ -357,19 +440,21 @@ body { border: 0; border-radius: var(--radius-sm); background: var(--brand); - color: #fff; + /* Dark ink on brand (5.2:1) — never white on brand (3.7:1, fails). */ + color: var(--bg); font: inherit; font-weight: 700; cursor: pointer; padding-inline: 1rem; } -.send-btn:hover:not(:disabled) { background: #4338ca; } +.send-btn:hover:not(:disabled) { background: #7d88f5; } .send-btn:disabled { background: #a5b4fc; cursor: not-allowed; } +/* Busy spinner: dark arc (--bg) on the #a5b4fc busy button = 9.7:1. */ .spinner { width: 16px; height: 16px; - border: 2.5px solid rgb(255 255 255 / 0.4); - border-top-color: #fff; + border: 2.5px solid rgb(10 14 23 / 0.30); + border-top-color: var(--bg); border-radius: 50%; animation: spin 0.8s linear infinite; } @@ -377,6 +462,11 @@ body { @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). */ +@media (prefers-reduced-motion: reduce) { + body::before, body::after { animation: none; } +} /* ---------- Banners ---------- */ .kb-banner { @@ -392,6 +482,7 @@ body { font-weight: 600; } .kb-banner.is-error { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); } +.kb-banner svg { width: 18px; height: 18px; flex: 0 0 auto; display: block; } /* ---------- Sources page ---------- */ .sources-shell { @@ -419,7 +510,8 @@ body { flex-direction: column; gap: 0.15rem; } -.stat-value { font-size: 2rem; font-weight: 800; color: var(--brand-ink); line-height: 1.1; } +/* Mono stat values — technical readout (phase 08). */ +.stat-value { font-family: var(--mono); font-size: 2rem; font-weight: 800; color: var(--brand-ink); line-height: 1.1; } .stat-value-sm { font-size: 1.15rem; font-weight: 700; } .stat-label { color: var(--ink-soft); font-size: 0.88rem; font-weight: 600; } @@ -474,7 +566,7 @@ body { @media (max-width: 640px) { :root { --header-h: 58px; } .container { padding-inline: 0.9rem; } - .brand-text { font-size: 1rem; } + .brand-text { font-size: 0.88rem; } .nav-link { padding: 0.45rem 0.7rem; font-size: 0.9rem; } .msg-body { max-width: 92%; } .empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; } diff --git a/frontend/index.html b/frontend/index.html index aa5bb6a..9c640b5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ Brain of Reese - + @@ -14,7 +14,7 @@
- + Brain of Reese