phase: 180_markdown_inline_links_images
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) - ``→`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:
+14
@@ -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)
|
||||
- ``→`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`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+137
@@ -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
|
||||
+16
@@ -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 `&` 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`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+137
@@ -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
|
||||
+19
@@ -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 `[](url)` (A6, both URLs allowlist-checked, either failing returns the original match) then plain `` → `<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 (``, `[](https://example.com)`, `)` → 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`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+137
@@ -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
|
||||
+15
@@ -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`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+137
@@ -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
|
||||
+11
@@ -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`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+137
@@ -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
|
||||
+20
@@ -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 `&`, `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/`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+137
@@ -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
|
||||
@@ -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)`,
|
||||
`` 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
|
||||
|
||||
@@ -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
|
||||
`&` 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 `` — and a linked image
|
||||
`[](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 `` 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
|
||||
|
||||
@@ -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 `[](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 `&`/`'`/`"` 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): `` →
|
||||
* `<img class="md-img" src="…" alt="…" loading="lazy">` and
|
||||
* `[](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 `` 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 `&` / `'` / `"` — 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 `&` / `'` / `"` (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 —
|
||||
// `` inside `[…](url)`, i.e. `[](url)` — gets
|
||||
// its OWN rule, AHEAD of both the image and the link rules —
|
||||
// without it the image rule consumes the inner `` 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
|
||||
// `` → 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>")
|
||||
|
||||
@@ -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"):  and
|
||||
[](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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ``&`` 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"
|
||||
"\n"
|
||||
"\n"
|
||||
"An unsafe reference stays plain text: [oops](javascript:alert(1)).\n"
|
||||
"\n"
|
||||
")\n"
|
||||
"\n"
|
||||
"["
|
||||
"](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 ``&``
|
||||
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 = ")"
|
||||
|
||||
|
||||
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 `&` form.
|
||||
html = bubble.inner_html()
|
||||
assert "https://example.com/a?x=1&y=2" in html, (
|
||||
"the query string must carry the escaped & 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 ", (
|
||||
"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}"
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
An unsafe reference stays plain text in the note:
|
||||
[oops](javascript:alert(1)) for a link and
|
||||
) for an image.
|
||||
|
||||
The diagram also ships as a linked image, so clicking the topology image
|
||||
opens the runbook:
|
||||
[](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
Reference in New Issue
Block a user