feat(ui): dark tech theme — emoji-free chrome, subtle animated CSS background, WCAG AA dark palette
This commit is contained in:
+58
-27
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
'<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 ---------- */
|
||||
function addMessage(who, html) {
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `msg ${who}`;
|
||||
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="bubble">${html}</div>
|
||||
</div>`;
|
||||
@@ -129,7 +140,7 @@ function addTyping() {
|
||||
wrap.className = "msg brain";
|
||||
wrap.id = "typing-indicator";
|
||||
wrap.innerHTML = `
|
||||
<span class="avatar" aria-hidden="true">🧠</span>
|
||||
<span class="avatar" aria-hidden="true">${BRAIN_AVATAR}</span>
|
||||
<div class="msg-body">
|
||||
<div class="bubble typing" role="status" aria-label="${TYPING_LABEL}">
|
||||
<span></span><span></span><span></span>
|
||||
|
||||
+130
-38
@@ -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 <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 {
|
||||
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; }
|
||||
|
||||
+6
-4
@@ -5,7 +5,7 @@
|
||||
<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.">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
@@ -14,7 +14,7 @@
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<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>
|
||||
<nav class="app-nav" aria-label="Primary">
|
||||
@@ -27,13 +27,15 @@
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<div class="container chat-shell" data-state="empty">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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-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>
|
||||
<p class="empty-state-sub">
|
||||
I've read through the homelab and deployment notes — ask me anything,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Documents indexed in Brain of Reese.">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
@@ -14,7 +14,7 @@
|
||||
<header class="app-header">
|
||||
<div class="container header-inner">
|
||||
<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>
|
||||
<nav class="app-nav" aria-label="Primary">
|
||||
@@ -66,7 +66,9 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p class="empty-state-sub">
|
||||
Run the import to pull in the markdown docs:
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -101,7 +101,7 @@ def test_off_topic_question_deflects_honestly(
|
||||
|
||||
# Visually distinct from a normal answer (accent-bg / accent-line).
|
||||
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
|
||||
|
||||
# ≥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")
|
||||
# Chip component contract: brand pill, ≥44px touch target.
|
||||
chip_style = chips.first.evaluate("el => getComputedStyle(el)")
|
||||
assert chip_style["backgroundColor"] == "rgb(238, 240, 254)" # --brand-soft
|
||||
assert chip_style["color"] == "rgb(55, 48, 163)" # --brand-ink
|
||||
assert chip_style["backgroundColor"] == "rgb(35, 43, 82)" # --brand-soft #232b52 (dark theme)
|
||||
assert chip_style["color"] == "rgb(165, 180, 252)" # --brand-ink #a5b4fc
|
||||
box = chips.first.bounding_box()
|
||||
assert box is not None and box["height"] >= 44
|
||||
|
||||
|
||||
@@ -108,8 +108,8 @@ def test_onboarding_chips_render(
|
||||
expect(page.locator("#empty-state")).to_be_visible()
|
||||
# Chip component contract: brand pill, >=44px touch target.
|
||||
style = chips.first.evaluate("el => getComputedStyle(el)")
|
||||
assert style["backgroundColor"] == "rgb(238, 240, 254)" # --brand-soft
|
||||
assert style["color"] == "rgb(55, 48, 163)" # --brand-ink
|
||||
assert style["backgroundColor"] == "rgb(35, 43, 82)" # --brand-soft #232b52 (dark theme)
|
||||
assert style["color"] == "rgb(165, 180, 252)" # --brand-ink #a5b4fc
|
||||
assert style["borderRadius"] == "999px"
|
||||
box = chips.first.bounding_box()
|
||||
assert box is not None and box["height"] >= 44
|
||||
|
||||
@@ -70,6 +70,38 @@ def test_styles_and_js_served(client) -> None:
|
||||
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:
|
||||
r = client.post("/api/chat", json={"message": ""})
|
||||
assert r.status_code == 422
|
||||
|
||||
@@ -82,8 +82,8 @@ def test_reduced_motion_calm_not_removed() -> None:
|
||||
|
||||
|
||||
def test_busy_button_style_tokens() -> None:
|
||||
"""Story spec: busy send button is #a5b4fc with the 16px white-arc
|
||||
spinner; the label swaps Send ↔ Thinking…."""
|
||||
"""Story spec: busy send button is #a5b4fc with the 16px dark-arc
|
||||
spinner (--bg on #a5b4fc = 9.7:1, phase 08); label swaps Send ↔ Thinking…."""
|
||||
css = _css()
|
||||
js = _js()
|
||||
assert ".send-btn:disabled" in css
|
||||
|
||||
Reference in New Issue
Block a user