feat(ui): dark tech theme — emoji-free chrome, subtle animated CSS background, WCAG AA dark palette

This commit is contained in:
2026-08-21 23:34:36 -04:00
parent 1b29b1cf9d
commit 2f738a7f19
11 changed files with 748 additions and 81 deletions
+58 -27
View File
@@ -3,6 +3,9 @@
> **Status:** Phase 1–3 complete (scaffolded, designed, decomposed). > **Status:** Phase 1–3 complete (scaffolded, designed, decomposed).
> **Rule:** Every agent reads this file first. Decisions marked `LOCKED` in the > **Rule:** Every agent reads this file first. Decisions marked `LOCKED` in the
> Anchors table are settled — do not re-litigate them in a phase. > 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) ### In scope (v1)
- Chat UI (mobile-friendly, well-styled, no auth, no CDN). - 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). (and any future directory the importer is pointed at).
- Self-hosted models via `https://aipi.reeseapps.com/v1` — `turbo` (chat), - Self-hosted models via `https://aipi.reeseapps.com/v1` — `turbo` (chat),
`embed` (embeddings, **768 dims — verified**). `embed` (embeddings, **768 dims — verified**).
@@ -31,7 +35,7 @@ that"* and offers alternatives instead of hallucinating.
### Out of scope (v1) ### Out of scope (v1)
- Auth / multi-user (API is stateless under `/api` so it can be added later). - 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). - 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 | | 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 | | 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 | | 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 | | 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 | 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 | | 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 | **`*.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 | | 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 | | 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 | | 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 | | 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) (self-hosted: turbo, embed)
Offline tooling (same repo, same venv): 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 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/` | | **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) | | **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/` | | **DB** | `documents`, `chunks`, `query_log` + `vector` extension | `db/` image, `alembic/` |
| **Frontend** | Chat shell, sources view, loading/feedback states | `frontend/` | | **Frontend** | Chat shell, sources view, loading/feedback states | `frontend/` |
@@ -102,12 +107,12 @@ that"* and offers alternatives instead of hallucinating.
user question user question
→ POST /api/chat {message} → POST /api/chat {message}
→ embed(question) [aipi /v1/embeddings, model=embed] → embed(question) [aipi /v1/embeddings, model=embed]
→ SELECT chunks ORDER BY embedding <=> $1 LIMIT 4 [pgvector cosine] → cosine top-30 + FTS top-30 (OR tsquery, ts_rank) [pgvector + PG FTS]
→ best_score = max(1 - distance) → RRF fuse (k=60) → docs ranked by best fused chunk score
├─ best_score >= 0.30 → top-2 documents' FULL content ├─ best cosine >= 0.62 OR fts_hits > 0 → top-2 documents' FULL content
│ → system prompt (persona + HONESTY rules + docs) │ → system prompt (persona + HONESTY rules + docs)
│ → turbo, stream=True → SSE deltas │ → 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) → turbo, stream=True → SSE deltas (honest reply)
→ query_log row (question, score, deflected, sources, latency) → query_log row (question, score, deflected, sources, latency)
→ final SSE "done" event: {deflected, sources[], suggestions[]} → 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/health` | Liveness + db up/down + version | 01 |
| GET | `/api/suggestions` | Onboarding suggestion strings | 01 (05 refines) | | GET | `/api/suggestions` | Onboarding suggestion strings | 01 (05 refines) |
| GET | `/api/docs` | Indexed document list (source, path, title, chunks, indexed_at) | 02 | | 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 | | POST | `/api/chat` | RAG chat turn → **SSE stream** | 03/04 |
### SSE contract (`POST /api/chat`) ### 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 | | position | `INT` | 0-based order within the doc |
| content | `TEXT` | chunk text (heading-aware) | | content | `TEXT` | chunk text (heading-aware) |
| embedding | `VECTOR(768)` | nullable until embedded (two-phase import) | | 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 > 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. > (~100–500 docs). Revisit with an HNSW index if retrieval latency grows.
### `query_log` ### `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 ### 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 `BOR_CHUNK_OVERLAP_CHARS` (200) overlap; each chunk keeps its nearest
preceding heading in the text for retrieval quality. 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 ## 6. RAG Pipeline & Persona
@@ -213,12 +226,15 @@ Rules:
model can suggest real alternatives (marker used by the E2E mock: model can suggest real alternatives (marker used by the E2E mock:
`DEFLECT_MODE` appears in the system prompt). `DEFLECT_MODE` appears in the system prompt).
### Retrieval ### Retrieval (hybrid — A7/A8, revised 2026-08-21)
- Embed the question (`embed`, 768-d) → `ORDER BY embedding <=> $1 LIMIT 4`. - Embed the question (`embed`, 768-d) → cosine top-30 candidates.
- `score = 1 − cosine_distance`. Gate on `max(score) >= 0.30`. - Lexical: OR tsquery over the question's tokens → FTS top-30 by `ts_rank`.
- Distinct parent docs ranked by best chunk score → top 2 → full content, - **Reciprocal Rank Fusion** (`Σ 1/(k+rank)`, k=60) → distinct parent docs
concatenated, truncated to `BOR_MAX_CONTEXT_CHARS` (24k) with a ranked by best chunk's fused score → top 2 → full content, concatenated,
`[…truncated…]` marker. 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. buttons always get `aria-label`); form input has a (visually-hidden) label.
- Live regions: message stream `aria-live="polite"`; typing indicator - Live regions: message stream `aria-live="polite"`; typing indicator
`role="status"`; banner `role="status"`; errors `role="alert"`. `role="status"`; banner `role="status"`; errors `role="alert"`.
- Contrast (verified pairs): ink `#1c2130` on `#fff` ≈14.9:1; ink-soft - **Dark tech theme (Phase 08, 2026-08-21)** — page `#0a0e17`, surface
`#4a5168` ≈7.6:1; white on brand `#4f46e5` ≈6.3:1; deflection text `#121a2e`; ink `#e8ebf4` on surface ≈14.5:1; ink-soft `#9aa4bd` on
`#92400e` on `#fff7e8` ≈8.7:1. All ≥4.5:1. 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 - `:focus-visible` outline 3px; `prefers-reduced-motion` respected by the
typing/spinner animations. typing/spinner animations.
@@ -277,7 +298,10 @@ Rules:
`#composer`, `#message-input`, `#send-btn` / `#send-label`, `#typing-indicator`, `#composer`, `#message-input`, `#send-btn` / `#send-label`, `#typing-indicator`,
`.msg.user/.msg.brain .bubble`, `.source-chip`, `.msg.brain.is-deflected`, `.msg.user/.msg.brain .bubble`, `.source-chip`, `.msg.brain.is-deflected`,
`#kb-banner`, `#app-version`; sources: `#stat-docs`, `#stat-chunks`, `#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; - **App logs:** single-line `timestamp LEVEL logger :: message` on stdout;
uvicorn access logs on. INFO by default (`BOR_LOG_LEVEL`). uvicorn access logs on. INFO by default (`BOR_LOG_LEVEL`).
- **Per-chat-turn log line (required):** - **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 - **Importer logs:** per-file `added|updated|unchanged|pruned` + summary
(counts, embedding batches, total time). (counts, embedding batches, total time).
- **`query_log` table:** durable record of every question (score, deflection, - **`query_log` table:** durable record of every question (score, deflection,
@@ -333,13 +357,17 @@ Rules:
# first import (and any future refresh): # first import (and any future refresh):
uv run python -m scripts.import_docs # defaults: ~/Homelab ~/Deployments 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 --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 uv run python -m scripts.llm_probe # sanity: models + dim
``` ```
Behavior: sha256 delta per `(source, path)` — unchanged files are skipped Behavior: sha256 delta per `(source, path)` — unchanged files are skipped
(no re-embedding); changed files are re-chunked + re-embedded (chunks (no re-embedding); changed files are re-chunked + re-embedded (chunks
replaced atomically); `--prune` removes docs whose files disappeared. replaced atomically); `--prune` removes docs whose files disappeared or no
Only `*.md` (A9) with the exclusion list (A9). 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` | | 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` | | 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` | | 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 Completion = unit+integration green, coverage >90%, story E2E green in
isolation, UI verification passed, **one `--no-gpg-sign` commit**. 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. - HNSW index on `chunks.embedding` at scale.
- Conversation persistence (messages tables). - Conversation persistence (messages tables).
- Watchdog auto-re-import (inotify) — until then the script is the truth. - 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`.
@@ -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"
```
+13 -2
View File
@@ -107,13 +107,24 @@ export function renderMarkdown(md) {
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]); 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 =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/><circle cx="12" cy="12" r="1.9" fill="currentColor" stroke="none"/><path d="M9.5 6.5V3.8M14.5 6.5V3.8M9.5 20.2v-2.7M14.5 20.2v-2.7M6.5 9.5H3.8M6.5 14.5H3.8M20.2 9.5h-2.7M20.2 14.5h-2.7"/></svg>';
const USER_AVATAR =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M4.8 20.2c.9-3.9 3.8-6 7.2-6s6.3 2.1 7.2 6"/></svg>';
/* ---------- messages ---------- */ /* ---------- messages ---------- */
function addMessage(who, html) { function addMessage(who, html) {
if (emptyState) emptyState.hidden = true; if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = `msg ${who}`; wrap.className = `msg ${who}`;
wrap.innerHTML = ` wrap.innerHTML = `
<span class="avatar" aria-hidden="true">${who === "brain" ? "🧠" : "🧑"}</span> <span class="avatar" aria-hidden="true">${who === "brain" ? BRAIN_AVATAR : USER_AVATAR}</span>
<div class="msg-body"> <div class="msg-body">
<div class="bubble">${html}</div> <div class="bubble">${html}</div>
</div>`; </div>`;
@@ -129,7 +140,7 @@ function addTyping() {
wrap.className = "msg brain"; wrap.className = "msg brain";
wrap.id = "typing-indicator"; wrap.id = "typing-indicator";
wrap.innerHTML = ` wrap.innerHTML = `
<span class="avatar" aria-hidden="true">🧠</span> <span class="avatar" aria-hidden="true">${BRAIN_AVATAR}</span>
<div class="msg-body"> <div class="msg-body">
<div class="bubble typing" role="status" aria-label="${TYPING_LABEL}"> <div class="bubble typing" role="status" aria-label="${TYPING_LABEL}">
<span></span><span></span><span></span> <span></span><span></span><span></span>
+130 -38
View File
@@ -1,30 +1,33 @@
/* ========================================================================== /* ==========================================================================
Brain of Reese — design system (no CDN; system fonts only) 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 { :root {
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */ /* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
--bg: #f4f5fb; --bg: #0a0e17; /* page: ink on bg 16.2:1 */
--surface: #ffffff; --surface: #121a2e; /* ink on surface 14.5:1 */
--ink: #1c2130; /* 14.9:1 on --surface */ --ink: #e8ebf4;
--ink-soft: #4a5168; /* 7.6:1 on --surface */ --ink-soft: #9aa4bd; /* 6.9:1 on --surface */
--line: #e3e6f0; --line: #26304a; /* decorative 1px borders */
--brand: #4f46e5; /* white on brand: 6.3:1 */ --brand: #6d78f2; /* text on brand is DARK ink (--bg): 5.2:1 —
--brand-soft: #eef0fe; never white on brand (3.7:1, fails) */
--brand-ink: #3730a3; --brand-soft: #232b52;
--accent-bg: #fff7e8; --brand-ink: #a5b4fc; /* 8.7:1 on --surface, 6.9:1 on --brand-soft */
--accent-ink: #92400e; /* 8.7:1 on --accent-bg */ --accent-bg: #2b2110;
--accent-line: #f59e0b; --accent-ink: #fbbf24; /* 9.5:1 on --accent-bg */
--ok-ink: #15803d; --accent-line: #f59e0b; /* 8.9:1 on --bg (deflection border) */
--ok-bg: #f0fdf4; --ok-bg: #10241b;
--err-ink: #b91c1c; --ok-ink: #6ee7a8; /* 10.6:1 on --ok-bg */
--err-bg: #fef2f2; --err-bg: #2d1318;
--err-line: #fecaca; --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: 10px;
--radius-sm: 9px; --radius-sm: 6px;
--shadow: 0 1px 2px rgb(28 33 48 / 0.06), 0 4px 16px rgb(28 33 48 / 0.07); --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(28 33 48 / 0.08), 0 12px 32px rgb(28 33 48 / 0.12); --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; --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
@@ -36,18 +39,66 @@
html, body { height: 100%; } html, body { height: 100%; }
/* The visible page background lives on <html> (the canvas). <body> 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 { body {
margin: 0; margin: 0;
font-family: var(--font); font-family: var(--font);
font-size: 16px; font-size: 16px;
line-height: 1.55; line-height: 1.55;
color: var(--ink); color: var(--ink);
background: var(--bg); background: transparent;
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100dvh; 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 { .container {
width: 100%; width: 100%;
max-width: 72rem; max-width: 72rem;
@@ -76,7 +127,7 @@ body {
left: -9999px; left: -9999px;
top: 0; top: 0;
background: var(--brand); background: var(--brand);
color: #fff; color: var(--bg);
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
border-radius: 0 0 var(--radius-sm) 0; border-radius: 0 0 var(--radius-sm) 0;
z-index: 100; z-index: 100;
@@ -89,15 +140,34 @@ body {
border-radius: 4px; border-radius: 4px;
} }
::selection {
background: rgb(109 120 242 / 0.45);
color: var(--ink);
}
/* ---------- Header ---------- */ /* ---------- Header ---------- */
.app-header { .app-header {
height: var(--header-h); height: var(--header-h);
background: var(--surface); background: var(--surface);
border-bottom: 1px solid var(--line);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 20; 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 { .header-inner {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -113,7 +183,13 @@ body {
color: var(--ink); color: var(--ink);
text-decoration: none; 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; } .brand-text strong { color: var(--brand-ink); font-weight: 700; }
.app-nav { display: flex; gap: 0.25rem; } .app-nav { display: flex; gap: 0.25rem; }
@@ -129,7 +205,7 @@ body {
align-items: center; align-items: center;
} }
.nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); } .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 ---------- */ /* ---------- Main frame ---------- */
.app-main { .app-main {
@@ -166,10 +242,14 @@ body {
border-radius: 50%; border-radius: 50%;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 1.05rem;
background: var(--brand-soft); background: var(--brand-soft);
border: 1px solid var(--line); 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 { .msg-body {
max-width: 85%; max-width: 85%;
/* min-width: 0 — as a flex item this overrides min-width:auto so a /* 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 p { margin: 0.2rem 0; }
.bubble pre { .bubble pre {
background: #10131c; background: #0d1120;
color: #e6e9f2; color: #e6e9f2;
padding: 0.7rem 0.9rem; padding: 0.7rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
overflow-x: auto; overflow-x: auto;
font-size: 0.85rem; font-size: 0.85rem;
@@ -206,10 +287,10 @@ body {
.msg.user .bubble { .msg.user .bubble {
background: var(--brand); background: var(--brand);
border-color: var(--brand); border-color: var(--brand);
color: #fff; color: var(--bg);
border-bottom-right-radius: 4px; 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 .bubble { border-bottom-left-radius: 4px; }
.msg.brain.is-deflected .bubble { .msg.brain.is-deflected .bubble {
@@ -245,7 +326,7 @@ body {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.source-chip:hover { background: #e2e5fd; } .source-chip:hover { background: #2a345f; }
/* "Maybe try" chips under a deflected bubble (phase 04). Unlike the /* "Maybe try" chips under a deflected bubble (phase 04). Unlike the
onboarding row (which scrolls horizontally on mobile), this group wraps onboarding row (which scrolls horizontally on mobile), this group wraps
@@ -295,7 +376,8 @@ body {
text-align: center; text-align: center;
margin-top: 1rem; 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-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 { 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; } .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; cursor: pointer;
transition: background 0.15s ease, transform 0.05s ease; 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); } .suggestion-chip:active { transform: scale(0.98); }
/* ---------- Composer ---------- */ /* ---------- Composer ---------- */
@@ -347,6 +429,7 @@ body {
padding: 0.55rem 0.5rem; padding: 0.55rem 0.5rem;
background: transparent; background: transparent;
} }
.composer textarea::placeholder { color: var(--ink-soft); }
.send-btn { .send-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -357,19 +440,21 @@ body {
border: 0; border: 0;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--brand); 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: inherit;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
padding-inline: 1rem; 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; } .send-btn:disabled { background: #a5b4fc; cursor: not-allowed; }
/* Busy spinner: dark arc (--bg) on the #a5b4fc busy button = 9.7:1. */
.spinner { .spinner {
width: 16px; height: 16px; width: 16px; height: 16px;
border: 2.5px solid rgb(255 255 255 / 0.4); border: 2.5px solid rgb(10 14 23 / 0.30);
border-top-color: #fff; border-top-color: var(--bg);
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@@ -377,6 +462,11 @@ body {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.spinner { animation-duration: 2s; } .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 ---------- */ /* ---------- Banners ---------- */
.kb-banner { .kb-banner {
@@ -392,6 +482,7 @@ body {
font-weight: 600; font-weight: 600;
} }
.kb-banner.is-error { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); } .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 page ---------- */
.sources-shell { .sources-shell {
@@ -419,7 +510,8 @@ body {
flex-direction: column; flex-direction: column;
gap: 0.15rem; 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-value-sm { font-size: 1.15rem; font-weight: 700; }
.stat-label { color: var(--ink-soft); font-size: 0.88rem; font-weight: 600; } .stat-label { color: var(--ink-soft); font-size: 0.88rem; font-weight: 600; }
@@ -474,7 +566,7 @@ body {
@media (max-width: 640px) { @media (max-width: 640px) {
:root { --header-h: 58px; } :root { --header-h: 58px; }
.container { padding-inline: 0.9rem; } .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; } .nav-link { padding: 0.45rem 0.7rem; font-size: 0.9rem; }
.msg-body { max-width: 92%; } .msg-body { max-width: 92%; }
.empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; } .empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; }
+6 -4
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Ask Brain of Reese anything about the homelab and deployments."> <meta name="description" content="Ask Brain of Reese anything about the homelab and deployments.">
<title>Brain of Reese</title> <title>Brain of Reese</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧠</text></svg>"> <link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/styles.css"> <link rel="stylesheet" href="/assets/styles.css">
</head> </head>
<body> <body>
@@ -14,7 +14,7 @@
<header class="app-header"> <header class="app-header">
<div class="container header-inner"> <div class="container header-inner">
<span class="brand"> <span class="brand">
<span class="brand-mark" aria-hidden="true">🧠</span> <svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span> <span class="brand-text">Brain of <strong>Reese</strong></span>
</span> </span>
<nav class="app-nav" aria-label="Primary"> <nav class="app-nav" aria-label="Primary">
@@ -27,13 +27,15 @@
<main id="main" class="app-main" tabindex="-1"> <main id="main" class="app-main" tabindex="-1">
<div class="container chat-shell" data-state="empty"> <div class="container chat-shell" data-state="empty">
<div class="kb-banner" id="kb-banner" role="status" hidden> <div class="kb-banner" id="kb-banner" role="status" hidden>
<span aria-hidden="true">⚠️</span> <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> <span id="kb-banner-text"></span>
</div> </div>
<section class="messages" id="messages" aria-live="polite" aria-label="Conversation with Brain of Reese"> <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" id="empty-state">
<div class="empty-state-emoji" aria-hidden="true">👋</div> <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> <h1 class="empty-state-title">Hey! I'm Brain of Reese.</h1>
<p class="empty-state-sub"> <p class="empty-state-sub">
I've read through the homelab and deployment notes — ask me anything, I've read through the homelab and deployment notes — ask me anything,
+5 -3
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Documents indexed in Brain of Reese."> <meta name="description" content="Documents indexed in Brain of Reese.">
<title>Sources · Brain of Reese</title> <title>Sources · Brain of Reese</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧠</text></svg>"> <link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/styles.css"> <link rel="stylesheet" href="/assets/styles.css">
</head> </head>
<body> <body>
@@ -14,7 +14,7 @@
<header class="app-header"> <header class="app-header">
<div class="container header-inner"> <div class="container header-inner">
<span class="brand"> <span class="brand">
<span class="brand-mark" aria-hidden="true">🧠</span> <svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span> <span class="brand-text">Brain of <strong>Reese</strong></span>
</span> </span>
<nav class="app-nav" aria-label="Primary"> <nav class="app-nav" aria-label="Primary">
@@ -66,7 +66,9 @@
</div> </div>
<div class="empty-state" id="sources-empty" hidden> <div class="empty-state" id="sources-empty" hidden>
<div class="empty-state-emoji" aria-hidden="true">📂</div> <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> <h2 class="empty-state-title">Nothing indexed yet</h2>
<p class="empty-state-sub"> <p class="empty-state-sub">
Run the import to pull in the markdown docs: Run the import to pull in the markdown docs:
+361
View File
@@ -0,0 +1,361 @@
"""Phase 08 E2E (Playwright): dark tech theme — palette, no emoji, animated
background, reduced motion, behavior intact, all assets local.
Story: ``.agent/user_stories/dark-tech-theme.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov
Test → story mapping (Playwright Mapping Rule):
1. ``test_dark_palette_and_contrast`` — dark page background on both pages;
computed ink-on-surface and brand-button text/background pairs >= 4.5:1
(same contrast helper as Phase 07).
2. ``test_no_emoji_in_chrome`` — neither page's ``innerText`` nor raw
``outerHTML`` contains any emoji code point.
3. ``test_animated_background`` — ``body::before``/``::after`` carry
background images AND run their animations by default (fixed,
pointer-events none).
4. ``test_reduced_motion_honored`` — a context with
``reduced_motion="reduce"`` → ``animation-name: none`` on both layers
(the static grid + glows remain).
5. ``test_behavior_unchanged_smoke`` — on-topic question streams an answer
+ a source chip + the send button recovers (state machine intact under
the new skin).
6. ``test_all_assets_local`` — every ``script[src]``/``link[href]`` on both
pages is same-origin or ``data:`` (no CDN).
"""
from __future__ import annotations
import asyncio
import re
import unicodedata
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import Browser, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
# Emoji code points banned from UI chrome (phase 08) — mirrors the
# integration guard in tests/integration/test_api.py.
EMOJI_RANGES = (
(0x1F300, 0x1FAFF), # symbols & pictographs (🧠 🧑 👋 📂)
(0x2600, 0x27BF), # misc symbols + dingbats (⚠)
(0x2B00, 0x2BFF), # misc symbols & arrows
)
EMOJI_SINGLETONS = frozenset({0xFE0F, 0x200D}) # VS-16, ZWJ
PREVIOUS_GLYPHS = "\U0001F9E0\U0001F9D1\U0001F44B\U0001F4C2\U000026A0"
def _find_emoji(text: str) -> list[str]:
"""Offending characters (with duplicates) in ``text`` — empty if clean."""
hits: list[str] = []
for ch in text:
cp = ord(ch)
if (
any(lo <= cp <= hi for lo, hi in EMOJI_RANGES)
or cp in EMOJI_SINGLETONS
or ch in PREVIOUS_GLYPHS
):
hits.append(ch)
return hits
# --------------------------------------------------------------------------
# KB seeding (same pattern as the earlier story suites)
# --------------------------------------------------------------------------
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _seed_kb(mock_port: int) -> ImportSummary:
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
summary = _run_in_thread(_import_fixtures(mock_port))
assert summary is not None and summary.added == 3
return summary
# --------------------------------------------------------------------------
# WCAG 2.1 contrast (same helper as Phase 07, test_responsive_polish.py)
# --------------------------------------------------------------------------
def _rgb(value: str) -> tuple[int, int, int]:
value = value.strip()
hex_match = re.match(r"^#([0-9a-f]{6})$", value, re.IGNORECASE)
if hex_match:
h = hex_match.group(1)
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
match = re.match(r"^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", value)
assert match, f"unparsable color: {value!r}"
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
def chan(c: int) -> float:
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = (chan(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(fg: str, bg: str) -> float:
l1, l2 = _rel_luminance(_rgb(fg)), _rel_luminance(_rgb(bg))
if l1 < l2:
l1, l2 = l2, l1
return (l1 + 0.05) / (l2 + 0.05)
def _assert_aa(pair: Any, label: str) -> None:
fg, bg = str(pair[0]), str(pair[1])
ratio = contrast_ratio(fg, bg)
assert ratio >= 4.5, f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)"
def _seconds(value: str) -> float:
"""Chromium reports animation durations as "60s" — parse as seconds."""
return float(str(value).replace("s", ""))
# --------------------------------------------------------------------------
# Tests (story → test mapping, see module docstring)
# --------------------------------------------------------------------------
def test_dark_palette_and_contrast(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC1: both pages are dark; the sampled text/background pairs compute
>= 4.5:1 from live computed styles (not eyeballed)."""
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
# The visible page background is the <html> canvas (rgb(10, 14, 23)
# = #0a0e17). <body> itself stays transparent so the z-index:-1
# grid/glow layers (test_animated_background) are not painted over.
bg = page.evaluate(
"() => getComputedStyle(document.documentElement).backgroundColor"
)
assert bg == "rgb(10, 14, 23)", f"expected the dark page bg on {path}, got {bg}"
body_bg = page.evaluate("() => getComputedStyle(document.body).backgroundColor")
assert body_bg == "rgba(0, 0, 0, 0)", (
f"{path}: body must stay transparent (the background layers need to show)"
)
# Chat page pairs.
page.goto(f"{app_url}/")
page.locator("#suggestions .suggestion-chip").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
const cs = (sel, prop) => getComputedStyle(document.querySelector(sel))[prop];
return {
ink_on_surface: [
cs(".empty-state-title", "color"),
cs(".empty-state", "backgroundColor"),
],
ink_soft_on_surface: [
cs(".empty-state-sub", "color"),
cs(".empty-state", "backgroundColor"),
],
button: [
cs(".send-btn", "color"), cs(".send-btn", "backgroundColor"),
],
chip: [
cs(".suggestion-chip", "color"),
cs(".suggestion-chip", "backgroundColor"),
],
};
}"""
)
_assert_aa(pairs["ink_on_surface"], "ink on surface (chat)")
_assert_aa(pairs["ink_soft_on_surface"], "ink-soft on surface (chat sub)")
_assert_aa(pairs["button"], "dark ink on brand (send button)")
_assert_aa(pairs["chip"], "chip ink on chip bg (chat)")
# Sources page pairs.
page.goto(f"{app_url}/sources.html")
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
const cs = (sel, prop) => getComputedStyle(document.querySelector(sel))[prop];
return {
ink_soft_on_surface: [
cs(".stat-label", "color"), cs(".stat-card", "backgroundColor"),
],
active_nav: [
cs(".nav-link.is-active", "color"),
cs(".nav-link.is-active", "backgroundColor"),
],
stat_value: [
cs(".stat-value", "color"), cs(".stat-card", "backgroundColor"),
],
};
}"""
)
_assert_aa(pairs["ink_soft_on_surface"], "ink-soft on surface (stat labels)")
_assert_aa(pairs["active_nav"], "dark ink on brand (active nav)")
_assert_aa(pairs["stat_value"], "brand-ink on surface (stat values)")
def test_no_emoji_in_chrome(page: Page, app_url: str, db_ready: None) -> None:
"""AC2: zero emoji in UI chrome — rendered text AND raw markup
(favicon, brand mark, avatars, banner icon, empty-state glyphs) on both
pages."""
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
page.wait_for_load_state("networkidle")
inner_text = page.evaluate("() => document.body.innerText")
outer_html = page.evaluate("() => document.documentElement.outerHTML")
for label, body in (("innerText", inner_text), ("outerHTML", outer_html)):
hits = _find_emoji(body)
named = [
f"U+{ord(ch):04X} {unicodedata.name(ch, '?')}" for ch in set(hits)
]
assert not hits, f"emoji in {label} on {path}: {sorted(named)}"
def test_animated_background(page: Page, app_url: str, db_ready: None) -> None:
"""AC3: the background is subtly animated, pure CSS, zero JS, and can
never block or dim content: both body pseudo-layers are fixed,
pointer-events:none, carry a background image and run their animation
by default (60s grid drift + 14s glow breathing)."""
page.goto(app_url)
report = page.evaluate(
"""() => {
const pick = (pseudo) => {
const cs = getComputedStyle(document.body, pseudo);
return {
image: cs.backgroundImage,
anim: cs.animationName,
duration: cs.animationDuration,
position: cs.position,
pointerEvents: cs.pointerEvents,
};
};
return { before: pick("::before"), after: pick("::after") };
}"""
)
for layer in ("before", "after"):
info = report[layer]
assert info["image"] != "none", f"body::{layer} must carry a background image"
assert info["anim"] not in ("", "none"), (
f"body::{layer} must animate by default (got {info['anim']!r})"
)
assert info["position"] == "fixed", f"body::{layer} must be position:fixed"
assert info["pointerEvents"] == "none", f"body::{layer} must not intercept input"
# The recipe: 60s seamless grid drift, 14s breathing glows.
assert _seconds(report["before"]["duration"]) == pytest.approx(60.0)
assert _seconds(report["after"]["duration"]) == pytest.approx(14.0)
def test_reduced_motion_honored(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC4: with prefers-reduced-motion: reduce both background layers stop
animating (animation-name: none) — the static grid + glows remain."""
context = browser.new_context(
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
)
try:
rpage = context.new_page()
rpage.goto(app_url)
report = rpage.evaluate(
"""() => {
const pick = (pseudo) => {
const cs = getComputedStyle(document.body, pseudo);
return { anim: cs.animationName, image: cs.backgroundImage };
};
return { before: pick("::before"), after: pick("::after") };
}"""
)
for layer in ("before", "after"):
assert report[layer]["anim"] == "none", (
f"body::{layer} must not animate under reduced motion"
)
assert report[layer]["image"] != "none", (
f"body::{layer}: the static background must remain visible"
)
finally:
context.close()
def test_behavior_unchanged_smoke(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC6: layout/behavior unchanged under the new skin — an on-topic
question streams a grounded answer, renders a source chip, and the send
button recovers (never stale)."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
expect(page.locator("#kb-banner")).to_be_hidden()
page.fill("#message-input", QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble")).to_contain_text(QUESTION)
bubble = page.locator(".msg.brain .bubble")
bubble.first.wait_for(state="visible", timeout=30_000)
expect(bubble.first).to_contain_text(QUESTION, timeout=30_000)
expect(bubble.first).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator(".msg.brain .source-chip", has_text="kubernetes.md")).to_have_count(1)
# The state machine settled: button re-enabled, label back to "Send".
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def test_all_assets_local(page: Page, app_url: str, db_ready: None) -> None:
"""AC5: no-CDN invariant on both pages — every script/link reference is
same-origin or a data: URI."""
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, f"expected local asset references on {path}"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), (
f"non-local asset reference on {path}: {ref}"
)
+3 -3
View File
@@ -101,7 +101,7 @@ def test_off_topic_question_deflects_honestly(
# Visually distinct from a normal answer (accent-bg / accent-line). # Visually distinct from a normal answer (accent-bg / accent-line).
style = bubble.evaluate("el => getComputedStyle(el)") style = bubble.evaluate("el => getComputedStyle(el)")
assert style["backgroundColor"] == "rgb(255, 247, 232)" # --accent-bg #fff7e8 assert style["backgroundColor"] == "rgb(43, 33, 16)" # --accent-bg #2b2110 (dark theme)
assert style["borderTopColor"] == "rgb(245, 158, 11)" # --accent-line #f59e0b assert style["borderTopColor"] == "rgb(245, 158, 11)" # --accent-line #f59e0b
# ≥2 "Maybe try:" chips below the bubble, in an accessible group. # ≥2 "Maybe try:" chips below the bubble, in an accessible group.
@@ -114,8 +114,8 @@ def test_off_topic_question_deflects_honestly(
expect(group.first).to_have_attribute("role", "list") expect(group.first).to_have_attribute("role", "list")
# Chip component contract: brand pill, ≥44px touch target. # Chip component contract: brand pill, ≥44px touch target.
chip_style = chips.first.evaluate("el => getComputedStyle(el)") chip_style = chips.first.evaluate("el => getComputedStyle(el)")
assert chip_style["backgroundColor"] == "rgb(238, 240, 254)" # --brand-soft assert chip_style["backgroundColor"] == "rgb(35, 43, 82)" # --brand-soft #232b52 (dark theme)
assert chip_style["color"] == "rgb(55, 48, 163)" # --brand-ink assert chip_style["color"] == "rgb(165, 180, 252)" # --brand-ink #a5b4fc
box = chips.first.bounding_box() box = chips.first.bounding_box()
assert box is not None and box["height"] >= 44 assert box is not None and box["height"] >= 44
+2 -2
View File
@@ -108,8 +108,8 @@ def test_onboarding_chips_render(
expect(page.locator("#empty-state")).to_be_visible() expect(page.locator("#empty-state")).to_be_visible()
# Chip component contract: brand pill, >=44px touch target. # Chip component contract: brand pill, >=44px touch target.
style = chips.first.evaluate("el => getComputedStyle(el)") style = chips.first.evaluate("el => getComputedStyle(el)")
assert style["backgroundColor"] == "rgb(238, 240, 254)" # --brand-soft assert style["backgroundColor"] == "rgb(35, 43, 82)" # --brand-soft #232b52 (dark theme)
assert style["color"] == "rgb(55, 48, 163)" # --brand-ink assert style["color"] == "rgb(165, 180, 252)" # --brand-ink #a5b4fc
assert style["borderRadius"] == "999px" assert style["borderRadius"] == "999px"
box = chips.first.bounding_box() box = chips.first.bounding_box()
assert box is not None and box["height"] >= 44 assert box is not None and box["height"] >= 44
+32
View File
@@ -70,6 +70,38 @@ def test_styles_and_js_served(client) -> None:
assert client.get("/assets/app.js").status_code == 200 assert client.get("/assets/app.js").status_code == 200
# Emoji code points banned from UI chrome (phase 08): the pictograph
# blocks, VS-16/ZWJ, plus the exact glyphs the old light theme used
# (🧠 🧑 👋 📂 ⚠).
_EMOJI_GLYPHS = "\U0001F9E0\U0001F9D1\U0001F44B\U0001F4C2\u26A0"
def _find_emoji(text: str) -> list[str]:
hits: list[str] = []
for ch in text:
cp = ord(ch)
if (
0x1F300 <= cp <= 0x1FAFF
or 0x2600 <= cp <= 0x27BF
or 0x2B00 <= cp <= 0x2BFF
or cp in (0xFE0F, 0x200D)
or ch in _EMOJI_GLYPHS
):
hits.append(ch)
return hits
@pytest.mark.parametrize(
"path", ["/", "/sources.html", "/assets/app.js", "/assets/styles.css"]
)
def test_ui_chrome_has_no_emoji(client, path: str) -> None:
"""Permanent regression guard (phase 08): the UI chrome — both pages,
the JS that renders it, and the stylesheet — is emoji-free."""
r = client.get(path)
assert r.status_code == 200
assert _find_emoji(r.text) == [], f"emoji found in {path}: {_find_emoji(r.text)!r}"
def test_chat_requires_message(client) -> None: def test_chat_requires_message(client) -> None:
r = client.post("/api/chat", json={"message": ""}) r = client.post("/api/chat", json={"message": ""})
assert r.status_code == 422 assert r.status_code == 422
+2 -2
View File
@@ -82,8 +82,8 @@ def test_reduced_motion_calm_not_removed() -> None:
def test_busy_button_style_tokens() -> None: def test_busy_button_style_tokens() -> None:
"""Story spec: busy send button is #a5b4fc with the 16px white-arc """Story spec: busy send button is #a5b4fc with the 16px dark-arc
spinner; the label swaps Send ↔ Thinking….""" spinner (--bg on #a5b4fc = 9.7:1, phase 08); label swaps Send ↔ Thinking…."""
css = _css() css = _css()
js = _js() js = _js()
assert ".send-btn:disabled" in css assert ".send-btn:disabled" in css