diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 26c817cde..a8d082f0b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -64,7 +64,7 @@ flowchart LR
| `chunking.py` | Splits a document into meaning-identifiable units (paragraph, sentence, DOM, conversation-turn) plus embedded-image extraction, in document order |
| `embedding_client.py` | Pluggable text-embedding channel (`Null` default, `OpenAiCompatible` real impl) + `chunked_max_similarity` |
| `adjudication_client.py` | Pluggable LLM-judgment channel (`Null` default, `ContextualOrchestrator` real impl) |
-| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl) |
+| `image_content.py` | Pluggable vision channel: OCR + object recognition/tagging for embedded images (`Null` default, `OpenAiCompatibleVisionClient` real impl). The product popup (`frontend/src/PostBody.tsx`) renders each `data:image` payload in document order so the buyer sees the picture, not the base64 string; GET does not call the vision client. |
| `tepp_client.py` | TEPP's published `AnalysisRunRequest` wire contract, pluggable transport |
| `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread |
| `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bf4dc62a..fd8717513 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.86.1] - 2026-08-16
+
+### Changed
+
+- Opening a post or its evidence panel now shows each embedded
+ `data:image` picture in document order, with the surrounding sentences
+ as text. The raw base64 string is no longer dumped into the popup.
+ Remote `http(s)` image URLs stay unloaded. After `make seed`, a post
+ whose body includes a data-URI image shows the picture; Extract Keyman
+ or Ask still runs OCR on that image for search.
+
## [0.86.0] - 2026-08-16
### Added
diff --git a/docs/image-content-schema.md b/docs/image-content-schema.md
index f023d56a0..4dae03dcf 100644
--- a/docs/image-content-schema.md
+++ b/docs/image-content-schema.md
@@ -82,6 +82,18 @@ picture sat relative to the surrounding paragraphs."
| `chunk_position` | `integer not null` | 0-based index among ALL of this document's chunks (text and image together) -- matches `Chunk.index` from `chunk_by_dom` |
| primary key | `(source_document_id, chunk_position)` | one image slot per position per document |
+## Viewer contract (before persistence exists)
+
+The demo popup does not yet read these tables. It splits the live
+`post_body` the same way `extract_base64_images` does: each
+`data:image/...;base64,...` payload becomes an ` Quote attached. Please confirm. {post.post_body}` at its original
+character offset, and the surrounding HTML is shown as text. A buyer who
+opens the post sees the picture that sat between the paragraphs, not the
+base64 wall. Remote `src="https://..."` tags are stripped, never fetched.
+OCR, caption, and tag search still require the vision client on extract /
+Ask (Li et al., 2023; Radford et al., 2021) and, in a real deployment,
+the tables below.
+
## Query shapes this supports
- **"Find images whose extracted text or tags match a search query, then
@@ -105,3 +117,17 @@ picture sat relative to the surrounding paragraphs."
ON CONFLICT DO NOTHING` before the provider call, or a short-lived
lease row) to close that race; this schema documents the storage
guarantee, not that concurrency control.
+
+## References
+
+Li, M., Lv, T., Chen, J., Cui, L., Lu, Y., Florencio, D., Zhang, C., Li, Z.,
+& Wei, F. (2023). TrOCR: Transformer-based optical character recognition
+with pre-trained models. *Proceedings of the AAAI Conference on Artificial
+Intelligence, 37*(11), 13094–13102. https://doi.org/10.1609/aaai.v37i11.26538
+
+Radford, A., Kim, J. W., Hallacy, C., Ramesh, A., Goh, G., Agarwal, S.,
+Sastry, G., Askell, A., Mishkin, P., Clark, J., Krueger, G., & Sutskever, I.
+(2021). Learning transferable visual models from natural language
+supervision. In M. Meila & T. Zhang (Eds.), *Proceedings of the 38th
+International Conference on Machine Learning* (pp. 8748–8763). PMLR.
+https://proceedings.mlr.press/v139/radford21a.html
diff --git a/frontend/package.json b/frontend/package.json
index dac241738..803acd91b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.86.0",
+ "version": "0.86.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 8f38b4dd0..b3fab25d1 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -108,9 +108,36 @@
}
.post-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--post-body-gap);
+}
+
+.post-body-text {
+ margin: 0;
white-space: pre-wrap;
}
+.post-embedded-image {
+ margin: 0;
+ padding: var(--post-image-padding);
+ border: 1px solid var(--post-image-border);
+ border-radius: var(--post-image-radius);
+ background: var(--post-image-bg);
+}
+
+.post-embedded-image img {
+ display: block;
+ max-width: 100%;
+ height: auto;
+}
+
+.post-embedded-image figcaption {
+ margin-top: 0.4rem;
+ font-size: 0.85rem;
+ color: var(--text);
+}
+
.popup-placeholder {
margin-top: 1.5rem;
padding: 1rem;
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 8618fcfbf..65d25259b 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -63,6 +63,7 @@ describe("App, authenticated", () => {
failedReportRun?: boolean;
succeededTeppRun?: boolean;
pendingTeppRun?: boolean;
+ postBody?: string;
}) {
const statusLabel: Record
{post.post_title}
-
{post.post_body}
++ {segment.text} +
+ ); + case "image": + return ( +Quote attached.
Please confirm.
`; + const segments = splitPostBody(html); + + expect(segments).toEqual([ + { kind: "text", text: "Quote attached." }, + { + kind: "image", + src: `data:image/png;base64,${TINY_PNG_B64}`, + mimeType: "image/png", + position: html.indexOf("between
` + + `See

end
'; + const segments = splitPostBody(html); + expect(segments.every((segment) => segment.kind === "text")).toBe(true); + expect(segments.map((segment) => (segment.kind === "text" ? segment.text : "")).join(" ")).toContain( + "See", + ); + expect(JSON.stringify(segments)).not.toContain("https://example.test"); + }); +}); diff --git a/frontend/src/postBodyDisplay.ts b/frontend/src/postBodyDisplay.ts new file mode 100644 index 000000000..c6ea29fdd --- /dev/null +++ b/frontend/src/postBodyDisplay.ts @@ -0,0 +1,72 @@ +/** + * Split a raw `post_body` into text and in-place data-URI images. + * + * The popup used to dump the source string, so a buyer who opened a post + * with an embedded invoice saw a base64 wall instead of the picture. + * Only `data:image/...;base64,...` payloads are turned into images — + * remote `http(s)` img tags are stripped, never fetched. + */ + +export type PostBodySegment = + | { kind: "text"; text: string } + | { kind: "image"; src: string; mimeType: string; position: number }; + +const DATA_URI_IMG = + /