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

All verification complete. Final state summary:

**Report — phase 180 final verification pass**
- Verified (all already implemented in working tree): `inline()` chain = code→strong→em→linked-image→image→link→headings→lists in `frontend/assets/markdown.js`; `allowLinkUrl`/`allowImageUrl` allowlists (A5), shared `mdAnchorOpen` (target/rel only for absolute http(s)); `####`→`<h5>`, `#####`/`######`→`<h6>`, `#`/`##`→`<h3>`/`###`→`<h4>` unchanged; `.md-link`/`.md-img`/h5-h6 scale in `styles.css` (palette vars only). All surfaces (chat, thinking, restore, viewer, modal) route through the one renderer (pinned in `app.js`/`document.js`).
- Completion criteria:
  - `[text](url)`→`a.md-link`, `javascript:`/`data:` inert — ✅ (node probe + unit + E2E)
  - `![alt](src)`→`img.md-img loading="lazy"` with `alt`; bad src inert — ✅
  - `####`/`#####`/`######` ladder; shallow rungs unchanged — ✅
  - Fenced link/image/`####` stay code; cell-level link renders in `.md-table` — ✅ (node probe)
  - Byte-identity fixture + phase-44/138 suites pass unedited (git-verified untouched; 6 + 3 E2E pass in isolation) — ✅
  - `uv run pytest --cov=app --cov-report=term-missing`: **4198 passed, TOTAL 99%**; `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`: **4 passed**; `uv run ruff check .`: clean; `uv run pyright`: **0 errors** — ✅
- Docs: full README/AGENTS.md review vs the whole project — env-example↔README table complete, page routes match `app/main.py`, AGENTS quick-ref artifacts exist. Fixed 1 inconsistency: README understated the image allowlist (impl per task 02 accepts first segments ≤2 chars, `^.{0,2}/`, not only `./`/`../`); phase-180 paragraphs otherwise accurate.
- Notable: image allowlist `.{0,2}/` is broader than the overview's `^\.{1,2}/` but is what task 02 explicitly specifies and the docstring documents — not a deviation from the tasks; SVG sizing deliberately deferred to phase 181.
- Next pending phase: `181_svg_document_preview`.
This commit is contained in:
2026-10-14 16:03:36 -04:00
parent 3d82a72b6f
commit 6880bd0866
39 changed files with 2952 additions and 1 deletions
@@ -1,54 +0,0 @@
# Phase 180 — Markdown inline links, inline images, and the full heading ladder
**Source:** `TODO.md` L4 ("Markdown [links](some-address) aren't being render correctly and show up as plain text"), L5 ("Quad-level headers (####) aren't being rendered and show up as plain text") and the in-answer half of L3 ("Need SVG render support on the front end when citing … source")
**Story:** n/a (TODO-derived phase, Protocol B append — owner roadmap confirmation 2026-10-14)
**Context:** `frontend/assets/markdown.js` — the SHARED escape-first renderer used by all three pages (`index.html` chat bubbles + thinking, `document.html` viewer, `shared.html`) and by `document-modal.js` through `document.js`'s `renderDocument`. Pipeline order: step 1 fence protection (`\u0000CODE n\u0000`) → step 1b GFM pipe-table protection (`\u0000TABLE n\u0000`, phase 44) → step 2 `text = inline(escapeHtml(text))` → step 2b blockquotes (phase 138) → step 3 the paragraph pass (split on `\n{2,}`, block guard `/^<(h\d|ul|ol|pre|li|blockquote)/`) → step 4 restore. The `inline(s)` chain (the single source of every inline transform, also applied per table cell) is today, in order: code span → `**strong**` → `*em*` → `^### `→`<h4>` → `^## `→`<h3>` → `^# `→`<h3>` → `^\s*[-*] `→`<li>` → `<ul>` grouping → `^\d+\. `→`<li>`. Three verified gaps: **no link transform exists at all** (`renderMarkdown("[a](https://x)")` returns the text verbatim inside a `<p>`), **no image transform exists at all** (`![alt](src)` prints literally), and the heading ladder stops at `###` — `####`/`#####`/`######` match no rule and print as plain text (each rule requires a space after its hashes, so no rule partially eats a deeper level). `frontend/assets/styles.css` — the `:root` palette (`--ink`, `--ink-soft`, `--line`, `--surface`, `--bg`, the `--brand-*` family, `--radius*`) plus the precedent rule families: `.md-table`/`.md-table-wrap` (L539-557), `.md-quote` + the `.msg.user .bubble .md-quote` inversion (L561-588), `.source-image-img` / `.doc-image-img` (L981-1027, L5611-5640) for image treatment. `app/core/security_headers.py::CSP` (L67-68) — `default-src 'self'; base-uri 'none'; frame-ancestors 'none'; img-src 'self' data:`: served-path and `data:` images are ALREADY permitted, no CSP change is needed or allowed here. Test patterns: `tests/unit/test_markdown_blockquotes.py` (source pins + a `HAVE_NODE`-skipped node behaviour harness + a byte-identity fixture string) and `tests/e2e/test_markdown_blockquotes.py` (dedicated fixture tree `tests/fixtures/quote_docs/`, mock LLM trigger, chat + viewer + localStorage-restore assertions).
## Objective
`[text](url)` renders as a real, safe `<a>`, `![alt](src)` renders as a lazy `<img>`, and `####`–`######` render as `<h5>`/`<h6>` — in chat answers, the thinking block, saved/shared chats, the document viewer and the document modal, from the ONE shared renderer. Every input that contains none of the three constructs renders byte-identical to today, so the phase-44 table suite, the phase-138 blockquote suite and every renderer-dependent unit suite stay green unedited.
## Design (shared by all tasks — the executor reads this, not the chat)
- **D1 — one chain, one place (tasks 01–03).** All three fixes are edits to the same `inline(s)` chain in `frontend/assets/markdown.js`. Nothing else in the pipeline moves: fences (step 1) and tables (step 1b) keep their precedence, and because a table cell goes through `inline(escapeHtml(cell))`, links/images/deep headings inside a cell work for free (pinned in task 05).
- **D2 — escape-first is the security boundary, so NEVER unescape.** `inline()` always receives HTML-escaped text. The captured URL may therefore contain `&amp;`/`&#39;`/`&quot;` — that is *valid* in an `href`/`src` and must be passed through verbatim (never decoded, never re-escaped). Any attempt to decode would re-open the injection surface the renderer exists to close.
- **D3 — links (task 01).** `/\[([^\]]*)\]\(([^)\s]+)\)/g` (empty link text allowed, no whitespace inside the URL), applied AFTER the `*em*` rule and BEFORE the heading ladder. The URL is validated by an allowlist *function*, not a regex-in-replacement: allow `^https?://`, `^mailto:`, `^/`, `^#`, `^\.{1,2}/`; anything else (`javascript:`, `data:`, `vbscript:`, `file:`, bare `javascript&colon;` variants) returns the ORIGINAL matched text unchanged — it stays visible as inert escaped text, never a live element. Absolute `http(s)://` links get `target="_blank" rel="noopener noreferrer"`; `mailto:`, root-relative and `#fragment` links get neither (a same-origin target in the same tab is the correct behaviour). Output marker: `<a class="md-link" …>`.
- **D4 — images (task 02), matched BEFORE the link rule.** `/!\[([^\]]*)\]\(([^)\s]+)\)/g` → `<img class="md-img" src="…" alt="…" loading="lazy">`. The image rule runs FIRST so the `!` is never orphaned into `!<a>`. Same allowlist discipline: `/`-rooted served paths, `http(s)://` and `data:image/*` render; anything else stays inert text. `alt` is ALWAYS emitted (empty allowed) — an `<img>` without `alt` fails WCAG. Linked images (`[![alt](src)](url)`) get their OWN rule ahead of both (locked A6): `<a class="md-link" …><img class="md-img" …></a>` — otherwise the image rule leaves a stray `[` inside the link text.
- **D5 — the heading ladder (task 03, locked A4).** Insert `/^#### (.*)$/gm → <h5>$1</h5>` and `/^#{5,6} (.*)$/gm → <h6>$1</h6>` immediately BEFORE the existing `/^### /` rule (deeper-first is the readable order; the rules cannot cross-match because every rule requires a space after its hashes). `#` and `##` keep mapping to `<h3>` — existing behaviour, deliberately unchanged. The step-3 guard already matches `h\d`, so no paragraph-guard change is needed (pinned, not assumed).
- **D6 — CSS (task 04).** `.md-link` (underlined; `color: var(--brand-ink)` — 9.0:1 on `--surface`, the file's documented AA-safe brand text token — with `--brand-hover` on `:hover`, the shared 3px `--brand` `:focus-visible` ring already used at L163, and the `.msg.user .bubble .md-link { color: var(--bg) }` inversion following the `.md-quote` precedent at L588, 5.2:1 on the brand bubble), `.md-img` (`max-width:100%; height:auto; object-fit:contain` + the `.source-image-img` border/radius/surface language), and the `.doc-md` / `.bubble` / `.thinking-text` `h5`/`h6` type scale. Palette variables ONLY, no new hue (the monochrome invariant), every non-obvious color carrying its `/* n.n:1 on --x (AA) */` comment.
- **D7 — byte-identity is the regression contract (task 05).** A checked-in fixture string with no links, no image refs and no `####`+ heading, plus its byte-exact pre-phase renderer output (the phase-138 `NO_QUOTE_INPUT`/`NO_QUOTE_PRE_PHASE_OUTPUT` shape), pinned in the node behaviour harness.
- **Known, documented limits (recorded in the module docstring, not fixed):** inline code spans are NOT placeholder-protected (pre-existing: `**bold**` inside backticks already transforms), so markdown syntax inside an inline code span transforms too; a URL containing a literal `)` or whitespace is a miss; 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.
- **NOT touched:** `app/**` (no server change in this phase), the CSP string, `document.js`'s `renderDocument` structure, `app.js`'s chip/figure builders, `styles.css` families outside `.md-*` + the `h5`/`h6` scale.
## Dependencies
- `179_git_clone_server_freeze` (todo) — numbering/pipeline order only. The code dependency is the shared renderer at the head of `complete/` (phases 44 tables, 138 blockquotes).
## Tasks
1. `01_inline_links.md` — the `[text](url)` → `<a class="md-link">` transform with the protocol allowlist in `markdown.js`.
2. `02_inline_images.md` — the `![alt](src)` → `<img class="md-img">` transform (ahead of the link rule) + the linked-image rule.
3. `03_heading_ladder.md` — `####` → `<h5>`, `#####`/`######` → `<h6>` in the ladder.
4. `04_styles_markdown_inline.md` — `.md-link`, `.md-img` and the `h5`/`h6` type scale in `styles.css`.
5. `05_unit_and_e2e.md` — `tests/unit/test_markdown_inline_links_images.py` + the phase's dedicated Playwright suite `tests/e2e/test_markdown_inline_links_images.py`, the full gates, the atomic commit.
## Testing & Quality
- **Unit:** `tests/unit/test_markdown_inline_links_images.py` (new, the phase-138 pattern): source pins (the chain order code → strong → em → **image → linked-image → link** → headings → lists; the allowlist function; the exact `md-link`/`md-img` markers; no `unescape`/`decodeURIComponent` anywhere in the file; the `.md-link`/`.md-img` CSS rules reference palette vars only) + node behaviour (link renders with `target`/`rel`; `javascript:`/`data:` inert; relative + `#fragment` + `mailto:` link without `target`; `&amp;` in a query string survives verbatim; image renders with `alt` (empty alt allowed), disallowed src inert; linked image wraps without a stray `[`; `####`→`<h5>`, `#####`/`######`→`<h6>`, `###`→`<h4>` unchanged; a link inside a fence stays code; a link/image inside a table cell renders; byte-identity fixture).
- **E2E (AGENTS rule 4):** `tests/e2e/test_markdown_inline_links_images.py`, run **in isolation** (`uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`, DB up).
- **Coverage:** **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`) — this phase adds no `app/` code, so the full suite staying green IS the gate.
- **Lint/types:** `uv run ruff check . && uv run pyright` clean.
## Completion Criteria
- [ ] `[text](url)` renders as `<a class="md-link">` in a chat answer, the thinking block, a restored shared chat, the viewer and the document modal; `javascript:`/`data:` URLs stay visible as inert text
- [ ] `![alt](src)` renders as `<img class="md-img" loading="lazy">` with an `alt` present; a non-allowlisted src stays inert text
- [ ] `####` renders `<h5>`, `#####`/`######` render `<h6>`; `#`/`##`/`###` are unchanged
- [ ] a link/image/`####` inside a fenced code block is not transformed; a cell-level link renders in a `.md-table`
- [ ] an input with none of the three constructs renders byte-identical (phase-44 + phase-138 suites pass unedited)
- [ ] `uv run pytest` green; coverage TOTAL >90%; the phase's E2E green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] one `--no-gpg-sign` Conventional Commit; phase dir moved to `.agents/phases/complete/` by the pipeline gate
## Locked decisions
- **A4 — `####` → `<h5>`; `#####` and `######` both → `<h6>`; `#`/`##` keep rendering as `<h3>` (owner-confirmed 2026-10-14, roadmap confirmation).**
- **A5 — link/image URL allowlist = `http:`, `https:`, `mailto:` (links), `/`-rooted + `./`/`../` relative paths, `#fragment`, and `data:image/*` (images). Everything else renders as inert text, never a live element (owner-confirmed 2026-10-14, roadmap confirmation).**
- **A6 — `[![alt](src)](url)` renders as a link wrapping the image (an explicit rule ahead of the two others), not as text with a stray bracket (assistant decision, surfaced here so the executor does not re-decide it).**
## Commit
```bash
git add frontend/ tests/ .agents/phases/ && git commit --no-gpg-sign -m "feat(ui): render markdown links, inline images, and h5/h6 headings in the shared renderer"
```
@@ -1,27 +0,0 @@
# Task 01 — The link transform in the shared renderer
**Phase:** `180_markdown_inline_links_images` · **Source:** `TODO.md:4` — "Markdown [links](some-address) aren't being render correctly and show up as plain text"
**Story:** n/a
## Objective
`renderMarkdown` turns `[text](url)` into `<a class="md-link" …>text</a>` in the shared `inline()` chain — through an explicit URL allowlist, escape-first, with every existing transform's output byte-identical.
## Work
1. `frontend/assets/markdown.js` — in `const inline = (s) => …`, insert one link `.replace()` **after** the `*em*` rule and **before** the `^### ` heading rule: `/(\[)([^\]]*)(\]\()([^)\s]+)(\))/g` written plainly as `/\[([^\]]*)\]\(([^)\s]+)\)/g` (empty link text allowed, no whitespace in the URL). The replacement is a FUNCTION, not a string, so the URL passes the allowlist check:
- `allowLinkUrl(url)` — returns the url for `^https?://`, `^mailto:`, `/`-rooted (`^/`), `^\.{1,2}/` relative, and `^#` fragment URLs; returns `null` for anything else (`javascript:`, `data:`, `vbscript:`, `file:`, `blob:`) — LOCKED A5;
- allowed → `<a class="md-link" href="<url>"[ target="_blank" rel="noopener noreferrer"]>text</a>`; the `target`/`rel` pair is added ONLY for `^https?://` (a `mailto:`/relative/fragment link stays in the same tab);
- not allowed → return the ORIGINAL matched substring untouched, so it renders as the inert escaped text it is today (never a live element, never dropped).
2. `frontend/assets/markdown.js` — pass the captured URL through VERBATIM: the chain's input is already `escapeHtml`-ed, so `&amp;`/`&#39;`/`&quot;` in a query string are already attribute-safe. No `decodeURIComponent`, no manual unescape, no re-escape (design D2 — this is the security boundary).
3. `frontend/assets/markdown.js` — module docstring: add the phase-180 paragraph (the chain order is now code → strong → em → **linked-image → image → link** (tasks 02 adds the first two ahead of this one) → headings → lists), the allowlist (LOCKED A5), the `target`/`rel` rule, and the documented limits (a URL containing a literal `)` or a space is a miss; inline code spans are not placeholder-protected, so markdown syntax inside `` `…` `` transforms — the pre-existing `**strong**` behaviour, unchanged here).
- ASSUMPTION: link text may itself contain inline markup (`**bold**`, `*em*`, `` `code` ``) — those rules run FIRST in the chain, so the text is already transformed when the link rule wraps it; no extra handling.
- ASSUMPTION: autolinks (`<https://x>`) and reference-style links (`[text][ref]`) stay out of scope — the TODO names the inline `[text](url)` form only.
## Testing & Quality
- Unit: pinned by task 05's file (source pins: the link rule sits between the `em` and heading rules, the marker is exactly `<a class="md-link"`, the file contains no `decodeURIComponent`/`unescape`; node behaviour: absolute link with `target="_blank" rel="noopener noreferrer"`, `mailto:`/relative/`#` link without them, `javascript:` + `data:` inert, `?a=1&b=2` keeping `&amp;`, an empty link text, a link inside a table cell).
- Coverage: n/a (frontend) — the suite + the pins are the gate.
## Completion Criteria
- [ ] node repro: `renderMarkdown("see [the docs](https://example.com/a?x=1&y=2)")` → a `<p>` containing `<a class="md-link" href="https://example.com/a?x=1&amp;y=2" target="_blank" rel="noopener noreferrer">the docs</a>`
- [ ] node repro: `renderMarkdown("[click](javascript:alert(1))")` contains no `<a` and no `href`
- [ ] every other existing construct (`strong`, `em`, `code`, `#`–`###`, lists, tables, fences, blockquotes) renders byte-identical to the pre-task renderer
- [ ] `uv run pytest` green (the renderer-dependent unit suites unedited); `uv run ruff check .` clean
@@ -1,27 +0,0 @@
# Task 02 — The image transform (and the linked-image rule) in the shared renderer
**Phase:** `180_markdown_inline_links_images` · **Source:** `TODO.md:3` — "Need SVG render support on the front end when citing … Also would be nice to click an svg and see a preview in the RAG tab rather than the xml" (the in-answer rendering half; the document-preview half is phase `181_svg_document_preview`)
**Story:** n/a
## Objective
`renderMarkdown` turns `![alt](src)` into a lazy `<img class="md-img">` (and `[![alt](src)](url)` into that image wrapped in a link), through the same URL allowlist as task 01 — so a diagram referenced inside markdown paints instead of printing its reference text.
## Work
1. `frontend/assets/markdown.js` — in the `inline()` chain, insert TWO rules **ahead of** task 01's link rule, in this order:
1. **linked image first:** `/\[!\[([^\]]]*)\]\(([^)\s]+)\)\]\(([^)\s]+)\)/g` → `<a class="md-link" href="url"[ target="_blank" rel="noopener noreferrer"]><img class="md-img" src="src" alt="alt" loading="lazy"></a>` (LOCKED A6 — without this rule the image rule consumes the inner `![…](…)` and the link rule then leaves a stray `[` in the link text). Both URLs are allowlist-checked (`allowLinkUrl` for the href, `allowImageUrl` for the src); either failing → the original text is returned untouched.
2. **image second:** `/!\[([^\]]*)\]\(([^)\s]+)\)/g` → `<img class="md-img" src="src" alt="alt" loading="lazy">`.
2. `frontend/assets/markdown.js` — add `allowImageUrl(url)` next to task 01's `allowLinkUrl`: allows `/`-rooted served paths (`/api/documents/<id>/image`, `/assets/…`), `^.{0,2}/` relative paths, `^https?://` and `^data:image/` — the `data:` case exists because the app's own CSP (`app/core/security_headers.py::CSP`, `img-src 'self' data:`) already permits `data:` images; everything else (`javascript:`, `file:`, non-image `data:`) returns `null` → the original escaped text stays visible and inert (LOCKED A5).
3. `frontend/assets/markdown.js` — `alt` is ALWAYS emitted, escaped-passthrough like the URL, empty allowed (`alt=""` for a decorative image) — an `<img>` with no `alt` attribute fails WCAG 1.1.
4. `frontend/assets/markdown.js` — docstring: record the final chain order (code → strong → em → **linked-image → image → link** → headings → lists), the image allowlist, and the documented limit that a size-less SVG (`viewBox` only) in `.md-img` paints at the replaced-element default box (vector sizing is phase 181's `04_svg_preview_render.md` contract).
- ASSUMPTION: no `onerror` handler is added — the renderer emits an HTML STRING into `innerHTML`-style surfaces and the no-CDN/CSP posture forbids inline handlers; a broken src degrades to the browser's alt text (the house pattern for the shared renderer). The JS `onerror` fallbacks live in `document.js`/`app.js`, which do not route through this function.
- ASSUMPTION: title syntax (`![alt](src "title")`) is a miss (the URL capture forbids whitespace) — out of scope.
## Testing & Quality
- Unit: pinned by task 05 (chain order pins the two image rules before the link rule; markers `<img class="md-img"` / `loading="lazy"`; `alt=` always present; node behaviour: served-path image, `data:image/*` image, disallowed-protocol image inert, linked image produces `<a class="md-link">` wrapping `<img class="md-img">` with no stray bracket, empty alt allowed, an image inside a table cell renders).
- Coverage: n/a (frontend).
## Completion Criteria
- [ ] node repro: `renderMarkdown('![topology](/api/documents/aaaa/image)')` → `<p><img class="md-img" src="/api/documents/aaaa/image" alt="topology" loading="lazy"></p>`
- [ ] node repro: `renderMarkdown('[![d](/a.svg)](https://example.com)')` → one `<a class="md-link"` containing one `<img class="md-img"`, and the output contains no stray `[` or `]`
- [ ] node repro: `renderMarkdown('![x](javascript:alert(1))')` contains no `<img` and no `src=`
- [ ] input with no image reference renders byte-identical; `uv run pytest` green
@@ -1,27 +0,0 @@
# Task 03 — Extend the heading ladder to `####`, `#####`, `######`
**Phase:** `180_markdown_inline_links_images` · **Source:** `TODO.md:5` — "Quad-level headers (####) aren't being rendered and show up as plain text"
**Story:** n/a
## Objective
The ladder renders `#### x` as `<h5>x</h5>` and `##### x` / `###### x` as `<h6>x</h6>`; `#`, `##` and `###` keep their existing output exactly.
## Work
1. `frontend/assets/markdown.js` — in the `inline()` chain, insert TWO rules immediately BEFORE the existing `/^### (.*)$/gm → "<h4>$1</h4>"` rule (deeper-first, so the ladder reads top-down by depth):
- `/^#### (.*)$/gm` → `"<h5>$1</h5>"`
- `/^#{5,6} (.*)$/gm` → `"<h6>$1</h6>"` (LOCKED A4: both `#####` and `######` map to `<h6>` — the app's type scale stops at two levels below `###`, no `<h7>` exists in HTML)
The rules cannot cross-match each other or the existing ones because every heading rule requires a space after its hash run (`####` never matches `/^### /`).
2. `frontend/assets/markdown.js` — leave `/^## /` and `/^# /` mapping to `<h3>` UNTOUCHED (LOCKED A4 — the existing flattening of `#`/`##` to `<h3>` is deliberate; the phase adds levels below `###`, it does not re-rank the existing ones).
3. `frontend/assets/markdown.js` — the step-3 paragraph guard `/^<(h\d|ul|ol|pre|li|blockquote)/` already matches `h5`/`h6`, so NO change is needed there: pin it with a unit assertion rather than editing it.
4. `frontend/assets/markdown.js` — docstring: record the ladder (`#`/`##` → `h3`, `###` → `h4`, `####` → `h5`, `#####`/`######` → `h6`) so the deliberate flattening is documented, not re-discovered.
- ASSUMPTION: a heading line must start at column 0 of its line (the `m` flag, the existing rule shape) — an indented `#### x` stays plain text, matching `^### ` today.
## Testing & Quality
- Unit: pinned by task 05 (source pins: the two new rules sit before `/^### /`; the guard regex unchanged; node behaviour: `#### A` → `<h5>A</h5>`, `##### A`/`###### A` → `<h6>A</h6>`, `### A` → `<h4>A</h4>` unchanged, `## A`/`# A` → `<h3>` unchanged, a `####` line inside a fence stays code, a `####` heading immediately followed by a paragraph is not wrapped in `<p>`).
- Coverage: n/a (frontend).
## Completion Criteria
- [ ] node repro: `renderMarkdown("#### Deep")` → `<h5>Deep</h5>` (no `<p>` wrapper, no literal `####`)
- [ ] node repro: `renderMarkdown("##### Deeper\n###### Deepest")` → two `<h6>` elements
- [ ] node repro: a `####` line inside a ``` fence renders inside `<pre><code>` unchanged
- [ ] existing heading output byte-identical; `uv run pytest` green (phase-44/138 suites unedited)
@@ -1,26 +0,0 @@
# Task 04 — Styling for `.md-link`, `.md-img` and the `h5`/`h6` type scale
**Phase:** `180_markdown_inline_links_images` · **Source:** `TODO.md:4–5` (link + heading rendering), `TODO.md:3` (inline image rendering)
**Story:** n/a
## Objective
The three new elements are legible and theme-correct in every renderer context (chat bubble — brain and user, the thinking block, the shared page, the viewer, the document modal), built from the existing `:root` palette only.
## Work
1. `frontend/assets/styles.css` — `.md-link` next to the `.md-quote` family (L561-588): `color: var(--brand-ink)` (the file's documented AA-safe brand text token, 9.0:1 on `--surface`), `text-decoration: underline` (a link must not rely on colour alone — WCAG 1.4.1), `text-underline-offset: 2px`, `:hover { color: var(--brand-hover) }`. The global `:focus-visible { outline: 3px solid var(--brand) }` (L162-166) already covers the link — do NOT add a competing focus rule, and do not reset the outline.
2. `frontend/assets/styles.css` — `.msg.user .bubble .md-link { color: var(--bg); }` — the exact inversion the `.md-quote` precedent applies at L588 (the user bubble is a `--brand` fill: `--bg` on `--brand` is 5.2:1, AA). Add the same inversion for `:hover` if the default `--brand-hover` fails contrast on the brand fill — a colour-mix toward `--bg` is acceptable, a new hue is not.
3. `frontend/assets/styles.css` — `.md-img`: `display: inline-block; max-width: 100%; height: auto; object-fit: contain;` plus the `.source-image-img` card language (`background: var(--surface)`, `border: 1px solid var(--line)`, `border-radius: var(--radius-sm)`) and `margin: 0.4rem 0`. No fixed width/height — an oversized screenshot must never widen the page.
4. `frontend/assets/styles.css` — the deep-heading type scale, ONE rule set for all renderer contexts (no chat-specific duplication): `.doc-md h5, .msg .bubble h5, .thinking-text h5 { margin: 0.8rem 0 0.3rem; font-size: 0.95rem; }` and the `h6` sibling at `0.88rem`, following the existing `.doc-md h3` / `.doc-md h4` rhythm (L5282-5283) — the scale stays monotonic (`h3` 1.15 → `h4` 1.0 → `h5` 0.95 → `h6` 0.88) and every level keeps a visible size difference from body text.
5. `frontend/assets/styles.css` — comment block above each rule in the house style: the phase number, the TODO source line, the contrast math for every non-obvious colour, and the "static content, no animation" note (the background-motion invariant).
- ASSUMPTION: `h5`/`h6` in the chat bubble get explicit sizing (they inherit UA defaults today, which are larger than `0.95rem`) so a deep heading in an answer cannot out-shout the answer text.
- ASSUMPTION: no `.md-img--vector` modifier here — vector (viewBox-only SVG) sizing belongs to `181_svg_document_preview/04_svg_preview_render.md`, which knows the document's format.
## Testing & Quality
- Unit: pinned by task 05 — the `.md-link`, `.md-img`, `h5`/`h6` rules exist and reference ONLY `var(--…)` palette tokens (no hex literals, no `rgb(`) — the established `:root`-drift pin pattern used by the blockquote/table CSS tests; the `.msg.user .bubble .md-link` inversion is present.
- Coverage: n/a (frontend).
## Completion Criteria
- [ ] a link in a brain bubble, a user bubble, the thinking block, the shared page, the viewer and the document modal is underlined and passes ≥4.5:1 against its surface
- [ ] Tab focus on a link shows the app's 3px `--brand` ring
- [ ] a wide image in an answer does not produce a horizontal scrollbar on the chat page at 375px and at 1440px
- [ ] `uv run pytest` green (the CSS-pinning unit suites unedited)
@@ -1,36 +0,0 @@
# Task 05 — Unit pins, the dedicated Playwright suite, the gates, the commit
**Phase:** `180_markdown_inline_links_images` · **Source:** `TODO.md:3–5` (the acceptance evidence for tasks 01–04)
**Story:** n/a
## Objective
Lock the three renderer fixes with a source-pin + node-behaviour unit file and the phase's own Playwright E2E suite (AGENTS rules 4/9), run every gate, and land the single atomic commit.
## Work
1. `tests/unit/test_markdown_inline_links_images.py` (new — the `tests/unit/test_markdown_blockquotes.py` shape, copy its `HAVE_NODE = shutil.which("node") is not None` skip and its `subprocess` node harness):
- **source pins on `frontend/assets/markdown.js`:** the chain order is code → strong → em → linked-image → image → link → `^#### ` → `^#{5,6} ` → `^### ` → `^## ` → `^# ` → lists (assert the index of each rule's literal in the file is strictly increasing); the exact markers `<a class="md-link"`, `<img class="md-img"`, `loading="lazy"`, `<h5>`, `<h6>`; `allowLinkUrl` / `allowImageUrl` named functions exist; the file contains NO `decodeURIComponent` / `unescape` / `innerHTML`; the step-3 guard regex is still exactly `/^<(h\d|ul|ol|pre|li|blockquote)/`.
- **CSS pins on `frontend/assets/styles.css`:** `.md-link`, `.md-img`, the `h5` and `h6` rules exist and their colour values reference `var(--…)` only (no hex, no `rgb(`); `.msg.user .bubble .md-link` exists (the inversion).
- **node behaviour (skipped without node):** every repro listed in tasks 01–04's Completion Criteria, PLUS the byte-identity contract — a checked-in `NO_INLINE_INPUT` / `NO_INLINE_PRE_PHASE_OUTPUT` fixture pair (a paragraph + a list + a pipe table + a fence + a blockquote, i.e. everything the renderer already supported) asserted byte-equal.
2. `tests/fixtures/links_docs/` (new dedicated fixture tree — the phase-138 `quote_docs` precedent: a NEW tree, never the shared 13-doc fixture set, whose import count many suites pin). One document `homelab/inline-markdown.md` whose body carries: a `[absolute link](https://example.com/a?x=1&y=2)`, a `[relative link](/sources)`, a `mailto:` link, a `#anchor`, an inert `[oops](javascript:alert(1))` probe, `![topology](data:image/svg+xml;base64,…)` (a tiny inline SVG — CSP `img-src 'self' data:` already allows it, no bytes fixture needed), an inert `![bad](javascript:alert(1))`, a `[![linked](data:…)](https://example.com)`, and an `#### Deep header` + `##### Deeper` line, plus a pipe table with a `[cell link](https://example.com)` inside a cell.
3. `tests/e2e/test_markdown_inline_links_images.py` (new — the phase-138 E2E skeleton verbatim in structure: module-level fixture constants, `_import_fixtures` + `_run_in_thread` + `_reset_db` (truncate `chunks, documents, query_log, steering_notes, kb_overview` then re-import, `summary.added == 1`), `login(page, app_url, next="/")`, the mock LLM trigger answering with the byte-stable inline-markdown answer):
- `test_chat_inline_markdown_renders` — the brain bubble contains `a.md-link` elements for the absolute (with `target="_blank"`, `rel` containing `noopener noreferrer`, `href` preserving `&amp;`), relative and `mailto:` links; NO `a.md-link` for the `javascript:` probe (its text stays visible); `img.md-img` for the data-URL image with a non-empty `alt` and `loading="lazy"`; the linked image is an `a.md-link > img.md-img` with no stray bracket in its text; `h5`/`h6` elements exist; `table.md-table a.md-link` exists (cell-level transform); the turn is NOT deflected.
- `test_viewer_inline_markdown_renders` — the fixture document opens from the Sources page in the document modal (admin, `openDocumentModal`) and renders the same `a.md-link` / `img.md-img` / `h5` / `h6` shapes (the shared renderer contract across surfaces).
- `test_restore_keeps_inline_markdown` — reload the chat page: the localStorage restore path re-runs `renderMarkdown` and the restored brain bubble keeps its links, image and headings.
- `test_no_horizontal_overflow_at_mobile` — at `viewport={"width": 375, "height": 720}`, `document.documentElement.scrollWidth <= clientWidth` with the wide image rendered (the `.md-img` `max-width: 100%` contract).
4. Run the gates, in order, and fix what fails (do not baseline, do not skip):
- `uv run pytest`
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL >90%
- `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov` (in isolation, DB up: `podman compose up -d db`)
- `uv run ruff check . && uv run pyright`
5. Move the phase directory to `.agents/phases/complete/` and commit — see the overview's Commit block.
## Testing & Quality
- This task IS the phase's test gate. The unit file must fail if any of tasks 01–03's rules are reordered or removed, and must fail if task 04's CSS introduces a hard-coded colour.
- Coverage: **>90%** on `app/` (no `app/` code changes in this phase — the number must not move).
## Completion Criteria
- [ ] `uv run pytest` green, including the new unit file (and green with node absent — the skip path)
- [ ] `uv run pytest --cov=app --cov-report=term-missing` TOTAL >90%
- [ ] `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov` green in isolation
- [ ] `uv run ruff check . && uv run pyright` clean
- [ ] one atomic commit, `--no-gpg-sign`: `feat(ui): render markdown links, inline images, and h5/h6 headings in the shared renderer`, containing `frontend/`, `tests/` and `.agents/`