diff --git a/.agents/phases/todo/180_markdown_inline_links_images/00_phase.md b/.agents/phases/complete/180_markdown_inline_links_images/00_phase.md similarity index 100% rename from .agents/phases/todo/180_markdown_inline_links_images/00_phase.md rename to .agents/phases/complete/180_markdown_inline_links_images/00_phase.md diff --git a/.agents/phases/todo/180_markdown_inline_links_images/01_inline_links.md b/.agents/phases/complete/180_markdown_inline_links_images/01_inline_links.md similarity index 100% rename from .agents/phases/todo/180_markdown_inline_links_images/01_inline_links.md rename to .agents/phases/complete/180_markdown_inline_links_images/01_inline_links.md diff --git a/.agents/phases/todo/180_markdown_inline_links_images/02_inline_images.md b/.agents/phases/complete/180_markdown_inline_links_images/02_inline_images.md similarity index 100% rename from .agents/phases/todo/180_markdown_inline_links_images/02_inline_images.md rename to .agents/phases/complete/180_markdown_inline_links_images/02_inline_images.md diff --git a/.agents/phases/todo/180_markdown_inline_links_images/03_heading_ladder.md b/.agents/phases/complete/180_markdown_inline_links_images/03_heading_ladder.md similarity index 100% rename from .agents/phases/todo/180_markdown_inline_links_images/03_heading_ladder.md rename to .agents/phases/complete/180_markdown_inline_links_images/03_heading_ladder.md diff --git a/.agents/phases/todo/180_markdown_inline_links_images/04_styles_markdown_inline.md b/.agents/phases/complete/180_markdown_inline_links_images/04_styles_markdown_inline.md similarity index 100% rename from .agents/phases/todo/180_markdown_inline_links_images/04_styles_markdown_inline.md rename to .agents/phases/complete/180_markdown_inline_links_images/04_styles_markdown_inline.md diff --git a/.agents/phases/todo/180_markdown_inline_links_images/05_unit_and_e2e.md b/.agents/phases/complete/180_markdown_inline_links_images/05_unit_and_e2e.md similarity index 100% rename from .agents/phases/todo/180_markdown_inline_links_images/05_unit_and_e2e.md rename to .agents/phases/complete/180_markdown_inline_links_images/05_unit_and_e2e.md diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__00_phase.a1.err b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__00_phase.a1.md b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__00_phase.a1.md new file mode 100644 index 0000000..490abad --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__00_phase.a1.md @@ -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)); `####`→`
`, `#####`/`######`→`
`, `#`/`##`→`

`/`###`→`

` unchanged; `.md-link`/`.md-img`/h5-h6 scale in `styles.css` (palette vars only). All surfaces (chat, thinking, restore, viewer, modal) route through the one renderer (pinned in `app.js`/`document.js`). +- Completion criteria: + - `[text](url)`→`a.md-link`, `javascript:`/`data:` inert — ✅ (node probe + unit + E2E) + - `![alt](src)`→`img.md-img loading="lazy"` with `alt`; bad src inert — ✅ + - `####`/`#####`/`######` ladder; shallow rungs unchanged — ✅ + - Fenced link/image/`####` stay code; cell-level link renders in `.md-table` — ✅ (node probe) + - Byte-identity fixture + phase-44/138 suites pass unedited (git-verified untouched; 6 + 3 E2E pass in isolation) — ✅ + - `uv run pytest --cov=app --cov-report=term-missing`: **4198 passed, TOTAL 99%**; `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`: **4 passed**; `uv run ruff check .`: clean; `uv run pyright`: **0 errors** — ✅ +- Docs: full README/AGENTS.md review vs the whole project — env-example↔README table complete, page routes match `app/main.py`, AGENTS quick-ref artifacts exist. Fixed 1 inconsistency: README understated the image allowlist (impl per task 02 accepts first segments ≤2 chars, `^.{0,2}/`, not only `./`/`../`); phase-180 paragraphs otherwise accurate. +- Notable: image allowlist `.{0,2}/` is broader than the overview's `^\.{1,2}/` but is what task 02 explicitly specifies and the docstring documents — not a deviation from the tasks; SVG sizing deliberately deferred to phase 181. +- Next pending phase: `181_svg_document_preview`. diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__00_phase.a1.md.status b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__00_phase.a1.validate b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__00_phase.a1.validate new file mode 100644 index 0000000..60b5009 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__01_inline_links.a1.err b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__01_inline_links.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__01_inline_links.a1.md b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__01_inline_links.a1.md new file mode 100644 index 0000000..c440509 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__01_inline_links.a1.md @@ -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 → `text`; `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` diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__01_inline_links.a1.md.status b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__01_inline_links.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__01_inline_links.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__01_inline_links.a1.validate b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__01_inline_links.a1.validate new file mode 100644 index 0000000..7da2b2d --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__01_inline_links.a1.validate @@ -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 diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__02_inline_images.a1.err b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__02_inline_images.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__02_inline_images.a1.md b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__02_inline_images.a1.md new file mode 100644 index 0000000..9849777 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__02_inline_images.a1.md @@ -0,0 +1,19 @@ +**Implemented (task 02 — inline images in the shared renderer)** +- `frontend/assets/markdown.js`: new `allowImageUrl()` allowlist (LOCKED A5: `/`-rooted served paths, `./`/`../`, `http(s)://`, `data:image/*` — CSP `img-src 'self' data:` already permits the last; everything else → `null` → inert text), plus two new rules **ahead of** task 01's link rule in the one `inline()` chain: linked-image `[![alt](src)](url)` (A6, both URLs allowlist-checked, either failing returns the original match) then plain `![alt](src)` → ``; `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 ``/`loading`. + +**Gates (100% green)** +- `uv run pytest tests/unit/test_markdown_inline_links_images.py` → 17 passed; with node off the PATH → 8 passed, 9 skipped +- `uv run pytest` → **4190 passed** (phase-44 table + phase-138 blockquote suites unedited) +- `uv run pytest --cov=app --cov-report=term-missing` → 4190 passed, **TOTAL 99%** (>90%) +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors +- E2E in isolation: `test_markdown_blockquotes.py` + `test_markdown_tables.py` → 9 passed; `test_smoke.py` → 3 passed (the phase's own E2E file is task 05) +- All three task repros verified under node (`![topology](/api/documents/aaaa/image)`, `[![d](/a.svg)](https://example.com)`, `![x](javascript:alert(1))` → no `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 diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__03_heading_ladder.a1.err b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__03_heading_ladder.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__03_heading_ladder.a1.md b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__03_heading_ladder.a1.md new file mode 100644 index 0000000..ddd87e6 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__03_heading_ladder.a1.md @@ -0,0 +1,15 @@ +**Implemented (task 03 — heading ladder)** +- `frontend/assets/markdown.js`: inserted `/^#### (.*)$/gm → "
$1
"` and `/^#{5,6} (.*)$/gm → "
$1
"` **deeper-first, immediately before** `/^### /` (locked A4); `#`/`##`→`

` and `###`→`

` 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`→`

Deep
`, `#####`/`######`→two `
` (no `

`), 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` diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__03_heading_ladder.a1.md.status b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__03_heading_ladder.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__03_heading_ladder.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__03_heading_ladder.a1.validate b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__03_heading_ladder.a1.validate new file mode 100644 index 0000000..b8728c6 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__03_heading_ladder.a1.validate @@ -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 diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__04_styles_markdown_inline.a1.err b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__04_styles_markdown_inline.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__04_styles_markdown_inline.a1.md b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__04_styles_markdown_inline.a1.md new file mode 100644 index 0000000..110bc86 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__04_styles_markdown_inline.a1.md @@ -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 `` 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`. diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__04_styles_markdown_inline.a1.md.status b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__04_styles_markdown_inline.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__04_styles_markdown_inline.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__04_styles_markdown_inline.a1.validate b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__04_styles_markdown_inline.a1.validate new file mode 100644 index 0000000..b8728c6 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__04_styles_markdown_inline.a1.validate @@ -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 diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__05_unit_and_e2e.a1.err b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__05_unit_and_e2e.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__05_unit_and_e2e.a1.md b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__05_unit_and_e2e.a1.md new file mode 100644 index 0000000..1687b0e --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__05_unit_and_e2e.a1.md @@ -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/`. diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__05_unit_and_e2e.a1.md.status b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__05_unit_and_e2e.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__05_unit_and_e2e.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__05_unit_and_e2e.a1.validate b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__05_unit_and_e2e.a1.validate new file mode 100644 index 0000000..60b5009 --- /dev/null +++ b/.agents/reports/180_markdown_inline_links_images/180_markdown_inline_links_images__05_unit_and_e2e.a1.validate @@ -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 diff --git a/AGENTS.md b/AGENTS.md index e91d8b3..dbcec2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,14 @@ (phase 156); an in-flight turn never paints an empty answer bubble — the box is created empty as the reasoning anchor and `.msg .bubble:empty { display: none }` hides it until the first - answer token (phase 166); the document viewer's page strip is CLASS-scoped — + answer token (phase 166); an answer's inline `[text](url)`, + `![alt](src)` and `####`+ heading render through the ONE shared + renderer into an allowlist-gated `a.md-link` (underlined, never a + `javascript:`/`data:` element), a container-capped + `img.md-img` (`max-width: 100%` — no horizontal overflow at 375px) + and an `h5`/`h6` sized into the existing type scale — never printed + reference text (phase 180); the document viewer's page strip is + CLASS-scoped — the primary class shows the strip in place of the text, the additive class never hides the text: Text/Pages toggle group (`aria-pressed`, Text default) with the strip behind it diff --git a/README.md b/README.md index 33751ab..4e20040 100644 --- a/README.md +++ b/README.md @@ -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 ``. 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 `![alt](src)` — and a linked image +`[![alt](src)](url)`, which renders as that image wrapped in the link — +becomes a 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//image`, `/assets/…`), relative paths whose first +segment is at most two characters (`./x`, `../x`, `d/x` — a longer first +segment like `images/x.png` is a documented miss), `http(s)://` and +`data:image/…` — the `data:` case needs no CSP change +because the app's own `img-src 'self' data:` already permits it. A +`javascript:`, `file:`, `blob:` or non-image `data:` src stays visible as +the inert `![alt](src)` text it is today, never an element, and a broken +served path degrades to the browser's alt text (the renderer emits an HTML +string, so it carries no inline `onerror` handler — the JS fallbacks live +in `document.js`/`app.js`). `.md-img` carries the app's bordered-image card +treatment and is capped to its container — `max-width: 100%`, `height: auto` +and `object-fit: contain` — so a wide screenshot never produces a horizontal +scrollbar at 375px or at 1440px. Sizing an SVG that declares no `width` or +`height` is deliberately left to phase 181. + +**The heading ladder reaches six levels** (phase 180, TODO.md L5): a +`#### x` line in an answer, the thinking block, a saved/shared chat, the +document viewer or the modal renders `

x
`, and `##### x` / +`###### x` render `
x
` (HTML stops at `h6`). The levels above are +UNCHANGED and deliberately flattened — `#`/`##` both render `

`, `###` +renders `

` — 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 diff --git a/frontend/assets/markdown.js b/frontend/assets/markdown.js index 426fe9d..de09b33 100644 --- a/frontend/assets/markdown.js +++ b/frontend/assets/markdown.js @@ -32,6 +32,76 @@ * a > inside a code fence never reaches the pass (step 1 placeholder), * and a > in a table cell is restored in step 4. Everything without a * quote line renders byte-identically to the pre-phase renderer. + * + * Phase 180 (2026-10-14, TODO.md L4/L5 + the in-answer half of L3) adds + * the inline link/image transforms and the rest of the heading ladder + * to the ONE `inline()` chain (the single source of every inline + * transform, also applied per table cell — so a link inside a cell + * works for free). The chain order is code → strong → em → + * **linked-image → image → link** — both image rules sit AHEAD of the + * link rule: the linked-image rule first of all so `[![alt](src)](url)` + * cannot leave a stray `[` in the link text (locked A6), the plain image + * rule next so the `!` of an image is never orphaned into `!` + * → headings → lists. The heading ladder (locked A4, TODO.md L5) is + * `#`/`##` → `

`, `###` → `

`, `####` → `

`, `#####` and + * `######` → `
`: the flattening of `#`/`##` to one `

` level is + * pre-existing and DELIBERATE (the phase adds the levels below `###`, it + * does not re-rank the existing ones), `#####`/`######` share `

` + * 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)` → `text`, 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): `![alt](src)` → + * `…` and + * `[![alt](src)](url)` → that same image wrapped in the link, again ONLY + * through an allowlist function (`allowImageUrl`, locked A5) — `/`-rooted + * served paths (`/api/documents//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 `` with no alt attribute fails WCAG + * 1.1, and there is deliberately NO `onerror` handler: this renderer + * returns an HTML string into DOM-sink surfaces, so an inline handler + * would break the no-CDN/CSP posture — a broken src degrades to + * the browser's alt text (the JS fallbacks live in document.js/app.js, + * which do not route through this function). Documented limits + * (known, not fixed): a URL containing a literal `)` or whitespace is a + * miss (so title syntax `![alt](src "title")` is a miss too), as is a + * relative image path whose first segment is longer than two characters + * (`images/x.png` — the allowlist accepts `./`, `../` and `/`-rooted); + * a linked image whose HREF alone is rejected falls through to the plain + * image rule, so the image renders inside the inert `[…](url)` text, and + * an image whose src is rejected by `allowImageUrl` but ACCEPTED by + * `allowLinkUrl` (a link-only shape used as an image src — `#fragment` + * or `mailto:`) falls through to the link rule, which turns `[alt](url)` + * into a link behind the leftover `!`; neither ever yields a live image + * or a live script; + * inline code spans are NOT placeholder-protected (pre-existing — + * `**bold**` inside backticks already transforms), so markdown syntax + * inside `` `…` `` transforms too; a size-less SVG (`viewBox` only, no + * `width`/`height`) in `.md-img` paints at the replaced-element default + * box — the vector sizing contract belongs to phase 181; + * autolinks (``) 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//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 +// `` + ); +} + function renderMarkdown(md) { // 1. Protect fenced code blocks. const codeBlocks = []; @@ -56,6 +172,74 @@ function renderMarkdown(md) { s.replace(/`([^`\n]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1$2") + // Phase 180 (task 02, TODO.md L3, locked A6): a LINKED image — + // `![alt](src)` inside `[…](url)`, i.e. `[![alt](src)](url)` — gets + // its OWN rule, AHEAD of both the image and the link rules — + // without it the image rule consumes the inner `![alt](src)` and + // the link rule then leaves a + // stray `[` in the link text. BOTH URLs are allowlist-checked (the + // href through allowLinkUrl, the src through allowImageUrl) and + // either rejection returns the whole original match, which stays + // inert escaped text (locked A5). `alt` is ALWAYS emitted — empty + // allowed, `alt=""` for a decorative image — because an `` + // 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) + + `${alt}` + + "" + ); + }) + // Phase 180 (task 02, TODO.md L3): a plain inline image + // `![alt](src)` → a lazy ``. Runs AFTER the + // linked-image rule (so the `!` is never orphaned into `!`) 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 `${alt}`; + }) + // 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}`; + }) + // 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 `
` (locked A4: HTML has no `` and + // the app's type scale stops two levels below `###`). The existing + // `#`/`##` → `

` 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, "

$1
") + .replace(/^#{5,6} (.*)$/gm, "
$1
") .replace(/^### (.*)$/gm, "

$1

") .replace(/^## (.*)$/gm, "

$1

") .replace(/^# (.*)$/gm, "

$1

") diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index dc3f740..f8f2b2b 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -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 + , and an element with no rule anywhere in this file + would paint as unmarked body text. ONE rule set serves every rendering + context the renderer feeds (the chat .bubble brain + user, the + details.thinking / details.reasoning scratchpad, the shared page's + .msg/.bubble copy, the viewer's .doc-md and the document modal, which + shares the renderDocument core) — all of them sit on --surface, and the + deflected bubble's --accent-bg holds the pairing too (8.3:1), so a + single AA pairing covers them all. Palette vars only, no hex/rgb/hsl + literal (the :root-drift test parses the built-ins out of this file — a + literal here would be a second source of truth, and a new hue would + break the monochrome invariant): + color --brand-ink — 9.9:1 on --surface, the :root-documented + AA-safe BRAND TEXT token (the same rose the + details.thinking summary and .msg.brain .avatar use); + underline unconditional — a link in running text must not rely on + color alone (WCAG 1.4.1, PLAN §7.2 "text + color, never + color alone"), and it is what keeps the link marked inside + the user bubble, where the color inverts to the bubble's + own ink below; + offset 2px — lifts the rule off the descenders (g/y/p stay open). + The focus state is the GLOBAL `:focus-visible` 3px --brand ring above + (do NOT restate or reset it here — one ring for the whole app). + Static content — no animation (nothing for prefers-reduced-motion to + still). */ +.md-link { + color: var(--brand-ink); /* 9.9:1 on --surface (AA) — :root's brand text token */ + text-decoration: underline; + text-underline-offset: 2px; +} +/* :hover brightens one notch on the same rose — --brand-hover is --brand + mixed with 14% white in :root: 5.9:1 on --surface and 5.0:1 on the + deflected bubble's --accent-bg, so the state change never drops under + AA on any canvas the renderer writes into (brightening is the safe + direction on a dark palette — every darker step fails there). */ +.md-link:hover { color: var(--brand-hover); } +/* The USER bubble runs the question through the same renderer on the + --brand fill (the .md-quote inversion above is the precedent): + --brand-ink on --brand is 1.9:1 and --brand-hover on --brand is 1.2:1 — + both AA failures — so the link keeps the bubble's own ink, --bg on + --brand at 5.2:1 (the :root pairing). The unconditional underline is + what marks it a link here, never the color. */ +.msg.user .bubble .md-link { color: var(--bg); } +/* …and the SAME inversion on :hover: the default --brand-hover would + erase the word (1.2:1 on the fill). A color-mix toward --bg cannot + help — the text is ALREADY --bg — so the hover state moves into the + DECORATION (a 2px rule at a deeper offset), which is a visible state + change that needs no new pair, keeps 5.2:1 at every theme, and stays + inside the palette (no new hue — the monochrome invariant). */ +.msg.user .bubble .md-link:hover { + color: var(--bg); + text-decoration-thickness: 2px; + text-underline-offset: 3px; +} + +/* Markdown inline images (phase 180, task 04, TODO.md L3 — the in-answer + half of "Need SVG render support on the front end"): ![alt](src) and + [![alt](src)](url) render 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 (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
for #### and
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 { diff --git a/tests/e2e/auth_helpers.py b/tests/e2e/auth_helpers.py index d1113c7..af9fff7 100644 --- a/tests/e2e/auth_helpers.py +++ b/tests/e2e/auth_helpers.py @@ -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 + ``](https://x.test?a=1&b=2)", + "empty": empty, + "fence": "```\n[a](https://x.test)\n```", + "cell": "| h |\n|---|\n| [cell](https://x.test) |", + } + exact = { + "abs": ( + '

see the docs

' + ), + "http": ( + '

a

' + ), + "mailto": '

m

', + "root": '

r

', + "dotrel": ( + '

d

' + '

u

' + ), + "frag": '

f

', + "rich": ( + '

' + "bold i c

" + ), + "esc": ( + '

' + "<script>alert(1)</script>

" + ), + "empty": ( + '

' + ), + "fence": "

[a](https://x.test)

", + } + results = _render(cases) + for name, html in results.items(): + if name in exact: + assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}" + assert ( + 'cell' + ) in results["cell"], "a link inside a table cell renders through inline()" + assert " None: + """LOCKED A5 under node: a ``javascript:``, ``data:``, ``vbscript:``, + ``file:`` or ``blob:`` URL (any case) never becomes a live element — + the whole ``[text](url)`` construct renders verbatim as the inert + escaped text it was before, with no ``{src}

", f"{name}: {html!r} must stay verbatim text" + assert " None: + """The allowlist function itself under node: accepted URLs come back + VERBATIM (escape-first — a ``&`` in the query string is not + decoded), everything else comes back ``null``.""" + cases: list[tuple[str, str | None]] = [ + # accepted — returned VERBATIM (escape-first: the & stays) + ("https://x.test/a?x=1&y=2", "https://x.test/a?x=1&y=2"), + ("http://x.test", "http://x.test"), + ("HTTPS://X.TEST", "HTTPS://X.TEST"), + ("mailto:a@x.test", "mailto:a@x.test"), + ("/docs/x.md", "/docs/x.md"), + ("./x.md", "./x.md"), + ("../x.md", "../x.md"), + ("#frag", "#frag"), + # rejected -> null (LOCKED A5): the construct stays inert text + ("javascript:alert(1)", None), + ("JaVaScRiPt:alert(1)", None), + ("data:text/html,x", None), + ("vbscript:x", None), + ("file:///etc/passwd", None), + ("blob:https://x.test/u", None), + ("x.test/no-scheme", None), + ("", None), + ("..%2fetc%2fpasswd", None), + ("javascript:alert(1)", None), + ] + driver = ( + _text(MARKDOWN_JS) + + "\nconst in_ = " + + json.dumps([c[0] for c in cases]) + + ";\nconsole.log(JSON.stringify(in_.map(allowLinkUrl)));" + ) + got = json.loads(_run_node(driver)) + assert got == [expected for _, expected in cases], f"allowLinkUrl mismatch: {got!r}" + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_image_transform_shapes_under_node() -> None: + """The image shapes executed under node (TODO.md L3): a served-path + image (the app's own ``/api/documents//image`` shape), a + ``data:image/*`` image (CSP ``img-src 'self' data:`` already permits + it), an absolute ``http(s)://`` image, ``./``/``../`` relative images, + an EMPTY alt (decorative — the attribute is still there), HTML in the + alt staying escaped (so the attribute cannot be broken out of), two + images on one line, an image inside a blockquote, an image inside a + fenced block staying code (step 1 protects first), and an image inside + a table cell rendering through the same ``inline()``.""" + cases = { + "served": "![topology](/api/documents/aaaa/image)", + "assets": "![logo](/assets/brand.svg)", + "data": "![i](data:image/svg+xml;base64,PHN2Zz4=)", + "abs": "![a](https://x.test/i.png)", + "dotrel": "![img](./x.png)\n\n![up](../y.png)", + "empty_alt": "![](/a.png)", + "esc_alt": "![he said \"hi\" & more](/a.png)", + "two": "![a](/a.png)![b](/b.png)", + "quote": "> ![q](/a.png)", + "fence": "```\n![a](/a.png)\n```", + "cell": "| h |\n|---|\n| ![c](/a.png) |", + } + exact = { + "served": ( + '

' + ), + "assets": ( + '

logo

' + ), + "data": ( + '

' + ), + "abs": ( + '

a

' + ), + "dotrel": ( + '

img

' + '

up

' + ), + "empty_alt": '

', + "esc_alt": ( + '

' + ), + "two": ( + '

a' + 'b

' + ), + "quote": ( + '
' + 'q' + "
" + ), + "fence": "

![a](/a.png)

", + } + results = _render(cases) + for name, html in results.items(): + if name in exact: + assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}" + assert 'c' in results[ + "cell" + ], "an image inside a table cell renders through inline()" + # every live image carries an alt AND the lazy hint — no exceptions + for name in ("served", "assets", "data", "abs", "empty_alt", "cell"): + start = results[name].find(IMG_MARKER) + assert start != -1, f"{name}: no rendered — {results[name]!r}" + tag = results[name][start : results[name].find(">", start) + 1] + assert "alt=" in tag and LAZY_ATTR in tag, f"{name}: {tag!r} misses alt/lazy" + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_disallowed_image_srcs_stay_inert_text() -> None: + """LOCKED A5 under node: a ``javascript:``, ``file:``, ``blob:``, + ``vbscript:`` or NON-image ``data:`` src (any case) never becomes a + live ```` — the whole ``![alt](src)`` construct renders verbatim + as the inert escaped text it was before, with no ``{expected}

", ( + f"{name}: {html!r} must stay verbatim text for {src!r}" + ) + assert " None: + """Documented limit (pinned so a change is deliberate): an image src + rejected by ``allowImageUrl`` but ACCEPTED by ``allowLinkUrl`` — a + link-only shape used as an image src, i.e. ``#fragment`` or + ``mailto:`` — reaches the link rule, so ``![alt](url)`` renders as a + link behind the leftover ``!``. The rejected src never becomes a live + image, and the surviving element is an ordinary allowlisted link (the + same shape the pre-phase renderer produced for that input).""" + cases = { + "frag": ( + "![f](#frag)", + '

!f

', + ), + "mailto": ( + "![a](mailto:x@y.test)", + '

!a

', + ), + } + results = _render({name: src for name, (src, _e) in cases.items()}) + for name, (src, expected) in cases.items(): + assert results[name] == expected, f"{name} ({src}): {results[name]!r}" + assert IMG_MARKER not in results[name], f"{name}: {src} must not become an " + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_linked_image_wraps_without_stray_brackets() -> None: + """LOCKED A6 under node: ``[![alt](src)](url)`` renders as ONE + ```` wrapping ONE ```` with no + stray ``[``/`]` left in the output — for an absolute href (which + carries ``target``/``rel``, and keeps ``&`` in a query string + verbatim), for a root-relative href and for a ``mailto:`` href (both + without ``target``/``rel``). When only the HREF is rejected the + construct falls through to the plain image rule (the image renders + inside the still-inert ``[…](url)`` text — documented limit, never a + live anchor); when only the SRC is rejected the whole construct stays + verbatim text.""" + cases = { + "abs": "[![d](/a.svg)](https://example.com)", + "query": "[![linked](data:image/png;base64,YQ==)](https://x.test/x?a=1&b=2)", + "relative": "[![d](/a.svg)](/page)", + "mailto": "[![d](/a.svg)](mailto:a@x.test)", + "bad_href": "[![d](/a.svg)](javascript:alert(1))", + "bad_src": "[![d](javascript:alert(1))](https://example.com)", + } + exact = { + "abs": ( + '' + 'd' + ), + "query": ( + '' + 'linked' + ), + "relative": ( + '' + 'd' + ), + "mailto": ( + '' + 'd' + ), + } + results = _render(cases) + for name, inner in exact.items(): + assert results[name] == f"

{inner}

", ( + f"{name}: {results[name]!r} != contract {inner!r}" + ) + assert "[" not in results[name][3:-4] and "]" not in results[name][3:-4], ( + f"{name}: {results[name]!r} keeps a stray bracket" + ) + for name in ("abs", "query", "relative", "mailto"): + html = results[name] + assert html.count(LINK_MARKER) == 1 and html.count(IMG_MARKER) == 1 + assert f"{LINK_MARKER} " in html and "

" in html + # the href-only rejection: the image still renders, the anchor does not + href_rejected = results["bad_href"] + assert LINK_MARKER not in href_rejected and "href" not in href_rejected + assert IMG_MARKER in href_rejected, "documented fall-through to the image rule" + # the src-only rejection: everything stays verbatim text + src_rejected = results["bad_src"] + assert src_rejected == f"

{cases['bad_src']}

", f"bad_src: {src_rejected!r}" + assert IMG_MARKER not in src_rejected and LINK_MARKER not in src_rejected + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_allowimage_url_function_accepts_and_rejects() -> None: + """The image allowlist itself under node: accepted srcs come back + VERBATIM (escape-first — an ``&`` inside the src is not decoded), + everything else comes back ``null``. ``data:`` is accepted ONLY for + ``image/*`` (the app's CSP shape), and a link-only shape (``#frag``, + ``mailto:``) is not an image.""" + cases: list[tuple[str, str | None]] = [ + # accepted — returned VERBATIM + ("/api/documents/aaaa/image", "/api/documents/aaaa/image"), + ("/assets/brand.svg", "/assets/brand.svg"), + ("./x.png", "./x.png"), + ("../y.png", "../y.png"), + ("https://x.test/i.png", "https://x.test/i.png"), + ("HTTP://X.TEST/I.PNG", "HTTP://X.TEST/I.PNG"), + ("data:image/svg+xml;base64,PHN2Zz4=", "data:image/svg+xml;base64,PHN2Zz4="), + ("DATA:IMAGE/PNG;base64,YQ==", "DATA:IMAGE/PNG;base64,YQ=="), + ("/x.png?a=1&b=2", "/x.png?a=1&b=2"), + # rejected -> null (LOCKED A5): the construct stays inert text + ("javascript:alert(1)", None), + ("JaVaScRiPt:alert(1)", None), + ("data:text/html,x", None), + ("data:image/png;base64,YQ==", None), + ("file:///etc/passwd", None), + ("blob:https://x.test/u", None), + ("vbscript:x", None), + ("x.test/i.png", None), + ("images/x.png", None), + ("#frag", None), + ("mailto:a@x.test", None), + ("", None), + ("..%2fetc%2fpasswd", None), + ] + driver = ( + _text(MARKDOWN_JS) + + "\nconst in_ = " + + json.dumps([c[0] for c in cases]) + + ";\nconsole.log(JSON.stringify(in_.map(allowImageUrl)));" + ) + got = json.loads(_run_node(driver)) + assert got == [expected for _, expected in cases], f"allowImageUrl mismatch: {got!r}" + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_heading_ladder_shapes_under_node() -> None: + """TODO.md L5 under node: ``#### Deep`` renders ``
Deep
`` with + NO ``

`` wrapper and no literal ``####``; ``##### `` and ``###### `` + both render ``

`` (two consecutive deep lines become two ``
`` + elements, still unwrapped); ``###``/``##``/``#`` are BYTE-IDENTICAL to + the pre-phase output; a deep heading immediately followed by a + paragraph is not wrapped in ``

``; inline markup inside a deep + heading still transforms; a ``####`` line inside a fence stays fence + content (step 1 protects first); a ``####`` in a table cell renders + through the same ``inline()``; and the non-heading shapes (a mid-line + ``####``, an indented ``####``, a hash run with no space) stay text.""" + cases = { + "h4": "#### Deep", + "h5_h6": "##### Deeper\n###### Deepest", + "h6": "###### Deepest", + "existing": "### A\n\n## B\n\n# C", + "then_para": "#### H\n\nA paragraph after.", + "rich": "#### Deep with **bold** and `c`", + "fence": "```\n#### Deep\n```", + "cell": "| h |\n|---|\n| #### cell head |", + "midline": "text with a #### mid-line hash", + "indented": " #### Not a heading", + "no_space": "####No space", + } + exact = { + "h4": "

Deep
", + "h5_h6": "
Deeper
\n
Deepest
", + "h6": "
Deepest
", + # the pre-existing ladder, unchanged (LOCKED A4) + "existing": "

A

B

C

", + "then_para": "
H

A paragraph after.

", + "rich": "
Deep with bold and c
", + "fence": "

#### Deep

", + "midline": "

text with a #### mid-line hash

", + # the paragraph pass trims the block, the heading rule still does not fire + "indented": "

#### Not a heading

", + "no_space": "

####No space

", + } + results = _render(cases) + for name, html in results.items(): + if name in exact: + assert html == exact[name], f"{name}: {html!r} != contract {exact[name]!r}" + # the untouched step-3 guard does its job: no heading inside a

+ for name in ("h4", "h5_h6", "h6", "then_para", "rich"): + assert "

" + assert results["h5_h6"].count("

") == 2 and results["h5_h6"].count("
") == 2, ( + "##### and ###### both render
" + ) + assert "####" not in results["h4"], "the hash syntax must not survive" + assert "
cell head
" in results["cell"], ( + "a deep heading inside a table cell renders through inline()" + ) + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_heading_ladder_stops_at_h6_and_never_invents_levels() -> None: + """LOCKED A4 under node: the deep run is ``#{5,6}``, so a 7+ hash run + is NOT a heading (``####### Seven`` has no space after five/six hashes + at column 0) and the renderer never invents an element HTML does not + have — there is no ````/```` in the source or the output.""" + js = _text(MARKDOWN_JS) + # (`` appears in the prose that explains WHY there is none, so the + # pin is on the output-template form, not the bare tag name) + for forbidden in ('"{src}

", f"{name}: {results[name]!r} stays text" + assert " None: + """The byte-identity contract (phase 180 design D7): an input with no + link, no image ref and no ``####``+ heading renders BYTE-EXACTLY as + the pre-phase renderer did (the fixture string and its pre-phase + output are checked into this test, per the phase-44/138 shape) — + which is what keeps the phase-44 table suite and the phase-138 + blockquote suite green unedited.""" + results = _render({"no_construct": NO_CONSTRUCT_INPUT}) + assert results["no_construct"] == NO_CONSTRUCT_PRE_PHASE_OUTPUT + assert LINK_MARKER not in results["no_construct"] + assert IMG_MARKER not in results["no_construct"] + assert LAZY_ATTR not in results["no_construct"] + # the ladder added no levels ABOVE ####, so an input whose deepest + # heading is ### gains no h5/h6 either + for tag in ("Top heading
", "

Second heading

", "

Third heading

"): + assert expected in results["no_construct"], f"ladder regression: {expected}"