phase: 180_markdown_inline_links_images
All verification complete. Final state summary: **Report — phase 180 final verification pass** - Verified (all already implemented in working tree): `inline()` chain = code→strong→em→linked-image→image→link→headings→lists in `frontend/assets/markdown.js`; `allowLinkUrl`/`allowImageUrl` allowlists (A5), shared `mdAnchorOpen` (target/rel only for absolute http(s)); `####`→`<h5>`, `#####`/`######`→`<h6>`, `#`/`##`→`<h3>`/`###`→`<h4>` unchanged; `.md-link`/`.md-img`/h5-h6 scale in `styles.css` (palette vars only). All surfaces (chat, thinking, restore, viewer, modal) route through the one renderer (pinned in `app.js`/`document.js`). - Completion criteria: - `[text](url)`→`a.md-link`, `javascript:`/`data:` inert — ✅ (node probe + unit + E2E) - ``→`img.md-img loading="lazy"` with `alt`; bad src inert — ✅ - `####`/`#####`/`######` ladder; shallow rungs unchanged — ✅ - Fenced link/image/`####` stay code; cell-level link renders in `.md-table` — ✅ (node probe) - Byte-identity fixture + phase-44/138 suites pass unedited (git-verified untouched; 6 + 3 E2E pass in isolation) — ✅ - `uv run pytest --cov=app --cov-report=term-missing`: **4198 passed, TOTAL 99%**; `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`: **4 passed**; `uv run ruff check .`: clean; `uv run pyright`: **0 errors** — ✅ - Docs: full README/AGENTS.md review vs the whole project — env-example↔README table complete, page routes match `app/main.py`, AGENTS quick-ref artifacts exist. Fixed 1 inconsistency: README understated the image allowlist (impl per task 02 accepts first segments ≤2 chars, `^.{0,2}/`, not only `./`/`../`); phase-180 paragraphs otherwise accurate. - Notable: image allowlist `.{0,2}/` is broader than the overview's `^\.{1,2}/` but is what task 02 explicitly specifies and the docstring documents — not a deviation from the tasks; SVG sizing deliberately deferred to phase 181. - Next pending phase: `181_svg_document_preview`.
This commit is contained in:
@@ -102,6 +102,64 @@ empty rectangle (nothing replaces the box: the existing loading feedback
|
||||
is the turn's visible state). The same rule covers a restored or shared
|
||||
answer whose persisted text is empty, which paints no box either.
|
||||
|
||||
**Markdown links render as links** (phase 180, TODO.md L4): an inline
|
||||
`[text](url)` in an answer, the thinking block, a saved/shared chat, the
|
||||
document viewer or the document modal — every surface that goes through
|
||||
the one shared renderer — becomes a real `<a class="md-link">`. Only
|
||||
allowlisted URLs become live elements: `http(s)://` (which also carries
|
||||
`target="_blank" rel="noopener noreferrer"`), `mailto:`, `/`-rooted and
|
||||
`./`/`../` relative paths, and `#fragment` links — the last three stay in
|
||||
the same tab and carry no `target`. Anything else (`javascript:`,
|
||||
`data:`, `vbscript:`, `file:`, `blob:`) renders as the plain
|
||||
`[text](url)` text it is today, never an element. The URL is used
|
||||
verbatim from the already-escaped text, so a `&` in a query string stays
|
||||
`&` and nothing is ever unescaped (escape-first is the security
|
||||
boundary); a URL containing a space or a literal `)` is a documented miss.
|
||||
Styling is one `.md-link` rule for every surface: underlined (a link never
|
||||
relies on colour alone) in the theme's AA-safe brand text colour — 9.9:1 on
|
||||
the answer bubble, 8.3:1 on a deflected one — and the user bubble's own
|
||||
ink on the brand fill (5.2:1), where the underline is what marks the link;
|
||||
Tab focus is the app's global 3px `--brand` ring.
|
||||
|
||||
**Markdown images render as images** (phase 180, the in-answer half of
|
||||
TODO.md L3): an inline `` — and a linked image
|
||||
`[](url)`, which renders as that image wrapped in the link —
|
||||
becomes a lazy `<img class="md-img" loading="lazy">` on every surface that
|
||||
shares the renderer, so a diagram referenced inside an answer or a
|
||||
document paints instead of printing its reference text. `alt` is always
|
||||
emitted (empty allowed for a decorative image). The image allowlist is
|
||||
its own function and accepts `/`-rooted served paths
|
||||
(`/api/documents/<id>/image`, `/assets/…`), relative paths whose first
|
||||
segment is at most two characters (`./x`, `../x`, `d/x` — a longer first
|
||||
segment like `images/x.png` is a documented miss), `http(s)://` and
|
||||
`data:image/…` — the `data:` case needs no CSP change
|
||||
because the app's own `img-src 'self' data:` already permits it. A
|
||||
`javascript:`, `file:`, `blob:` or non-image `data:` src stays visible as
|
||||
the inert `` text it is today, never an element, and a broken
|
||||
served path degrades to the browser's alt text (the renderer emits an HTML
|
||||
string, so it carries no inline `onerror` handler — the JS fallbacks live
|
||||
in `document.js`/`app.js`). `.md-img` carries the app's bordered-image card
|
||||
treatment and is capped to its container — `max-width: 100%`, `height: auto`
|
||||
and `object-fit: contain` — so a wide screenshot never produces a horizontal
|
||||
scrollbar at 375px or at 1440px. Sizing an SVG that declares no `width` or
|
||||
`height` is deliberately left to phase 181.
|
||||
|
||||
**The heading ladder reaches six levels** (phase 180, TODO.md L5): a
|
||||
`#### x` line in an answer, the thinking block, a saved/shared chat, the
|
||||
document viewer or the modal renders `<h5>x</h5>`, and `##### x` /
|
||||
`###### x` render `<h6>x</h6>` (HTML stops at `h6`). The levels above are
|
||||
UNCHANGED and deliberately flattened — `#`/`##` both render `<h3>`, `###`
|
||||
renders `<h4>` — so an answer's deep sections gain the two rungs below
|
||||
`###` without re-ranking what already rendered. Like every other inline
|
||||
rule the ladder needs a space after its hashes and sits at column 0 of its
|
||||
line, so a mid-sentence `####` and an indented `#### x` stay plain text,
|
||||
and a `####` inside a fenced block stays code. The two new rungs are sized
|
||||
into the existing type scale — `h3` 1.15rem → `h4` 1rem → `h5` 0.95rem →
|
||||
`h6` 0.88rem, margins tightening with depth — so a deep heading neither
|
||||
collapses into body text (the UA default is 0.83em/0.67em) nor out-shouts
|
||||
the answer around it, in the bubble, the thinking block and the viewer
|
||||
alike.
|
||||
|
||||
The document's meta row (the chat modal **and** the standalone viewer at
|
||||
`/document.html`) carries a **Copy path** button: one click copies the
|
||||
document's `source/path` (the exact form the `read` tool, the chips, and
|
||||
|
||||
Reference in New Issue
Block a user