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
+58
View File
@@ -102,6 +102,64 @@ empty rectangle (nothing replaces the box: the existing loading feedback
is the turn's visible state). The same rule covers a restored or shared
answer whose persisted text is empty, which paints no box either.
**Markdown links render as links** (phase 180, TODO.md L4): an inline
`[text](url)` in an answer, the thinking block, a saved/shared chat, the
document viewer or the document modal — every surface that goes through
the one shared renderer — becomes a real `<a class="md-link">`. Only
allowlisted URLs become live elements: `http(s)://` (which also carries
`target="_blank" rel="noopener noreferrer"`), `mailto:`, `/`-rooted and
`./`/`../` relative paths, and `#fragment` links — the last three stay in
the same tab and carry no `target`. Anything else (`javascript:`,
`data:`, `vbscript:`, `file:`, `blob:`) renders as the plain
`[text](url)` text it is today, never an element. The URL is used
verbatim from the already-escaped text, so a `&` in a query string stays
`&amp;` and nothing is ever unescaped (escape-first is the security
boundary); a URL containing a space or a literal `)` is a documented miss.
Styling is one `.md-link` rule for every surface: underlined (a link never
relies on colour alone) in the theme's AA-safe brand text colour — 9.9:1 on
the answer bubble, 8.3:1 on a deflected one — and the user bubble's own
ink on the brand fill (5.2:1), where the underline is what marks the link;
Tab focus is the app's global 3px `--brand` ring.
**Markdown images render as images** (phase 180, the in-answer half of
TODO.md L3): an inline `![alt](src)` — and a linked image
`[![alt](src)](url)`, which renders as that image wrapped in the link —
becomes a lazy `<img class="md-img" loading="lazy">` on every surface that
shares the renderer, so a diagram referenced inside an answer or a
document paints instead of printing its reference text. `alt` is always
emitted (empty allowed for a decorative image). The image allowlist is
its own function and accepts `/`-rooted served paths
(`/api/documents/<id>/image`, `/assets/…`), relative paths whose first
segment is at most two characters (`./x`, `../x`, `d/x` — a longer first
segment like `images/x.png` is a documented miss), `http(s)://` and
`data:image/…` — the `data:` case needs no CSP change
because the app's own `img-src 'self' data:` already permits it. A
`javascript:`, `file:`, `blob:` or non-image `data:` src stays visible as
the inert `![alt](src)` text it is today, never an element, and a broken
served path degrades to the browser's alt text (the renderer emits an HTML
string, so it carries no inline `onerror` handler — the JS fallbacks live
in `document.js`/`app.js`). `.md-img` carries the app's bordered-image card
treatment and is capped to its container — `max-width: 100%`, `height: auto`
and `object-fit: contain` — so a wide screenshot never produces a horizontal
scrollbar at 375px or at 1440px. Sizing an SVG that declares no `width` or
`height` is deliberately left to phase 181.
**The heading ladder reaches six levels** (phase 180, TODO.md L5): a
`#### x` line in an answer, the thinking block, a saved/shared chat, the
document viewer or the modal renders `<h5>x</h5>`, and `##### x` /
`###### x` render `<h6>x</h6>` (HTML stops at `h6`). The levels above are
UNCHANGED and deliberately flattened — `#`/`##` both render `<h3>`, `###`
renders `<h4>` — so an answer's deep sections gain the two rungs below
`###` without re-ranking what already rendered. Like every other inline
rule the ladder needs a space after its hashes and sits at column 0 of its
line, so a mid-sentence `####` and an indented `#### x` stay plain text,
and a `####` inside a fenced block stays code. The two new rungs are sized
into the existing type scale — `h3` 1.15rem → `h4` 1rem → `h5` 0.95rem →
`h6` 0.88rem, margins tightening with depth — so a deep heading neither
collapses into body text (the UA default is 0.83em/0.67em) nor out-shouts
the answer around it, in the bubble, the thinking block and the viewer
alike.
The document's meta row (the chat modal **and** the standalone viewer at
`/document.html`) carries a **Copy path** button: one click copies the
document's `source/path` (the exact form the `read` tool, the chips, and