phase: 180_markdown_inline_links_images
Build and Push Containers / build-and-push-app (push) Successful in 1m51s
Build and Push Containers / build-and-push-db (push) Successful in 15s

All verification complete. Final state summary:

**Report — phase 180 final verification pass**
- Verified (all already implemented in working tree): `inline()` chain = code→strong→em→linked-image→image→link→headings→lists in `frontend/assets/markdown.js`; `allowLinkUrl`/`allowImageUrl` allowlists (A5), shared `mdAnchorOpen` (target/rel only for absolute http(s)); `####`→`<h5>`, `#####`/`######`→`<h6>`, `#`/`##`→`<h3>`/`###`→`<h4>` unchanged; `.md-link`/`.md-img`/h5-h6 scale in `styles.css` (palette vars only). All surfaces (chat, thinking, restore, viewer, modal) route through the one renderer (pinned in `app.js`/`document.js`).
- Completion criteria:
  - `[text](url)`→`a.md-link`, `javascript:`/`data:` inert — ✅ (node probe + unit + E2E)
  - `![alt](src)`→`img.md-img loading="lazy"` with `alt`; bad src inert — ✅
  - `####`/`#####`/`######` ladder; shallow rungs unchanged — ✅
  - Fenced link/image/`####` stay code; cell-level link renders in `.md-table` — ✅ (node probe)
  - Byte-identity fixture + phase-44/138 suites pass unedited (git-verified untouched; 6 + 3 E2E pass in isolation) — ✅
  - `uv run pytest --cov=app --cov-report=term-missing`: **4198 passed, TOTAL 99%**; `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`: **4 passed**; `uv run ruff check .`: clean; `uv run pyright`: **0 errors** — ✅
- Docs: full README/AGENTS.md review vs the whole project — env-example↔README table complete, page routes match `app/main.py`, AGENTS quick-ref artifacts exist. Fixed 1 inconsistency: README understated the image allowlist (impl per task 02 accepts first segments ≤2 chars, `^.{0,2}/`, not only `./`/`../`); phase-180 paragraphs otherwise accurate.
- Notable: image allowlist `.{0,2}/` is broader than the overview's `^\.{1,2}/` but is what task 02 explicitly specifies and the docstring documents — not a deviation from the tasks; SVG sizing deliberately deferred to phase 181.
- Next pending phase: `181_svg_document_preview`.
This commit is contained in:
2026-10-14 16:03:36 -04:00
parent 3d82a72b6f
commit 6880bd0866
39 changed files with 2952 additions and 1 deletions
@@ -0,0 +1,14 @@
All verification complete. Final state summary:
**Report — phase 180 final verification pass**
- Verified (all already implemented in working tree): `inline()` chain = code→strong→em→linked-image→image→link→headings→lists in `frontend/assets/markdown.js`; `allowLinkUrl`/`allowImageUrl` allowlists (A5), shared `mdAnchorOpen` (target/rel only for absolute http(s)); `####`→`<h5>`, `#####`/`######`→`<h6>`, `#`/`##`→`<h3>`/`###`→`<h4>` unchanged; `.md-link`/`.md-img`/h5-h6 scale in `styles.css` (palette vars only). All surfaces (chat, thinking, restore, viewer, modal) route through the one renderer (pinned in `app.js`/`document.js`).
- Completion criteria:
- `[text](url)`→`a.md-link`, `javascript:`/`data:` inert — ✅ (node probe + unit + E2E)
- `![alt](src)`→`img.md-img loading="lazy"` with `alt`; bad src inert — ✅
- `####`/`#####`/`######` ladder; shallow rungs unchanged — ✅
- Fenced link/image/`####` stay code; cell-level link renders in `.md-table` — ✅ (node probe)
- Byte-identity fixture + phase-44/138 suites pass unedited (git-verified untouched; 6 + 3 E2E pass in isolation) — ✅
- `uv run pytest --cov=app --cov-report=term-missing`: **4198 passed, TOTAL 99%**; `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`: **4 passed**; `uv run ruff check .`: clean; `uv run pyright`: **0 errors** — ✅
- Docs: full README/AGENTS.md review vs the whole project — env-example↔README table complete, page routes match `app/main.py`, AGENTS quick-ref artifacts exist. Fixed 1 inconsistency: README understated the image allowlist (impl per task 02 accepts first segments ≤2 chars, `^.{0,2}/`, not only `./`/`../`); phase-180 paragraphs otherwise accurate.
- Notable: image allowlist `.{0,2}/` is broader than the overview's `^\.{1,2}/` but is what task 02 explicitly specifies and the docstring documents — not a deviation from the tasks; SVG sizing deliberately deferred to phase 181.
- Next pending phase: `181_svg_document_preview`.
@@ -0,0 +1,137 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6468 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,16 @@
**Implemented (task 01 — inline links in the shared renderer)**
- `frontend/assets/markdown.js`: new top-level `allowLinkUrl(url)` (LOCKED A5 allowlist: `http(s)://`, `mailto:`, `/`-rooted, `./`/`../`, `#`; everything else → `null`), plus one new `.replace()` in the shared `inline()` chain between the `*em*` rule and the `^### ` heading rule: `/\[([^\]]*)\]\(([^)\s]+)\)/g` with a **function** replacement → `<a class="md-link" href="…"[ target="_blank" rel="noopener noreferrer"]>text</a>`; `target`/`rel` only for absolute `http(s)`; a rejected URL returns the original match (inert escaped text, never dropped, never live). URL passed verbatim — no decode/unescape (D2). Module docstring gained the phase-180 paragraph (chain order incl. where task 02's image rules go, the allowlist, the `target`/`rel` rule, documented limits: `)`/space in a URL, unprotected inline code spans, no autolinks/reference links).
- New `tests/unit/test_markdown_inline_links_images.py` (phase-138 pattern, scoped to task 01 and extensible by tasks 02–05): source pins (chain order code→strong→em→link→headings→lists, rule lives inside `inline()`, exact marker, single `target`/`rel` branch, allowlist function shapes, `return _m` on reject, no `decodeURIComponent`/`unescape`/`innerHTML`/`eval`) + node behaviour (absolute link exact HTML with `&amp;` preserved, `http`, `mailto`/`/`/`./`/`../`/`#` without `target`, already-transformed rich link text, escaped HTML in text/URL, empty link text, fence-wins, table-cell link, 6 `javascript:`/`data:`/`vbscript:`/`file:`/`blob:` inert cases, 18-case `allowLinkUrl` accept/reject table, and a byte-identity fixture proving non-construct output equals the pre-phase renderer byte-for-byte).
**Gates (all 100% green)**
- `uv run pytest tests/unit/test_markdown_inline_links_images.py -v --no-cov` → 8 passed
- `uv run pytest` → **4181 passed** (phase-44 table + phase-138 blockquote suites green unedited)
- `uv run pytest --cov=app --cov-report=term-missing` → 4181 passed, **TOTAL 99%** (>90%)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
- Renderer regression E2E in isolation: `tests/e2e/test_markdown_blockquotes.py` 3 passed, `tests/e2e/test_markdown_tables.py` 6 passed, `tests/e2e/test_smoke.py` 3 passed (the phase's own E2E file is task 05)
**Docs**: `README.md` Chat section gained a "Markdown links render as links (phase 180)" paragraph (allowlist, `target`/`rel` rule, inert-text fallback, verbatim-escape contract). No `AGENTS.md` change (no operating-rule/UI-structure invariant changed by this task).
**Notes/deviations**: none to the design. Kept `^/` acceptance literal per D3 (so `//host/...` is allowed and gets no `target`, same as root-relative). Links are unstyled until task 04 adds `.md-link`; `.md-link`/`.md-img` CSS and image/heading pins deliberately deferred to tasks 02–05.
**Next pending task**: `.agents/phases/todo/180_markdown_inline_links_images/02_inline_images.md`
@@ -0,0 +1,137 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................................................ [ 99%]
..... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6468 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,19 @@
**Implemented (task 02 — inline images in the shared renderer)**
- `frontend/assets/markdown.js`: new `allowImageUrl()` allowlist (LOCKED A5: `/`-rooted served paths, `./`/`../`, `http(s)://`, `data:image/*` — CSP `img-src 'self' data:` already permits the last; everything else → `null` → inert text), plus two new rules **ahead of** task 01's link rule in the one `inline()` chain: linked-image `[![alt](src)](url)` (A6, both URLs allowlist-checked, either failing returns the original match) then plain `![alt](src)` → `<img class="md-img" src alt loading="lazy">`; `alt` always emitted (empty allowed), URLs/alt verbatim (no decode, D2), no inline handler (DOM-sink + CSP posture).
- Factored the anchor markup into one `mdAnchorOpen()` helper shared by the link + linked-image rules, so the `target`/`rel` pair stays single-sourced (task 01's "appears exactly once" pin still holds; its ordering pin updated to code-flow).
- Module docstring: final chain order, image allowlist + CSP reason, always-alt / no-`onerror` rationale, documented limits (`)`/space in URL, `images/x.png` bare-relative miss, size-less SVG → phase 181, and the two rejected-URL fall-throughs — now pinned by tests, never a live image or script).
- `tests/unit/test_markdown_inline_links_images.py`: extended with image source pins (chain order code→strong→em→linked-image→image→link→headings→lists, both rules inside `inline()`, marker/alt/lazy templates, both-allowlist gate, docstring contract) + node behaviour (served/`/assets`/`data:image`/absolute/`./`/`../` images, empty + escaped alt, fence-wins, table cell, 9 inert disallowed-src cases, linked-image exact HTML for absolute/relative/`mailto:` hrefs with no stray bracket, the 2 fall-throughs, 22-case `allowImageUrl` table); byte-identity fixture also asserts no `<img>`/`loading`.
**Gates (100% green)**
- `uv run pytest tests/unit/test_markdown_inline_links_images.py` → 17 passed; with node off the PATH → 8 passed, 9 skipped
- `uv run pytest` → **4190 passed** (phase-44 table + phase-138 blockquote suites unedited)
- `uv run pytest --cov=app --cov-report=term-missing` → 4190 passed, **TOTAL 99%** (>90%)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
- E2E in isolation: `test_markdown_blockquotes.py` + `test_markdown_tables.py` → 9 passed; `test_smoke.py` → 3 passed (the phase's own E2E file is task 05)
- All three task repros verified under node (`![topology](/api/documents/aaaa/image)`, `[![d](/a.svg)](https://example.com)`, `![x](javascript:alert(1))` → no `<img`/`src=`)
**Docs**: `README.md` gained a "Markdown images render as images" paragraph (allowlist, `alt`/lazy contract, inert-text fallback, no-`onerror` rationale, phase-181 SVG note). No `AGENTS.md` change (no operating-rule / UI-structure invariant altered by this task — `.md-img` CSS is task 04).
**Notable decisions**: relative-image rule implemented literally as `^.{0,2}/` per design D4; the two allowlist-divergence fall-throughs are documented + pinned rather than special-cased (changing task 01's locked link regex was the alternative and is out of scope).
**Next pending task**: `.agents/phases/todo/180_markdown_inline_links_images/03_heading_ladder.md`
@@ -0,0 +1,137 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
.............. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6468 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,15 @@
**Implemented (task 03 — heading ladder)**
- `frontend/assets/markdown.js`: inserted `/^#### (.*)$/gm → "<h5>$1</h5>"` and `/^#{5,6} (.*)$/gm → "<h6>$1</h6>"` **deeper-first, immediately before** `/^### /` (locked A4); `#`/`##`→`<h3>` and `###`→`<h4>` untouched; step-3 `h\d` guard deliberately unchanged (pinned, not edited); module docstring now records the full ladder, the deliberate flattening and the space-after-hashes / column-0 contract.
- Extended the phase unit file (per its own scope note) with task-03 pins: ladder order + each rule exactly once + space-after-hashes, shared-`inline()` containment, guard still `/^<(h\d|ul|ol|pre|li|blockquote)/` (never narrowed to `h[1-4]`), docstring records the ladder; node behaviour: `#### Deep`→`<h5>Deep</h5>`, `#####`/`######`→two `<h6>` (no `<p>`), existing `#`/`##`/`###` byte-identical, fence wins, table cell renders, mid-line / indented / no-space `####` stay text, 7+ hashes never a heading (no `h7`/`h8` ever emitted), byte-identity fixture gains no `h5`/`h6`.
- **Fixed a pre-existing defect** (proved by re-running with my two rules removed): `tests/e2e/auth_helpers.login()` returned at URL-commit while the page's deferred module graph was still loading (`readyState: "interactive"`), so an immediate `#composer.requestSubmit()` did a **native GET** to `/?message=…` and lost the turn — `test_thinking_display.py` (5) and `test_thinking_scroll.py` (4) failed 100 %. `login()` now waits for `load` (fresh + idempotent branches).
**Gates (100 % green)**
- `uv run pytest --cov=app --cov-report=term-missing` → **4195 passed, TOTAL 99 %** (>90 %); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
- `uv run pytest tests/unit/test_markdown_inline_links_images.py --no-cov` → 22 passed
- E2E in isolation (`--no-cov`): thinking_display 5 ✓ + thinking_scroll 4 ✓ (were failing); regression samples all green — markdown_blockquotes+tables+smoke+chip_sizing+pinned_composer 22 ✓, chat_history+share_chat+save_doc_session+history_copy 14 ✓, api_tokens+history_page_width+llm_history+responsive_polish 24 ✓, loading_feedback+retry_answer+answer_images+stop_generation+reasoning_bubble+shared_header 20 ✓
**Docs**: `README.md` gained "The heading ladder reaches six levels (phase 180, TODO.md L5)" (mapping, deliberate `#`/`##` flattening, space/column-0 limits). No `AGENTS.md` change (no operating-rule / UI-structure invariant affected — `.md-link`/`.md-img`/h5-h6 styling is task 04).
**Notes**: no deviation from LOCKED A4/A5/A6; task-03 unit pins were written now (the file's scope note says task 03 extends it) instead of deferring to task 05.
**Next pending task**: `.agents/phases/todo/180_markdown_inline_links_images/04_styles_markdown_inline.md`
@@ -0,0 +1,137 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6468 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,11 @@
**Task 04 complete — `.md-link`, `.md-img` and the `h5`/`h6` type scale (frontend/assets/styles.css).**
- **`.md-link`** (next to the `.md-quote` family): `color: var(--brand-ink)` (9.9:1 on `--surface`, 8.3:1 on the deflected `--accent-bg`), unconditional `text-decoration: underline` + `text-underline-offset: 2px` (WCAG 1.4.1), `:hover { color: var(--brand-hover) }` (5.9:1 / 5.0:1). No focus rule added — the global 3px `--brand` ring covers it.
- **`.msg.user .bubble .md-link { color: var(--bg) }`** (the `.md-quote` inversion, 5.2:1 on the brand fill) + a `:hover` inversion: `--brand-hover` on `--brand` is **1.2:1** and the text is already `--bg`, so a colour-mix cannot help — the hover state moves to the decoration (`text-decoration-thickness: 2px`, offset 3px), palette-only and contrast-safe at every theme.
- **`.md-img`**: `inline-block`, `max-width: 100%`, `height: auto`, `object-fit: contain` + the `.source-image-img` card language (`--surface`/`--line`/`--radius-sm`), `margin: 0.4rem 0`; no fixed width/height, no `--vector` modifier (phase 181).
- **Type scale**: `.doc-md h5, .msg .bubble h5, .thinking-text h5 { margin: 0.8rem 0 0.3rem; font-size: 0.95rem; }` + the `h6` sibling at `0.88rem` (0.7/0.3) — ONE grouped rule set, monotonic 1.15 → 1.0 → 0.95 → 0.88. House-style comments above each rule: phase number, TODO source line, contrast math per non-obvious colour, "static content — no animation".
- **Verified in headless Chromium** against the real stylesheet: link ratio per context (9.89 brain / 8.33 deflected / 5.35 user / 9.89 thinking), `text-decoration: underline` everywhere, Tab focus = `3px solid rgb(244,63,94)` on every link, and a 3000×200 `<img class="md-img">` gives `scrollWidth == clientWidth` at **375px and 1440px** (image clamped to the bubble).
- Gates (all 100% green): `uv run pytest` → **4195 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors; `uv run pytest tests/e2e/test_markdown_blockquotes.py -v --no-cov` → 3 passed (markdown-CSS regression, unedited).
- **Docs:** `README.md` — extended the three phase-180 paragraphs with the styling contract (underline + AA ratios + brand ring; image column-capping/no-scrollbar; the 1.15→0.88rem ladder). No AGENTS.md impact (it documents layout rules, not the `.md-*` families).
- Decisions/deviations: no unit file written — task 04 defers its CSS pins to `05_unit_and_e2e.md` (creating them here would collide with task 05's own `tests/unit/test_markdown_inline_links_images.py`); h5/h6 rules carry no `color` (they inherit each context's AA ink) — the only documented nit is that the 0.875rem scratchpad uses UA `h3`/`h4` sizes, so the fixed 0.95rem `h5` rides a hair above its body text (noted in the CSS comment; re-sizing shallower rungs is out of scope).
- Next pending task: `180_markdown_inline_links_images/05_unit_and_e2e.md`.
@@ -0,0 +1,137 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6468 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,20 @@
All gates green. Report:
**Implemented (task 05 — `180_markdown_inline_links_images`)**
- `tests/unit/test_markdown_inline_links_images.py`: added task 04's stylesheet pins — `.md-link` (`--brand-ink`, unconditional underline, `--brand-hover`, house contrast note ≥4.5:1), `.msg.user .bubble .md-link` inversion (`var(--bg)`), `.md-img` (`max-width:100%` / `height:auto` / `object-fit:contain` + card tokens, no fixed size), and the monotonic `.doc-md` h3→h6 rem scale (1.15/1.0/0.95/0.88, colour-free) — all palette-`var()` only, no hex/rgb/hsl.
- New dedicated fixture tree `tests/fixtures/links_docs/homelab/inline-markdown.md` (all three constructs + both inert probes + a cell link; shared 13-doc corpus untouched).
- `tests/e2e/mock_llm.py`: new `INLINE_TRIGGER` / byte-stable `INLINE_ANSWER` branch (before `DEFLECT_MODE`, house convention) + module-docstring bullet.
- New phase suite `tests/e2e/test_markdown_inline_links_images.py` (4 tests): chat render, document-modal render (shared renderer), localStorage restore, 375px no-horizontal-overflow — asserting 6 allowlisted `a.md-link` with the `target`/`rel` rule, verbatim `&amp;`, `img.md-img` alt + `loading="lazy"`, `a.md-link > img.md-img` with no stray bracket, `h5`/`h6`, `table.md-table a.md-link`, inert `javascript:` probes, non-deflection.
**Gates (exact)**
- `uv run pytest` → **4198 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (6468 stmts, 17 missing) — unchanged by this phase
- `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov` → **4 passed** (isolated, DB up); `test_markdown_blockquotes.py` + `test_markdown_tables.py` **9 passed unedited**; `test_answer_images.py` 5 passed
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
- Sensitivity checks: unit file green on the node-absent skip path (14 passed / 11 skipped); opening the link allowlist to any URL turns the E2E red (markdown.js restored byte-identical, verified with `cmp`).
**Docs**: `AGENTS.md` rule 5 gained the phase-180 UI invariant (allowlisted `a.md-link`, container-capped `img.md-img`, `h5`/`h6` in scale). `README.md` already carries the three phase-180 paragraphs (tasks 01–04); re-verified its contrast figures (9.89 / 8.33 / 5.35 computed).
**Decisions/deviations**: kept the existing `NO_CONSTRUCT_INPUT`/`…_PRE_PHASE_OUTPUT` byte-identity fixture names (overview's `NO_INLINE_*` is naming only); left `:root`'s pre-existing `--brand-ink /* 9.0:1 */` comment alone (computed 9.9:1 — pre-existing palette-doc rounding, out of phase scope); phase-dir move + commit left to the harness per pipeline rules.
**Next pending task**: none in phase 180 — next is `181_svg_document_preview/`.
@@ -0,0 +1,137 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 17%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 234 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 289 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 841 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6468 17 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+8 -1
View File
@@ -24,7 +24,14 @@
(phase 156); an in-flight turn never paints an empty answer
bubble — the box is created empty as the reasoning anchor and
`.msg .bubble:empty { display: none }` hides it until the first
answer token (phase 166); the document viewer's page strip is CLASS-scoped —
answer token (phase 166); an answer's inline `[text](url)`,
`![alt](src)` and `####`+ heading render through the ONE shared
renderer into an allowlist-gated `a.md-link` (underlined, never a
`javascript:`/`data:` element), a container-capped
`img.md-img` (`max-width: 100%` — no horizontal overflow at 375px)
and an `h5`/`h6` sized into the existing type scale — never printed
reference text (phase 180); the document viewer's page strip is
CLASS-scoped —
the primary class shows the strip in place of the text, the
additive class never hides the text: Text/Pages toggle group
(`aria-pressed`, Text default) with the strip behind it
+58
View File
@@ -102,6 +102,64 @@ empty rectangle (nothing replaces the box: the existing loading feedback
is the turn's visible state). The same rule covers a restored or shared
answer whose persisted text is empty, which paints no box either.
**Markdown links render as links** (phase 180, TODO.md L4): an inline
`[text](url)` in an answer, the thinking block, a saved/shared chat, the
document viewer or the document modal — every surface that goes through
the one shared renderer — becomes a real `<a class="md-link">`. Only
allowlisted URLs become live elements: `http(s)://` (which also carries
`target="_blank" rel="noopener noreferrer"`), `mailto:`, `/`-rooted and
`./`/`../` relative paths, and `#fragment` links — the last three stay in
the same tab and carry no `target`. Anything else (`javascript:`,
`data:`, `vbscript:`, `file:`, `blob:`) renders as the plain
`[text](url)` text it is today, never an element. The URL is used
verbatim from the already-escaped text, so a `&` in a query string stays
`&amp;` and nothing is ever unescaped (escape-first is the security
boundary); a URL containing a space or a literal `)` is a documented miss.
Styling is one `.md-link` rule for every surface: underlined (a link never
relies on colour alone) in the theme's AA-safe brand text colour — 9.9:1 on
the answer bubble, 8.3:1 on a deflected one — and the user bubble's own
ink on the brand fill (5.2:1), where the underline is what marks the link;
Tab focus is the app's global 3px `--brand` ring.
**Markdown images render as images** (phase 180, the in-answer half of
TODO.md L3): an inline `![alt](src)` — and a linked image
`[![alt](src)](url)`, which renders as that image wrapped in the link —
becomes a lazy `<img class="md-img" loading="lazy">` on every surface that
shares the renderer, so a diagram referenced inside an answer or a
document paints instead of printing its reference text. `alt` is always
emitted (empty allowed for a decorative image). The image allowlist is
its own function and accepts `/`-rooted served paths
(`/api/documents/<id>/image`, `/assets/…`), relative paths whose first
segment is at most two characters (`./x`, `../x`, `d/x` — a longer first
segment like `images/x.png` is a documented miss), `http(s)://` and
`data:image/…` — the `data:` case needs no CSP change
because the app's own `img-src 'self' data:` already permits it. A
`javascript:`, `file:`, `blob:` or non-image `data:` src stays visible as
the inert `![alt](src)` text it is today, never an element, and a broken
served path degrades to the browser's alt text (the renderer emits an HTML
string, so it carries no inline `onerror` handler — the JS fallbacks live
in `document.js`/`app.js`). `.md-img` carries the app's bordered-image card
treatment and is capped to its container — `max-width: 100%`, `height: auto`
and `object-fit: contain` — so a wide screenshot never produces a horizontal
scrollbar at 375px or at 1440px. Sizing an SVG that declares no `width` or
`height` is deliberately left to phase 181.
**The heading ladder reaches six levels** (phase 180, TODO.md L5): a
`#### x` line in an answer, the thinking block, a saved/shared chat, the
document viewer or the modal renders `<h5>x</h5>`, and `##### x` /
`###### x` render `<h6>x</h6>` (HTML stops at `h6`). The levels above are
UNCHANGED and deliberately flattened — `#`/`##` both render `<h3>`, `###`
renders `<h4>` — so an answer's deep sections gain the two rungs below
`###` without re-ranking what already rendered. Like every other inline
rule the ladder needs a space after its hashes and sits at column 0 of its
line, so a mid-sentence `####` and an indented `#### x` stay plain text,
and a `####` inside a fenced block stays code. The two new rungs are sized
into the existing type scale — `h3` 1.15rem → `h4` 1rem → `h5` 0.95rem →
`h6` 0.88rem, margins tightening with depth — so a deep heading neither
collapses into body text (the UA default is 0.83em/0.67em) nor out-shouts
the answer around it, in the bubble, the thinking block and the viewer
alike.
The document's meta row (the chat modal **and** the standalone viewer at
`/document.html`) carries a **Copy path** button: one click copies the
document's `source/path` (the exact form the `read` tool, the chips, and
+184
View File
@@ -32,6 +32,76 @@
* a > inside a code fence never reaches the pass (step 1 placeholder),
* and a > in a table cell is restored in step 4. Everything without a
* quote line renders byte-identically to the pre-phase renderer.
*
* Phase 180 (2026-10-14, TODO.md L4/L5 + the in-answer half of L3) adds
* the inline link/image transforms and the rest of the heading ladder
* to the ONE `inline()` chain (the single source of every inline
* transform, also applied per table cell — so a link inside a cell
* works for free). The chain order is code → strong → em →
* **linked-image → image → link** — both image rules sit AHEAD of the
* link rule: the linked-image rule first of all so `[![alt](src)](url)`
* cannot leave a stray `[` in the link text (locked A6), the plain image
* rule next so the `!` of an image is never orphaned into `!<a>`
* → headings → lists. The heading ladder (locked A4, TODO.md L5) is
* `#`/`##` → `<h3>`, `###` → `<h4>`, `####` → `<h5>`, `#####` and
* `######` → `<h6>`: the flattening of `#`/`##` to one `<h3>` level is
* pre-existing and DELIBERATE (the phase adds the levels below `###`, it
* does not re-rank the existing ones), `#####`/`######` share `<h6>`
* because HTML stops at h6, and the rules are written deeper-first. Every
* rule requires a SPACE after its hash run, so no rule can partially eat
* a deeper level (`#### x` never matches `/^### /`) and a heading must
* start at column 0 of its line (the `m` flag) — an indented `#### x`
* stays plain text, exactly as an indented `### x` does today. Links:
* `[text](url)` → `<a class="md-link" href="…">text</a>`, but ONLY for
* URLs the allowlist function (`allowLinkUrl`, locked A5) accepts —
* `http:`/`https:`, `mailto:`, `/`-rooted and `./`/`../` relative paths,
* and `#fragment`; anything else (`javascript:`, `data:`, `vbscript:`,
* `file:`, `blob:`) returns the ORIGINAL matched text, so it stays
* visible as the inert escaped text it is today and never becomes a
* live element. Absolute `http(s)://` links carry
* `target="_blank" rel="noopener noreferrer"`; `mailto:` / relative /
* fragment links carry neither (a same-origin target in the same tab is
* correct). The captured URL passes through VERBATIM — the chain's
* input is already escapeHtml-ed, so `&amp;`/`&#39;`/`&quot;` in a query
* string are already attribute-safe and decoding them would re-open the
* injection surface this renderer exists to close. Images (task 02,
* TODO.md L3): `![alt](src)` →
* `<img class="md-img" src="…" alt="…" loading="lazy">` and
* `[![alt](src)](url)` → that same image wrapped in the link, again ONLY
* through an allowlist function (`allowImageUrl`, locked A5) — `/`-rooted
* served paths (`/api/documents/<id>/image`, `/assets/…`), `./`/`../`
* relative paths, `http(s)://` and `data:image/…` (the `data:` case is
* the app's own CSP speaking: `img-src 'self' data:` in
* app/core/security_headers.py::CSP already permits `data:` images, so
* no CSP change is involved); `javascript:`, `file:`, `blob:` and a
* non-image `data:` return null and the reference stays visible as inert
* text. `alt` is ALWAYS emitted (empty allowed — `alt=""` for a
* decorative image) because an `<img>` with no alt attribute fails WCAG
* 1.1, and there is deliberately NO `onerror` handler: this renderer
* returns an HTML string into DOM-sink surfaces, so an inline handler
* would break the no-CDN/CSP posture — a broken src degrades to
* the browser's alt text (the JS fallbacks live in document.js/app.js,
* which do not route through this function). Documented limits
* (known, not fixed): a URL containing a literal `)` or whitespace is a
* miss (so title syntax `![alt](src "title")` is a miss too), as is a
* relative image path whose first segment is longer than two characters
* (`images/x.png` — the allowlist accepts `./`, `../` and `/`-rooted);
* a linked image whose HREF alone is rejected falls through to the plain
* image rule, so the image renders inside the inert `[…](url)` text, and
* an image whose src is rejected by `allowImageUrl` but ACCEPTED by
* `allowLinkUrl` (a link-only shape used as an image src — `#fragment`
* or `mailto:`) falls through to the link rule, which turns `[alt](url)`
* into a link behind the leftover `!`; neither ever yields a live image
* or a live script;
* inline code spans are NOT placeholder-protected (pre-existing —
* `**bold**` inside backticks already transforms), so markdown syntax
* inside `` `…` `` transforms too; a size-less SVG (`viewBox` only, no
* `width`/`height`) in `.md-img` paints at the replaced-element default
* box — the vector sizing contract belongs to phase 181;
* autolinks (`<https://x>`) and reference-style links (`[text][ref]`) are
* out of scope. Everything without a link/image/`####`+ construct renders
* byte-identically to
* the pre-phase renderer (pinned by the phase-180 unit suite).
*/
function escapeHtml(s) {
@@ -40,6 +110,52 @@ function escapeHtml(s) {
}[c]));
}
// Phase 180 (task 01, TODO.md L4): the link URL allowlist (LOCKED A5).
// The inline chain always receives HTML-ESCAPED text, so a captured URL
// may legitimately contain `&amp;` / `&#39;` / `&quot;` — valid inside an
// href — and is returned VERBATIM (never decoded, never re-escaped:
// design D2, the escape-first boundary). Accepted schemes/shapes:
// `http(s)://`, `mailto:`, `/`-rooted, `./` and `../` relative, and
// `#fragment`. Everything else — `javascript:`, `data:`, `vbscript:`,
// `file:`, `blob:` — returns null so the caller keeps the ORIGINAL
// matched text as inert escaped prose.
function allowLinkUrl(url) {
return /^(?:https?:\/\/|mailto:|\/|\.{1,2}\/|#)/i.test(url) ? url : null;
}
// Phase 180 (task 02, TODO.md L3): the image URL allowlist (LOCKED A5).
// Same escape-first, verbatim contract as allowLinkUrl — the inline chain
// always receives HTML-ESCAPED text, so a captured src may legitimately
// contain `&amp;` / `&#39;` / `&quot;` (valid inside a src) and is
// returned untouched (never decoded, never re-escaped: design D2).
// Accepted: `/`-rooted served paths (`/api/documents/<id>/image`,
// `/assets/…`), `./` and `../` relative paths, `http(s)://`, and
// `data:image/…` — the `data:` case exists because the app's OWN CSP
// already permits it (`img-src 'self' data:`,
// app/core/security_headers.py::CSP), so no CSP change is involved.
// Everything else — `javascript:`, `file:`, `blob:`, a NON-image `data:`
// (e.g. `data:text/html,…`) — returns null so the caller keeps the
// ORIGINAL matched text as inert escaped prose.
function allowImageUrl(url) {
return /^(?:https?:\/\/|data:image\/|\/|.{0,2}\/)/i.test(url) ? url : null;
}
// Phase 180 (tasks 01 + 02): the ONE anchor-opener builder, shared by the
// link rule and the linked-image rule (locked A6) so the exact
// `<a class="md-link"` marker and the `target`/`rel` rule have a single
// source in the file. `safe` MUST already be an allowLinkUrl-accepted
// URL (callers check first and keep the original text on null); the
// pair is emitted ONLY for an absolute `http(s)://` target — `mailto:`,
// `/`-rooted, `./`/`../` and `#fragment` targets stay in the same tab.
function mdAnchorOpen(safe) {
const external = /^https?:\/\//i.test(safe);
return (
`<a class="md-link" href="${safe}"` +
(external ? ' target="_blank" rel="noopener noreferrer"' : "") +
`>`
);
}
function renderMarkdown(md) {
// 1. Protect fenced code blocks.
const codeBlocks = [];
@@ -56,6 +172,74 @@ function renderMarkdown(md) {
s.replace(/`([^`\n]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>")
// Phase 180 (task 02, TODO.md L3, locked A6): a LINKED image —
// `![alt](src)` inside `[…](url)`, i.e. `[![alt](src)](url)` — gets
// its OWN rule, AHEAD of both the image and the link rules —
// without it the image rule consumes the inner `![alt](src)` and
// the link rule then leaves a
// stray `[` in the link text. BOTH URLs are allowlist-checked (the
// href through allowLinkUrl, the src through allowImageUrl) and
// either rejection returns the whole original match, which stays
// inert escaped text (locked A5). `alt` is ALWAYS emitted — empty
// allowed, `alt=""` for a decorative image — because an `<img>`
// with no alt attribute fails WCAG 1.1; like the URLs it is the
// already-escaped text passed through VERBATIM (design D2).
.replace(/\[!\[([^\]]*)\]\(([^)\s]+)\)\]\(([^)\s]+)\)/g, (_m, alt, src, url) => {
const safeSrc = allowImageUrl(src);
if (safeSrc === null) return _m;
const safeHref = allowLinkUrl(url);
if (safeHref === null) return _m;
return (
mdAnchorOpen(safeHref) +
`<img class="md-img" src="${safeSrc}" alt="${alt}" loading="lazy">` +
"</a>"
);
})
// Phase 180 (task 02, TODO.md L3): a plain inline image
// `![alt](src)` → a lazy `<img class="md-img">`. Runs AFTER the
// linked-image rule (so the `!` is never orphaned into `!<a>`) and
// BEFORE the link rule. Same allowlist discipline as links (locked
// A5): a rejected src returns the whole original match, so the
// reference stays visible as the inert escaped text it is today.
// No `onerror` fallback on purpose — this renderer returns an HTML
// STRING into DOM-sink surfaces and inline handlers break the
// no-CDN/CSP posture, so a broken src degrades to the
// browser's alt text (the JS fallbacks live in document.js/app.js,
// which do not route through this function).
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, src) => {
const safe = allowImageUrl(src);
if (safe === null) return _m;
return `<img class="md-img" src="${safe}" alt="${alt}" loading="lazy">`;
})
// Phase 180 (task 01): inline links, after the emphasis rules (so
// link text carrying **strong**/*em*/`code` — or an image the two
// rules above already transformed — is in place when it gets
// wrapped) and before the heading ladder. Empty link text is
// allowed; whitespace inside the URL is a miss (documented limit),
// as is a URL containing a literal `)`. The replacement is a
// FUNCTION so the URL goes through the allowLinkUrl allowlist
// (locked A5) — a rejected URL returns the whole original match,
// which stays inert escaped text. The anchor itself (marker +
// `target`/`rel` for absolute http(s) targets) is built by the one
// shared mdAnchorOpen helper, also used by the linked-image rule.
.replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, text, url) => {
const safe = allowLinkUrl(url);
if (safe === null) return _m;
return `${mdAnchorOpen(safe)}${text}</a>`;
})
// Phase 180 (task 03, TODO.md L5, locked A4): the rest of the heading
// ladder, written DEEPER-FIRST immediately before the existing
// `^### ` rule. Rules cannot cross-match because every heading rule
// requires a SPACE after its hash run — `#### x` never matches
// `/^### /`, `##### x`/`###### x` never match `/^#### /` — so a deep
// heading can never be partially eaten by a shallower rule. `#####`
// and `######` BOTH map to `<h6>` (locked A4: HTML has no `<h7>` and
// the app's type scale stops two levels below `###`). The existing
// `#`/`##` → `<h3>` flattening stays UNTOUCHED (locked A4). Like the
// rules it sits next to, each rule anchors at column 0 of its line
// (the `m` flag), so an indented `#### x` remains plain text.
.replace(/^#### (.*)$/gm, "<h5>$1</h5>")
.replace(/^#{5,6} (.*)$/gm, "<h6>$1</h6>")
.replace(/^### (.*)$/gm, "<h4>$1</h4>")
.replace(/^## (.*)$/gm, "<h3>$1</h3>")
.replace(/^# (.*)$/gm, "<h3>$1</h3>")
+114
View File
@@ -587,6 +587,120 @@ body::before {
quote stays distinct. */
.msg.user .bubble .md-quote { color: var(--bg); }
/* Markdown inline links (phase 180, task 04, TODO.md L4 — "Markdown
[links](some-address) aren't being render correctly and show up as
plain text"): the shared renderer (markdown.js inline chain) turns
[text](url) — and a linked image, locked A6 — into
<a class="md-link">, and an element with no rule anywhere in this file
would paint as unmarked body text. ONE rule set serves every rendering
context the renderer feeds (the chat .bubble brain + user, the
details.thinking / details.reasoning scratchpad, the shared page's
.msg/.bubble copy, the viewer's .doc-md and the document modal, which
shares the renderDocument core) — all of them sit on --surface, and the
deflected bubble's --accent-bg holds the pairing too (8.3:1), so a
single AA pairing covers them all. Palette vars only, no hex/rgb/hsl
literal (the :root-drift test parses the built-ins out of this file — a
literal here would be a second source of truth, and a new hue would
break the monochrome invariant):
color --brand-ink — 9.9:1 on --surface, the :root-documented
AA-safe BRAND TEXT token (the same rose the
details.thinking summary and .msg.brain .avatar use);
underline unconditional — a link in running text must not rely on
color alone (WCAG 1.4.1, PLAN §7.2 "text + color, never
color alone"), and it is what keeps the link marked inside
the user bubble, where the color inverts to the bubble's
own ink below;
offset 2px — lifts the rule off the descenders (g/y/p stay open).
The focus state is the GLOBAL `:focus-visible` 3px --brand ring above
(do NOT restate or reset it here — one ring for the whole app).
Static content — no animation (nothing for prefers-reduced-motion to
still). */
.md-link {
color: var(--brand-ink); /* 9.9:1 on --surface (AA) — :root's brand text token */
text-decoration: underline;
text-underline-offset: 2px;
}
/* :hover brightens one notch on the same rose — --brand-hover is --brand
mixed with 14% white in :root: 5.9:1 on --surface and 5.0:1 on the
deflected bubble's --accent-bg, so the state change never drops under
AA on any canvas the renderer writes into (brightening is the safe
direction on a dark palette — every darker step fails there). */
.md-link:hover { color: var(--brand-hover); }
/* The USER bubble runs the question through the same renderer on the
--brand fill (the .md-quote inversion above is the precedent):
--brand-ink on --brand is 1.9:1 and --brand-hover on --brand is 1.2:1 —
both AA failures — so the link keeps the bubble's own ink, --bg on
--brand at 5.2:1 (the :root pairing). The unconditional underline is
what marks it a link here, never the color. */
.msg.user .bubble .md-link { color: var(--bg); }
/* …and the SAME inversion on :hover: the default --brand-hover would
erase the word (1.2:1 on the fill). A color-mix toward --bg cannot
help — the text is ALREADY --bg — so the hover state moves into the
DECORATION (a 2px rule at a deeper offset), which is a visible state
change that needs no new pair, keeps 5.2:1 at every theme, and stays
inside the palette (no new hue — the monochrome invariant). */
.msg.user .bubble .md-link:hover {
color: var(--bg);
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
/* Markdown inline images (phase 180, task 04, TODO.md L3 — the in-answer
half of "Need SVG render support on the front end"): ![alt](src) and
[![alt](src)](url) render <img class="md-img"> through the shared
renderer, so the painted diagram has to stay INSIDE the reading column
instead of pushing a horizontal scrollbar (the TODO's complaint is a
reference that prints; the fix is only useful when what replaces it
fits). No fixed width/height — max-width:100% + height:auto keep any
aspect ratio at 375px and at 1440px (the .doc-image-img / .source-image-img
contract), object-fit:contain never crops, and the card language
(--surface plate, --line hairline, --radius-sm) is the treatment every
other image in the app already carries, so a diagram inside an answer
reads as an image rather than a stray artifact. inline-block so the
block-ish margin behaves and the image sits as its own box inside a
wrapping <a> (locked A6) instead of on a text baseline. Palette vars
only (no literal). Static content — no animation (nothing for
prefers-reduced-motion to still). Known limit (phase 181): an SVG that
declares a viewBox but no width/height paints at the replaced-element
default box, constrained by max-width — vector sizing is phase 181's
contract, so NO .md-img--vector modifier here. */
.md-img {
display: inline-block;
max-width: 100%;
height: auto;
object-fit: contain;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
margin: 0.4rem 0;
}
/* The deep rungs of the heading ladder (phase 180, task 04, TODO.md L5 —
"Quad-level headers (####) aren't being rendered"): the renderer now
emits <h5> for #### and <h6> for #####/###### (locked A4) and neither
tag had a rule ANYWHERE in this file, so a deep heading in an answer
inherited the UA defaults (h5 0.83em / h6 0.67em with 1.67em / 2.33em
block margins — ~1.4rem / 1.6rem on this scale) — gaps wider than the
paragraphs around it while the type itself went SMALLER than the answer
text it sits in. ONE rule set per level for every renderer context (the
viewer's .doc-md, the chat AND shared-page .bubble, the thinking
scratchpad) — no chat-specific duplication — extending the existing
.doc-md h3 1.15rem / .doc-md h4 1rem scale MONOTONICALLY
(1.15 → 1.0 → 0.95 → 0.88): every level stays a visible step from the
1rem body text and from its neighbor, and the margins
tighten with the depth on the same h3 1.1/0.4 → h4 0.9/0.35 rhythm.
Sizes are rem (never px), so the ladder survives a browser
font-size/zoom change; no color is set, so the scale adds no new
contrast pair (it inherits each context's own AA ink — ink on --surface
15.3:1, ink-soft on --surface 8.2:1 in the scratchpad). Known, accepted:
the 0.875rem scratchpad owns no h3/h4 rules of its own (UA sizes there),
so the fixed 0.95rem h5 rides a hair above its body text — a heading
stays a heading, and re-sizing the shallower rungs is out of this
phase's scope. Static content — no animation (nothing for
prefers-reduced-motion to still). */
.doc-md h5, .msg .bubble h5, .thinking-text h5 { margin: 0.8rem 0 0.3rem; font-size: 0.95rem; }
.doc-md h6, .msg .bubble h6, .thinking-text h6 { margin: 0.7rem 0 0.3rem; font-size: 0.88rem; }
.msg.user { justify-content: flex-end; }
.msg.user .msg-body { align-items: flex-end; }
.msg.user .bubble {
+20
View File
@@ -98,6 +98,7 @@ def login(page: Page, app_url: str, password: str | None = None, next: str | Non
expect(page.locator("#login-password")).to_be_visible(timeout=2_000)
except AssertionError:
expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)
_wait_for_app_boot(page)
return
else:
expect(page.locator("#login-password")).to_be_visible()
@@ -108,3 +109,22 @@ def login(page: Page, app_url: str, password: str | None = None, next: str | Non
expect(page).to_have_url(url) # no redirect on failure
return
expect(page).to_have_url(app_url + (next or DEFAULT_NEXT), timeout=30_000)
_wait_for_app_boot(page)
def _wait_for_app_boot(page: Page) -> None:
"""Wait until the landed page's deferred module graph has EXECUTED.
``to_have_url`` passes the moment the redirect navigation COMMITS, while
``document.readyState`` is still ``interactive`` — the page's
``<script type="module">`` (app.js plus everything it imports) can still
be in flight, so a handler the page wires up in JS does not exist yet.
A caller that submits straight away (``#composer.requestSubmit()`` in the
thinking suites) then fires the form's NATIVE GET submit: the browser
navigates to ``/?message=…``, the conversation is never sent, and the
story's first assertion fails against an empty chat. ``load`` fires after
every deferred module has run, so this makes an interactive page a real
precondition of ``login`` for every suite — and it is a no-op once the
page has settled, so no suite pays a wait twice.
"""
page.wait_for_load_state("load")
+80
View File
@@ -528,6 +528,21 @@ Implements just enough of the aipi surface:
marker, same reasoning as ``SUMMARY_MODE``), so a marker question
always gets the quote answer; the E2E asks it against an on-topic
fixture (HIGH gate) and asserts non-deflection.
- user message containing ``show me inline markdown`` (phase 180,
markdown links / images / the deep heading ladder, TODO.md L3-L5) ->
the fixed inline answer (``INLINE_ANSWER``): an absolute link with a
``&`` query string, a ``/``-rooted link, a ``mailto:`` link and a
``#fragment`` link, an inert ``javascript:`` link probe, a
``data:image/svg+xml`` image, an inert ``javascript:`` image probe, a
LINKED image (locked A6), a ``####`` and a ``#####`` heading, a pipe
table carrying a link in a cell, and a plain closing line —
byte-stable, so the story E2E can assert the rendered
``<a class="md-link">`` / ``<img class="md-img">`` / ``<h5>`` /
``<h6>`` shapes, the inert probes, the cell-level transform and the
restore path's re-render. Checked BEFORE the ``DEFLECT_MODE`` branch
(like ``TABLE_TRIGGER`` — the marker lives in the user message), so a
marker question always gets the inline answer; the E2E asks it against
an on-topic fixture (HIGH gate) and asserts non-deflection.
- user message containing ``echo my history``
(``HISTORY_TRIGGER``, phase 74, TODO L4 — chat history with prior
thinking reaches the LLM) -> the deterministic HISTORY ECHO, derived
@@ -1491,6 +1506,60 @@ QUOTE_ANSWER = (
"And the quote ends here — a plain closing line."
)
#: Phase 180 (markdown links / images / the deep heading ladder, TODO.md
#: L3-L5): a user message containing this substring (case-insensitive)
#: gets the fixed inline answer (``INLINE_ANSWER`` below) — the renderer's
#: whole phase-180 contract in one byte-stable answer (see the module
#: docstring). The two ``data:image/svg+xml;base64,`` payloads are tiny
#: valid SVGs (no spaces, no parentheses — the URL capture is ``[^)\s]+``)
#: so the CSP's existing ``img-src 'self' data:`` paints them. Existing
#: E2E questions do not contain the phrase, so every other suite is
#: unaffected.
INLINE_TRIGGER = "show me inline markdown"
#: The fixed inline answer (phase 180) — byte-stable on purpose: the story
#: E2E asserts the rendered ``<a class="md-link">`` shapes (the absolute
#: link with its ``target``/``rel`` pair and its ``&amp;`` kept verbatim,
#: the relative / ``mailto:`` / ``#fragment`` links without them), the two
#: inert probes (no live anchor, no live image, the reference still
#: VISIBLE), the ``<img class="md-img">`` with its alt and lazy hint, the
#: linked image wrapped with no stray bracket (locked A6), the ``<h5>`` /
#: ``<h6>`` rungs (locked A4), the link inside the ``.md-table`` cell, and
#: the plain closing line (the stream-completion marker — it lands last).
INLINE_ANSWER = (
"Here's the shape, inline:\n"
"\n"
"[the absolute link](https://example.com/a?x=1&y=2) opens the runbook.\n"
"\n"
"[the relative link](/sources) opens the catalog, "
"[the mail link](mailto:reese@example.com) writes to the owner and "
"[the anchor link](#homelab-inline-markdown-notes) stays on this page.\n"
"\n"
"![topology](data:image/svg+xml;base64,"
"PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCAzMiI+"
"PHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjMyIiBmaWxsPSIjMzg2YjdhIi8+"
"PGNpcmNsZSBjeD0iMjQiY3k9IjE2IiByPSI4IiBmaWxsPSIjZTVlN2ViIi8+PC9zdmc+)\n"
"\n"
"An unsafe reference stays plain text: [oops](javascript:alert(1)).\n"
"\n"
"![bad](javascript:alert(1))\n"
"\n"
"[![linked topology](data:image/svg+xml;base64,"
"PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+"
"PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSIjN2EzODY4Ii8+PC9zdmc+)"
"](https://example.com/runbook)\n"
"\n"
"#### Deep header\n"
"\n"
"##### Deeper\n"
"\n"
"| Note | Link |\n"
"|---|---|\n"
"| topology | [cell link](https://example.com) |\n"
"\n"
"And the inline markdown ends here — a plain closing line."
)
# ---------------------------------------------------------------------------
# Phase 67 (LLM retry, TODO.md L3): deterministic failure injection
# ---------------------------------------------------------------------------
@@ -4085,6 +4154,17 @@ def compose_answer(body: dict[str, Any]) -> str:
# the marker lives in the user message, so a marker question
# always gets the quote answer whatever the gate says.
answer = QUOTE_ANSWER
elif INLINE_TRIGGER in user.lower():
# Markdown links, inline images and the deep heading ladder
# (phase 180, TODO.md L3-L5): the story E2E's deterministic
# inline answer — the allowlisted link/image shapes, the two
# inert ``javascript:`` probes, the linked image (locked A6),
# the ``####``/``#####`` rungs (locked A4) and the table-cell
# link, in one byte-stable stream. Checked BEFORE the
# DEFLECT_MODE branch, like TABLE_TRIGGER: the marker lives in
# the user message, so a marker question always gets the inline
# answer whatever the gate says.
answer = INLINE_ANSWER
elif HISTORY_TRIGGER in user.lower():
# Phase 74 (TODO L4, chat history): the deterministic history
# echo — proves on the wire that the client's prior turns (and
@@ -0,0 +1,395 @@
"""Phase 180 E2E (Playwright): markdown links, images and deep headings.
Source: ``TODO.md`` L4 ("Markdown [links](some-address) aren't being
render correctly and show up as plain text"), L5 ("Quad-level headers
(####) aren't being rendered and show up as plain text") and the
in-answer half of L3 ("Need SVG render support on the front end").
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov
Seeding reuses the real importer against the phase-180 fixture directory
``tests/fixtures/links_docs/`` with the deterministic mock embeddings.
A dedicated tree (like phase 138's ``quote_docs``) — the shared 13-doc
fixture set's import count is pinned by many other suites and must not
move. The mock's ``INLINE_TRIGGER`` ("show me inline markdown", phase 180
task 05) returns the byte-stable inline-markdown answer: an absolute link
with a ``&`` query string, a ``/``-rooted link, a ``mailto:`` link and a
``#fragment`` link, an inert ``javascript:`` link probe, a
``data:image/svg+xml`` image, an inert ``javascript:`` image probe, a
LINKED image (locked A6), a ``####`` and a ``#####`` heading, a pipe
table with a link in a cell, and a plain closing line. The fixture
``homelab/inline-markdown.md`` (the same shapes in a real document) is
the viewer subject — the document viewer is database-only, so the
imported row is enough.
Test → story mapping (Playwright Mapping Rule):
1. ``test_chat_inline_markdown_renders`` — the brain bubble carries the
six ``a.md-link`` elements (the absolute one with
``target="_blank" rel="noopener noreferrer"`` and its ``&amp;``
preserved verbatim in the DOM, the relative / ``mailto:`` /
``#fragment`` ones with neither), NO live anchor for the
``javascript:`` probe (whose text stays visible), the
``img.md-img`` with a non-empty ``alt`` + ``loading="lazy"``, the
linked image as ``a.md-link > img.md-img`` with no stray bracket,
the ``<h5>``/``<h6>`` rungs and the cell-level link inside
``table.md-table``; the turn is NOT deflected.
2. ``test_viewer_inline_markdown_renders`` — the fixture document opens
from the Sources catalog (admin) in the document modal and renders
the same ``a.md-link`` / ``img.md-img`` / ``h5`` / ``h6`` shapes
with the same two inert probes (the shared-renderer contract across
surfaces).
3. ``test_restore_keeps_inline_markdown`` — a reload restores the
conversation (the localStorage restore path re-runs the shared
renderer, app.js ``addMessage``) and the restored brain bubble keeps
its links, its image and its deep headings.
4. ``test_no_horizontal_overflow_at_mobile`` — with the wide image
rendered at 375px, the document never scrolls horizontally (the
``.md-img`` ``max-width: 100%`` contract, task 04).
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import 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
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "links_docs"
#: Carries the mock's ``INLINE_TRIGGER`` ("show me inline markdown") and
#: is on-topic (the fixture doc answers it — token-overlap cosine
#: ≈0.48 against the mock embeddings, so the turn is HIGH and the suite
#: can assert non-deflection).
QUESTION = (
"Show me inline markdown links, images and deep headers "
"from the homelab runbook notes?"
)
INLINE_PATH = "homelab/inline-markdown.md"
INLINE_TITLE = "Homelab Inline Markdown Notes"
#: The byte-stable answer's plain closing line (the stream-completion
#: marker — it lands last).
ANSWER_END = "a plain closing line"
#: Every allowlisted link the answer renders, as ``(href, is_external)``.
#: The external (absolute ``http(s)://``) ones carry the
#: ``target``/``rel`` pair; the others stay in the same tab (task 01).
ANSWER_LINKS = [
("https://example.com/a?x=1&y=2", True),
("/sources", False),
("mailto:reese@example.com", False),
("#homelab-inline-markdown-notes", False),
("https://example.com/runbook", True),
("https://example.com", True),
]
#: Every live image the answer renders, as ``alt`` text (task 02: ``alt``
#: is ALWAYS emitted, and the lazy hint is on every image).
ANSWER_IMAGE_ALTS = ["topology", "linked topology"]
#: The inert probes — the whole construct must stay visible TEXT (never a
#: live element, never dropped).
INERT_LINK = "[oops](javascript:alert(1))"
INERT_IMAGE = "![bad](javascript:alert(1))"
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 _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 (the established house helper).
"""
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"]
def _reset_db(mock_port: int) -> ImportSummary:
"""Truncate the KB (+ the global prompt-state rows), then re-import
the phase-180 fixture (one document)."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
)
db.commit()
summary = _run_in_thread(_import_fixtures(mock_port))
assert summary is not None and summary.added == 1, summary
return summary
def _ask_inline_answer(page: Page, app_url: str) -> Any:
"""Drive the trigger question and return the brain bubble once the
whole byte-stable inline answer has streamed in (the plain closing
line lands last)."""
login(page, app_url, next="/") # phase 79: chat is require_user-gated
page.fill("#message-input", QUESTION)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble").first
bubble.wait_for(state="visible", timeout=30_000)
expect(bubble).to_contain_text(ANSWER_END, timeout=30_000)
# Non-deflection is part of the inline contract (honesty gate
# interplay, like phase 44's table and phase 138's quote answer).
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
return bubble
def _assert_links(root: Any, links: list[tuple[str, bool]]) -> None:
"""The shared assertion (chat bubble, modal, restored bubble): exactly
``len(links)`` ``a.md-link`` elements with those hrefs, each external
one carrying ``target="_blank" rel="noopener noreferrer"`` and each
other one carrying NEITHER (a same-origin target in the same tab is
correct), and no live ``javascript:`` anchor anywhere."""
anchors = root.locator("a.md-link")
expect(anchors).to_have_count(len(links))
seen: list[tuple[str, bool]] = []
for i in range(anchors.count()):
a = anchors.nth(i)
href = a.get_attribute("href")
target = a.get_attribute("target")
rel = a.get_attribute("rel") or ""
# LOCKED A5: an anchor is never built for a scripting scheme.
assert href is not None and not href.lower().startswith("javascript"), (
f"link {i}: a live javascript: anchor exists ({href!r})"
)
expected_external = next((ext for h, ext in links if h == href), None)
assert expected_external is not None, f"link {i}: unexpected href {href!r}"
if expected_external:
assert target == "_blank", f"link {i} ({href!r}): target={target!r}"
assert "noopener" in rel and "noreferrer" in rel, (
f"link {i} ({href!r}): rel={rel!r} misses noopener/noreferrer"
)
else:
assert target is None, f"link {i} ({href!r}) must stay in the same tab"
assert rel == "", f"link {i} ({href!r}) must carry no rel"
seen.append((href, expected_external))
assert sorted(seen) == sorted(links), f"link set mismatch: {seen!r} != {links!r}"
def _assert_images(root: Any, alts: list[str]) -> None:
"""The shared assertion: exactly ``len(alts)`` ``img.md-img``
elements, each with its ``alt`` present and non-empty and
``loading="lazy"``, and no live ``javascript:`` src (LOCKED A5)."""
images = root.locator("img.md-img")
expect(images).to_have_count(len(alts))
got: list[str] = []
for i in range(images.count()):
img = images.nth(i)
alt = img.get_attribute("alt")
src = img.get_attribute("src") or ""
assert alt is not None, f"image {i}: <img> without an alt attribute (WCAG 1.1)"
assert alt != "", f"image {i}: expected a descriptive alt, got the empty one"
assert img.get_attribute("loading") == "lazy", f"image {i}: loading is not lazy"
assert not src.lower().startswith("javascript"), (
f"image {i}: a live javascript: src exists ({src!r})"
)
got.append(alt)
assert sorted(got) == sorted(alts), f"alt set mismatch: {got!r} != {alts!r}"
def _assert_headings(root: Any) -> None:
"""The shared assertion (locked A4): ``####`` renders ``<h5>`` and
``#####`` renders ``<h6>``, with no literal hash syntax left and no
``<p>`` wrapper around either heading."""
expect(root.locator("h5")).to_have_count(1)
expect(root.locator("h5")).to_have_text("Deep header")
expect(root.locator("h6")).to_have_count(1)
expect(root.locator("h6")).to_have_text("Deeper")
html = root.inner_html()
assert "####" not in html, "the hash syntax must not survive as text"
assert "<p><h5" not in html and "<p><h6" not in html, "a deep heading was wrapped in <p>"
def _assert_inert_probes(root: Any) -> None:
"""The two rejected constructs stay VISIBLE TEXT (never dropped,
never a live element): no anchor with a ``javascript:`` href, no
``<img>`` with a ``javascript:`` src, no live ``<script>`` — and the
escaped reference text is still on screen."""
expect(root.locator('a[href^="javascript" i]')).to_have_count(0)
expect(root.locator('img[src^="javascript" i]')).to_have_count(0)
expect(root.locator("script")).to_have_count(0)
expect(root).to_contain_text(INERT_LINK)
expect(root).to_contain_text(INERT_IMAGE)
# ---------------------------------------------------------------------------
# 1. Chat: links, images and the deep headings render in the answer
# ---------------------------------------------------------------------------
def test_chat_inline_markdown_renders(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
bubble = _ask_inline_answer(page, app_url)
# TODO.md L4: the six allowlisted links are real anchors, and the
# target/rel pair rides ONLY the absolute http(s) ones.
_assert_links(bubble, ANSWER_LINKS)
# Escape-first, never decoded (design D2): the `&` of the query
# string reaches the DOM as the escaped `&amp;` form.
html = bubble.inner_html()
assert "https://example.com/a?x=1&amp;y=2" in html, (
"the query string must carry the escaped &amp; verbatim"
)
# TODO.md L3 (the in-answer half): the diagram paints instead of
# printing its reference, with its alt and its lazy hint.
_assert_images(bubble, ANSWER_IMAGE_ALTS)
assert "![topology](" not in bubble.inner_text(), (
"the image reference must render, not print"
)
# LOCKED A6: the linked image is ONE anchor wrapping ONE image, with
# no stray bracket left in the link's own text.
wrapped = bubble.locator("a.md-link > img.md-img")
expect(wrapped).to_have_count(1)
anchor_text = page.locator("a.md-link", has=page.locator("img.md-img")).first.inner_text()
assert anchor_text.strip() == "", f"stray text in the linked-image anchor: {anchor_text!r}"
for bracket in ("[", "]"):
assert bracket not in anchor_text, f"{bracket} left in the linked-image anchor"
# LOCKED A4 / TODO.md L5: the deep rungs of the ladder.
_assert_headings(bubble)
# LOCKED A5: the two rejected constructs stay inert visible text.
_assert_inert_probes(bubble)
# D1: the cell goes through the SAME inline() chain, so a link
# inside a table cell renders (and the table itself still renders).
table = bubble.locator("table.md-table")
expect(table).to_have_count(1)
cell_link = table.locator("a.md-link")
expect(cell_link).to_have_count(1)
expect(cell_link).to_have_text("cell link")
assert cell_link.get_attribute("target") == "_blank"
# ---------------------------------------------------------------------------
# 2. Shared renderer: the viewer/modal renders the fixture document
# ---------------------------------------------------------------------------
def test_viewer_inline_markdown_renders(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
# Admin → Sources → the inline-markdown.md row → same-page document
# modal. Phase 97: the catalog is the drill-down tree — the row
# lives at its folder level (``links_docs`` → ``homelab``).
login(page, app_url) # phase 16: the Sources catalog is admin-only
for name in ("links_docs", "homelab"): # the fixture's source + folder
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
row = page.locator("#docs-tbody tr", has_text=INLINE_PATH)
expect(row).to_have_count(1)
row.locator("td:nth-child(2) a.doc-link").click()
expect(page.locator(".doc-modal")).to_be_visible()
expect(page.locator("#doc-modal-title")).to_have_text(INLINE_TITLE)
# The same shapes the chat bubble gets — the shared renderer serves
# the modal too: the allowlisted links with their target/rel rule,
# the data-URL images with alt + lazy, the deep headings, and the
# two inert probes.
content = page.locator("#doc-modal-content")
_assert_links(content, ANSWER_LINKS)
_assert_images(content, ANSWER_IMAGE_ALTS)
_assert_headings(content)
_assert_inert_probes(content)
# The cell-level transform in the document's own table (the cell
# goes through the same inline() chain as the whole-text pass).
table = content.locator("table.md-table")
expect(table).to_have_count(1)
expect(table.locator("a.md-link")).to_have_count(1)
# ---------------------------------------------------------------------------
# 3. Restore: a reload re-renders the saved conversation through the
# same shared renderer and keeps the links, the image, the headings
# ---------------------------------------------------------------------------
def test_restore_keeps_inline_markdown(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
_ask_inline_answer(page, app_url)
# Reload — the same context keeps its localStorage; the phase-14
# restore path re-runs the shared renderer over the persisted RAW
# text (app.js addMessage), so everything must come back.
page.reload()
expect(page.locator("#empty-state")).to_be_hidden()
bubble = page.locator(".msg.brain .bubble").first
bubble.wait_for(state="visible", timeout=30_000)
expect(bubble).to_contain_text(ANSWER_END, timeout=30_000)
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
# The restored bubble keeps the links (same hrefs, same target/rel
# rule), the image (alt + lazy), the deep headings and the inert
# probes — the restore runs the SAME renderer.
_assert_links(bubble, ANSWER_LINKS)
_assert_images(bubble, ANSWER_IMAGE_ALTS)
_assert_headings(bubble)
_assert_inert_probes(bubble)
expect(bubble.locator("a.md-link > img.md-img")).to_have_count(1)
expect(bubble.locator("table.md-table a.md-link")).to_have_count(1)
# ---------------------------------------------------------------------------
# 4. Task 04's image contract: the wide image never overflows the page
# horizontally at a phone viewport
# ---------------------------------------------------------------------------
def test_no_horizontal_overflow_at_mobile(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_viewport_size({"width": 375, "height": 720})
page.set_default_timeout(30_000)
bubble = _ask_inline_answer(page, app_url)
# The images are rendered (not text) at 375px — the max-width:100%
# contract only means anything once the replaced element is painted.
images = bubble.locator("img.md-img")
expect(images).to_have_count(len(ANSWER_IMAGE_ALTS))
for i in range(images.count()):
box = images.nth(i).bounding_box()
assert box is not None and box["width"] > 0, f"image {i} is not painted"
assert box["width"] <= 375, f"image {i} is {box['width']}px wide at a 375px viewport"
# The document itself never scrolls sideways (the phase-07 pin).
scroll_w, client_w = page.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
)
assert scroll_w <= client_w, f"horizontal overflow at 375px: {scroll_w} > {client_w}"
+30
View File
@@ -0,0 +1,30 @@
# Homelab Inline Markdown Notes
The homelab runbook keeps its links and its topology diagram inline in
every markdown note, so the links and the image render in place.
The backup job publishes an [absolute link](https://example.com/a?x=1&y=2),
a [relative link](/sources), a [mail link](mailto:reese@example.com) and
an [anchor link](#homelab-inline-markdown-notes) in the same note.
![topology](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCAzMiI+PHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjMyIiBmaWxsPSIjMzg2YjdhIi8+PGNpcmNsZSBjeD0iMjQiIGN5PSIxNiIgcj0iOCIgZmlsbD0iI2U1ZTdlYiIvPjwvc3ZnPg==)
An unsafe reference stays plain text in the note:
[oops](javascript:alert(1)) for a link and
![bad](javascript:alert(1)) for an image.
The diagram also ships as a linked image, so clicking the topology image
opens the runbook:
[![linked topology](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSIjN2EzODY4Ii8+PC9zdmc+)](https://example.com/runbook)
#### Deep header
The runbook keeps one deep header and one deeper header per section.
##### Deeper
| Note | Link |
|---|---|
| topology | [cell link](https://example.com) |
The end of the homelab inline markdown notes.
File diff suppressed because it is too large Load Diff