From 8054016cc9d6f58534cc7601edea5341693e2490 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 09:15:07 +0000 Subject: [PATCH 01/10] feat: overlay persisted image-region boxes on source figures Draw buyer-facing region locators from stored x/y/width/height ratios when the source image can be reattached. Invalid boxes and missing bitmaps stay list-only. Never invent coordinates or show LLM instructions. ADR 0111, v2.13.2. --- AGENTS.md | 8 +- CHANGELOG.d/2.13.2-image-region-overlays.md | 15 +++ .../0110-buyer-image-evidence-rendering.md | 2 +- docs/adr/0111-buyer-image-region-overlays.md | 32 ++++++ frontend/package.json | 2 +- frontend/src/App.css | 50 ++++++++++ frontend/src/PostBody.test.tsx | 77 +++++++++++++++ frontend/src/PostBody.tsx | 99 +++++++++++++++---- frontend/src/i18n.test.ts | 3 + frontend/src/i18n.ts | 8 ++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 13 files changed, 278 insertions(+), 24 deletions(-) create mode 100644 CHANGELOG.d/2.13.2-image-region-overlays.md create mode 100644 docs/adr/0111-buyer-image-region-overlays.md diff --git a/AGENTS.md b/AGENTS.md index aa93be63e..6688e2e5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,10 @@ contextual-orchestrator owns model discovery and selection. retaining the original asset and provenance. Recognize image DOM/visual regions before OCR, descriptions, Keyman extraction, or embeddings. Store region-level evidence; never show an internal LLM instruction such as - `This post is an image` to a buyer. + `This post is an image` to a buyer. When a source image can be reattached, + draw only persisted visual-region boxes; never invent coordinates. If the + bitmap is missing or a box is invalid, keep the region list and do not + fabricate a locator (ADR 0110 / 0111). ## Source parsing and semantic units @@ -146,7 +149,8 @@ contextual-orchestrator owns model discovery and selection. paragraphs, not the authoring application's spacing workaround. - Image descriptions, OCR text, and region evidence are analysis artifacts, not buyer-facing prompt instructions. Buyer UI shows the source content and - useful captions/evidence only, with provenance where appropriate. + useful captions/evidence only, with provenance where appropriate. Overlay + boxes use stored ratios; a missing image fails closed to the list. ## Pluggable channels: never fake a missing signal diff --git a/CHANGELOG.d/2.13.2-image-region-overlays.md b/CHANGELOG.d/2.13.2-image-region-overlays.md new file mode 100644 index 000000000..a8aa057da --- /dev/null +++ b/CHANGELOG.d/2.13.2-image-region-overlays.md @@ -0,0 +1,15 @@ +# 2.13.2 — Overlay persisted image-region boxes + +## Added + +- Buyer post figures now draw persisted visual-region boxes on a reattached + source image. Selecting a box announces that region's caption or OCR. +- Invalid, missing, or out-of-bounds coordinates stay list-only. When the + bitmap cannot be reattached, the region list remains the fallback and no + locator is invented. + +## Changed + +- Image-region overlay and current-region labels are translated through the + five-locale catalog (Korean, English fallback, Chinese, Japanese, + Vietnamese). diff --git a/docs/adr/0110-buyer-image-evidence-rendering.md b/docs/adr/0110-buyer-image-evidence-rendering.md index d9f8e457b..803d75939 100644 --- a/docs/adr/0110-buyer-image-evidence-rendering.md +++ b/docs/adr/0110-buyer-image-evidence-rendering.md @@ -1,4 +1,4 @@ -# ADR 0104: Render image evidence as buyer content, not LLM instructions +# ADR 0110: Render image evidence as buyer content, not LLM instructions - Status: Accepted - Date: 2026-08-20 diff --git a/docs/adr/0111-buyer-image-region-overlays.md b/docs/adr/0111-buyer-image-region-overlays.md new file mode 100644 index 000000000..60c42e212 --- /dev/null +++ b/docs/adr/0111-buyer-image-region-overlays.md @@ -0,0 +1,32 @@ +# ADR 0111: Overlay persisted visual-region boxes on the source image + +- Status: Accepted +- Date: 2026-08-20 +- Depends on: [0067](0067-visual-region-vision-agent.md), [0091](0091-visual-region-embedding-persistence.md), [0110](0110-buyer-image-evidence-rendering.md) + +## Context + +ADR 0067 and ADR 0091 persist parent-relative `x_ratio`, `y_ratio`, +`width_ratio`, and `height_ratio` for each accepted visual region. ADR 0110 +renders captions, OCR, tags, and a region list, but the buyer still cannot +see which panel of the source image supplied that evidence. + +## Decision + +- When the source image can be reattached and a region has a finite persisted + box inside the unit square, draw that box on the figure as a + keyboard-focusable control. Selecting it announces the persisted caption or + OCR as the current image region. +- Never invent coordinates. Invalid, missing, non-finite, zero-area, or + out-of-bounds boxes stay list-only. When the source image cannot be + reattached, keep the region list and do not draw empty overlays. +- Do not surface LLM instructions from image `unit_text`. Overlay labels come + only from persisted region caption or extracted text. +- Translate overlay and current-region labels through the five-locale UI + catalog. + +## Consequences + +Buyers can locate panel-level evidence on the source image. Search and +embedding remain bound to the existing region tables. A missing bitmap still +fails closed to the list rather than fabricating a locator. diff --git a/frontend/package.json b/frontend/package.json index e2e996bbe..a8d0f7b80 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.6", + "version": "2.13.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 795ed2a3b..9f905f0e6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -349,6 +349,56 @@ height: auto; } +.post-embedded-image-frame { + position: relative; + display: inline-block; + max-width: 100%; +} + +.post-embedded-image-frame img { + display: block; + max-width: 100%; + height: auto; +} + +.post-image-region-overlays { + position: absolute; + inset: 0; +} + +.post-image-region-overlay { + position: absolute; + box-sizing: border-box; + margin: 0; + padding: 0; + border: 2px solid var(--color-accent-info); + background: var(--color-accent-info-background); + cursor: pointer; + appearance: none; +} + +.post-image-region-overlay[aria-pressed="true"] { + border-color: var(--color-accent); + background: var(--color-accent-background); +} + +.post-image-region-overlay:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.post-image-current-region { + margin: 0.4rem 0 0; + font-size: 0.85rem; + color: var(--text-h); +} + +@media (prefers-reduced-motion: no-preference) { + .post-image-region-overlay { + transition: border-color 160ms ease, background-color 160ms ease; + } +} + @media print { body * { visibility: hidden; diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx index 820a120a3..36ee7bdc7 100644 --- a/frontend/src/PostBody.test.tsx +++ b/frontend/src/PostBody.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import { PostBody } from "./PostBody"; @@ -188,5 +189,81 @@ describe("PostBody", () => { expect(screen.getByText("diagram, process")).toBeInTheDocument(); expect(screen.getByText("Main panel")).toBeInTheDocument(); expect(screen.queryByText(/This post is an image/)).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Main panel/ })).not.toBeInTheDocument(); + }); + + it("overlays persisted region boxes on a reattached source image", async () => { + const user = userEvent.setup(); + const source = + ''; + + render( + , + ); + + const overlay = screen.getByRole("button", { name: "Image region: Main panel" }); + expect(overlay).toHaveStyle({ + left: "10%", + top: "20%", + width: "30%", + height: "40%", + }); + expect(screen.queryByRole("button", { name: /Overflow panel/ })).not.toBeInTheDocument(); + expect(screen.queryByText(/This post is an image/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Invented box must not render/)).not.toBeInTheDocument(); + expect(screen.getByText("Overflow panel")).toBeInTheDocument(); + + overlay.focus(); + expect(overlay).toHaveFocus(); + await user.click(overlay); + expect(screen.getByText("Current image region: Main panel")).toBeInTheDocument(); }); }); diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx index 338951b3c..5fc14fde8 100644 --- a/frontend/src/PostBody.tsx +++ b/frontend/src/PostBody.tsx @@ -1,21 +1,79 @@ +import { useState, type ReactNode } from "react"; import { splitPostBody, type PostBodySegment } from "./postBodyDisplay"; import { t } from "./i18n"; -import type { PostContentUnit, PostImageContent } from "./api"; -import type { ReactNode } from "react"; +import type { PostContentUnit, PostImageContent, PostImageRegion } from "./api"; + +function hasPersistedOverlayBox(region: PostImageRegion): boolean { + const { x_ratio, y_ratio, width_ratio, height_ratio } = region; + if (![x_ratio, y_ratio, width_ratio, height_ratio].every((value) => Number.isFinite(value))) { + return false; + } + if (x_ratio < 0 || y_ratio < 0 || width_ratio <= 0 || height_ratio <= 0) { + return false; + } + return x_ratio + width_ratio <= 1 && y_ratio + height_ratio <= 1; +} + +function regionBuyerLabel(region: PostImageRegion): string { + return region.caption || region.extracted_text || t("Unknown"); +} + +function ImageEvidenceFigure({ + imageContent, + sourceImage, +}: { + imageContent?: PostImageContent; + sourceImage?: Extract; +}) { + const [selectedRegionIndex, setSelectedRegionIndex] = useState(null); + const regions = imageContent?.regions ?? []; + const overlayRegions = sourceImage ? regions.filter(hasPersistedOverlayBox) : []; + const selectedRegion = overlayRegions.find((region) => region.region_index === selectedRegionIndex); -function renderImageEvidence( - index: number, - imageContent?: PostImageContent, - sourceImage?: Extract, -) { return ( -
+
{sourceImage ? ( - {imageContent?.caption +
+ {imageContent?.caption + {overlayRegions.length ? ( +
+ {overlayRegions.map((region) => { + const label = regionBuyerLabel(region); + const pressed = selectedRegionIndex === region.region_index; + return ( +
+ ) : null} +
) : null} {imageContent?.caption || !sourceImage ? (
{imageContent?.caption || t("Embedded image")}
) : null} + {selectedRegion ? ( +

+ {t("Current image region")}: {regionBuyerLabel(selectedRegion)} +

+ ) : null} {imageContent?.tags.length ? (

{t("Image tags")}: {imageContent.tags.join(", ")} @@ -27,13 +85,13 @@ function renderImageEvidence(

{imageContent.extracted_text}

) : null} - {imageContent?.regions?.length ? ( + {regions.length ? (
{t("Image regions")}
    - {imageContent.regions.map((region) => ( + {regions.map((region) => (
  1. - {region.caption || region.extracted_text || t("Unknown")} + {regionBuyerLabel(region)} {region.tags.length ? ( {t("Image tags")}: {region.tags.join(", ")} @@ -67,7 +125,13 @@ function renderSegment(segment: PostBodySegment, index: number, imageContent?: P

    ); case "image": - return renderImageEvidence(index, imageContent, segment); + return ( + + ); default: { const _exhaustive: never = segment; throw new Error(`unexpected post body segment: ${JSON.stringify(_exhaustive)}`); @@ -104,9 +168,11 @@ function renderStructuredUnits( const sourceImage = sourceImages[imageOrdinal++]; const content = imageContent.find((item) => item.unit_index === unit.unit_index); rendered.push( - sourceImage - ? renderSegment(sourceImage, index, content) - : renderImageEvidence(index, content), + sourceImage ? ( + renderSegment(sourceImage, index, content) + ) : ( + + ), ); index += 1; continue; @@ -176,7 +242,6 @@ export function PostBody({
    {splitPostBody(body).map((segment, index) => { const content = segment.kind === "image" ? imageContent[imageOrdinal++] : undefined; - if (segment.kind !== "text") return renderSegment(segment, index, content); return renderSegment(segment, index, content); })}
    diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 5a3afbfc3..77eb6b75c 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -23,6 +23,9 @@ describe("i18n", () => { "Keymen", "Unknown", "Image tags", + "Image regions", + "Image region", + "Current image region", "Counterparties", "due", "Activity", diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index d5717f396..b389ba566 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -273,6 +273,8 @@ const TRANSLATIONS: Partial>> = { "Embedded image": "삽입 이미지", "Text detected in image": "이미지에서 인식된 텍스트", "Image regions": "이미지 영역", + "Image region": "이미지 영역", + "Current image region": "현재 이미지 영역", "Image tags": "이미지 태그", "Embedded image could not be decoded. Re-export the source post and open it again.": "첨부 이미지를 해독할 수 없습니다. 원문을 다시 내보내고 다시 여세요.", @@ -603,6 +605,8 @@ const TRANSLATIONS: Partial>> = { "Embedded image": "嵌入图像", "Text detected in image": "图像中识别的文字", "Image regions": "图像区域", + "Image region": "图像区域", + "Current image region": "当前图像区域", "Image tags": "图像标签", "Embedded image could not be decoded. Re-export the source post and open it again.": "无法解码嵌入图像。请重新导出原始文章后再打开。", @@ -948,6 +952,8 @@ const TRANSLATIONS: Partial>> = { "Embedded image": "埋め込み画像", "Text detected in image": "画像から認識されたテキスト", "Image regions": "画像領域", + "Image region": "画像領域", + "Current image region": "現在の画像領域", "Image tags": "画像タグ", "Embedded image could not be decoded. Re-export the source post and open it again.": "埋め込み画像をデコードできませんでした。原文を再エクスポートして、もう一度開いてください。", @@ -1278,6 +1284,8 @@ const TRANSLATIONS: Partial>> = { "Embedded image": "Hình ảnh nhúng", "Text detected in image": "Văn bản nhận dạng trong hình ảnh", "Image regions": "Các vùng trong hình ảnh", + "Image region": "Vùng hình ảnh", + "Current image region": "Vùng hình ảnh hiện tại", "Image tags": "Thẻ hình ảnh", "Embedded image could not be decoded. Re-export the source post and open it again.": "Không thể giải mã hình ảnh nhúng. Hãy xuất lại bài viết gốc rồi mở lại.", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 95330cb50..2e052a26d 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.12.6" +__version__ = "2.13.2" diff --git a/pyproject.toml b/pyproject.toml index cb4be2916..cd3acca1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.6" +version = "2.13.2" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/uv.lock b/uv.lock index 10bcf9ff1..de224e53a 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.6" +version = "2.13.2" source = { editable = "." } dependencies = [ { name = "certifi" }, From e7d1b353bb423c6bceb4664f96227cda3a987a29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:31:05 +0900 Subject: [PATCH 02/10] docs: avoid image overlay ADR collision --- AGENTS.md | 2 +- ...e-region-overlays.md => 0112-buyer-image-region-overlays.md} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/adr/{0111-buyer-image-region-overlays.md => 0112-buyer-image-region-overlays.md} (95%) diff --git a/AGENTS.md b/AGENTS.md index 6688e2e5b..7431440f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,7 @@ contextual-orchestrator owns model discovery and selection. `This post is an image` to a buyer. When a source image can be reattached, draw only persisted visual-region boxes; never invent coordinates. If the bitmap is missing or a box is invalid, keep the region list and do not - fabricate a locator (ADR 0110 / 0111). + fabricate a locator (ADR 0110 / 0112). ## Source parsing and semantic units diff --git a/docs/adr/0111-buyer-image-region-overlays.md b/docs/adr/0112-buyer-image-region-overlays.md similarity index 95% rename from docs/adr/0111-buyer-image-region-overlays.md rename to docs/adr/0112-buyer-image-region-overlays.md index 60c42e212..b101aa0d1 100644 --- a/docs/adr/0111-buyer-image-region-overlays.md +++ b/docs/adr/0112-buyer-image-region-overlays.md @@ -1,4 +1,4 @@ -# ADR 0111: Overlay persisted visual-region boxes on the source image +# ADR 0112: Overlay persisted visual-region boxes on the source image - Status: Accepted - Date: 2026-08-20 From 668168859a99e218668c8413881c3b6bcf7121d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:40:29 +0900 Subject: [PATCH 03/10] test: cover invalid image overlay bounds --- frontend/src/PostBody.test.tsx | 84 ++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx index 36ee7bdc7..cd2fc5bae 100644 --- a/frontend/src/PostBody.test.tsx +++ b/frontend/src/PostBody.test.tsx @@ -266,4 +266,88 @@ describe("PostBody", () => { await user.click(overlay); expect(screen.getByText("Current image region: Main panel")).toBeInTheDocument(); }); + + it("keeps invalid persisted boxes list-only and does not create overlay controls", () => { + render( + '} + structureUnits={[ + { + unit_index: 0, + unit_kind_code: "image", + unit_label: "img", + unit_text: "internal image instruction", + indent_level: 0, + indent_source_code: "unresolved", + indent_confidence: 0, + indent_evidence: "", + }, + ]} + imageContent={[ + { + unit_index: 0, + mime_type: "image/png", + status_code: "described", + extracted_text: "", + caption: "", + tags: [], + regions: [ + { + region_index: 0, + x_ratio: Number.NaN, + y_ratio: 0, + width_ratio: 0.2, + height_ratio: 0.2, + status_code: "unavailable", + extracted_text: "NaN box", + caption: "NaN box", + tags: [], + }, + { + region_index: 1, + x_ratio: -0.1, + y_ratio: 0, + width_ratio: 0.2, + height_ratio: 0.2, + status_code: "unavailable", + extracted_text: "Negative box", + caption: "Negative box", + tags: [], + }, + { + region_index: 2, + x_ratio: 0, + y_ratio: 0, + width_ratio: 0, + height_ratio: 0.2, + status_code: "unavailable", + extracted_text: "Zero box", + caption: "Zero box", + tags: [], + }, + { + region_index: 3, + x_ratio: 0.8, + y_ratio: 0.8, + width_ratio: 0.3, + height_ratio: 0.2, + status_code: "unavailable", + extracted_text: "Overflow box", + caption: "Overflow box", + tags: [], + }, + ], + }, + ]} + />, + ); + + expect(screen.queryByRole("group", { name: "Image regions" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Image region/ })).not.toBeInTheDocument(); + expect(screen.getByText("NaN box")).toBeInTheDocument(); + expect(screen.getByText("Negative box")).toBeInTheDocument(); + expect(screen.getByText("Zero box")).toBeInTheDocument(); + expect(screen.getByText("Overflow box")).toBeInTheDocument(); + expect(screen.getByAltText("Embedded image")).toBeInTheDocument(); + }); }); From e6fd907ed36d1228729c0df8d6cd22f603051891 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:44:14 +0900 Subject: [PATCH 04/10] docs: allocate unique image overlay ADR number --- AGENTS.md | 2 +- ...e-region-overlays.md => 0113-buyer-image-region-overlays.md} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/adr/{0112-buyer-image-region-overlays.md => 0113-buyer-image-region-overlays.md} (95%) diff --git a/AGENTS.md b/AGENTS.md index 7431440f5..d7743a371 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,7 @@ contextual-orchestrator owns model discovery and selection. `This post is an image` to a buyer. When a source image can be reattached, draw only persisted visual-region boxes; never invent coordinates. If the bitmap is missing or a box is invalid, keep the region list and do not - fabricate a locator (ADR 0110 / 0112). + fabricate a locator (ADR 0110 / 0113). ## Source parsing and semantic units diff --git a/docs/adr/0112-buyer-image-region-overlays.md b/docs/adr/0113-buyer-image-region-overlays.md similarity index 95% rename from docs/adr/0112-buyer-image-region-overlays.md rename to docs/adr/0113-buyer-image-region-overlays.md index b101aa0d1..57c7b51d0 100644 --- a/docs/adr/0112-buyer-image-region-overlays.md +++ b/docs/adr/0113-buyer-image-region-overlays.md @@ -1,4 +1,4 @@ -# ADR 0112: Overlay persisted visual-region boxes on the source image +# ADR 0113: Overlay persisted visual-region boxes on the source image - Status: Accepted - Date: 2026-08-20 From 2da6a18ff0c03c95a90bc2f08950522ff1860f8a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:55:24 +0900 Subject: [PATCH 05/10] fix: validate embedded image sources at render boundary --- CHANGELOG.d/2.13.2-image-region-overlays.md | 2 ++ docs/adr/0110-buyer-image-evidence-rendering.md | 3 +++ frontend/src/PostBody.test.tsx | 13 +++++++++++++ frontend/src/PostBody.tsx | 13 +++++++++---- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.d/2.13.2-image-region-overlays.md b/CHANGELOG.d/2.13.2-image-region-overlays.md index a8aa057da..3a5f09c7b 100644 --- a/CHANGELOG.d/2.13.2-image-region-overlays.md +++ b/CHANGELOG.d/2.13.2-image-region-overlays.md @@ -13,3 +13,5 @@ - Image-region overlay and current-region labels are translated through the five-locale catalog (Korean, English fallback, Chinese, Japanese, Vietnamese). +- Buyer image rendering now rejects script, SVG, external, and malformed + source URLs before they reach an image element. diff --git a/docs/adr/0110-buyer-image-evidence-rendering.md b/docs/adr/0110-buyer-image-evidence-rendering.md index 803d75939..40b9590dd 100644 --- a/docs/adr/0110-buyer-image-evidence-rendering.md +++ b/docs/adr/0110-buyer-image-evidence-rendering.md @@ -16,6 +16,9 @@ placeholder and is not buyer content. - Render a source image with its accessible caption, OCR, tags, and region evidence when the raw data URI is present. +- At the render boundary, accept only canonical base64 data URIs for inert + raster image media types. Reject external, script, SVG, and malformed + sources and fall back to persisted caption/OCR/region evidence. - When the source image cannot be reattached, render only the persisted `PostImageContent` evidence in a figure; never render the image unit's internal `unit_text` as buyer prose. diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx index 06071e558..abc66e1ab 100644 --- a/frontend/src/PostBody.test.tsx +++ b/frontend/src/PostBody.test.tsx @@ -46,6 +46,19 @@ describe("PostBody", () => { expect(screen.queryByAltText(/character offset/i)).not.toBeInTheDocument(); }); + it("rejects script and active SVG image sources before browser rendering", () => { + const { rerender } = render('} />); + + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + + rerender( + '} />, + ); + + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByText("Embedded image")).toBeInTheDocument(); + }); + it("renders authoritative LLM structure levels for semantic list units", () => { render( Number.isFinite(value))) { @@ -27,14 +30,16 @@ function ImageEvidenceFigure({ }) { const [selectedRegionIndex, setSelectedRegionIndex] = useState(null); const regions = imageContent?.regions ?? []; - const overlayRegions = sourceImage ? regions.filter(hasPersistedOverlayBox) : []; + const sourceImageSrc = + sourceImage && SAFE_EMBEDDED_IMAGE_SOURCE.test(sourceImage.src) ? sourceImage.src : undefined; + const overlayRegions = sourceImageSrc ? regions.filter(hasPersistedOverlayBox) : []; const selectedRegion = overlayRegions.find((region) => region.region_index === selectedRegionIndex); return (
    - {sourceImage ? ( + {sourceImageSrc ? (
    - {imageContent?.caption + {imageContent?.caption {overlayRegions.length ? (
    {overlayRegions.map((region) => { @@ -66,7 +71,7 @@ function ImageEvidenceFigure({ ) : null}
    ) : null} - {imageContent?.caption || !sourceImage ? ( + {imageContent?.caption || !sourceImageSrc ? (
    {imageContent?.caption || t("Embedded image")}
    ) : null} {selectedRegion ? ( From a550f8fab304fbff4b1a94b8a44121036aef2067 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:10:45 +0900 Subject: [PATCH 06/10] fix: preserve buyer image region evidence --- frontend/src/PostBody.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx index 13a0f2d58..4113eaec1 100644 --- a/frontend/src/PostBody.tsx +++ b/frontend/src/PostBody.tsx @@ -142,13 +142,15 @@ function ImageEvidenceFigure({ {regions.map((region) => (
  2. {region.caption ?

    {region.caption}

    : null} - {region.extracted_text ? ( + {region.extracted_text && parsePipeDelimitedTable(region.extracted_text) ? (
    {renderImageText(region.extracted_text)}
    - ) : region.caption ? null : ( + ) : !region.caption && region.extracted_text ? ( +

    {region.extracted_text}

    + ) : !region.caption ? ( t("Unknown") - )} + ) : null} {region.tags.length ? ( {t("Image tags")}: {region.tags.join(", ")} From 5336b288e9b2eadc899024f6de6d45fd0dfc8746 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:11:44 +0900 Subject: [PATCH 07/10] fix: preserve image region evidence during restack --- frontend/src/PostBody.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx index 7ed322219..713e605c2 100644 --- a/frontend/src/PostBody.tsx +++ b/frontend/src/PostBody.tsx @@ -141,12 +141,13 @@ function ImageEvidenceFigure({
      {regions.map((region) => (
    1. - {region.caption ?

      {region.caption}

      : null} - {region.extracted_text ? ( + {region.caption ? ( +

      {region.caption}

      + ) : region.extracted_text ? (
      {renderImageText(region.extracted_text)}
      - ) : region.caption ? null : ( + ) : ( {t("Unknown")} )} {region.tags.length ? ( From 8b37682e3fb906c71a2456a446591dacbe63e5bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 16:21:52 +0900 Subject: [PATCH 08/10] fix: preserve buyer chrome across image overlays --- CHANGELOG.d/2.13.2-image-region-overlays.md | 3 + docs/adr/0113-buyer-image-region-overlays.md | 8 +- frontend/src/App.css | 174 ++++++++++++++++++- frontend/src/PostBody.test.tsx | 67 +++++++ frontend/src/PostBody.tsx | 2 +- frontend/src/styles/tokens.test.ts | 16 ++ 6 files changed, 262 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.d/2.13.2-image-region-overlays.md b/CHANGELOG.d/2.13.2-image-region-overlays.md index 3a5f09c7b..7ae754a87 100644 --- a/CHANGELOG.d/2.13.2-image-region-overlays.md +++ b/CHANGELOG.d/2.13.2-image-region-overlays.md @@ -15,3 +15,6 @@ Vietnamese). - Buyer image rendering now rejects script, SVG, external, and malformed source URLs before they reach an image element. +- The login, application shell, footer, and shared button styles remain + defined while the overlay stylesheet evolves; a new post image also clears + any selection from the previous post. diff --git a/docs/adr/0113-buyer-image-region-overlays.md b/docs/adr/0113-buyer-image-region-overlays.md index 57c7b51d0..2742949ac 100644 --- a/docs/adr/0113-buyer-image-region-overlays.md +++ b/docs/adr/0113-buyer-image-region-overlays.md @@ -24,9 +24,15 @@ see which panel of the source image supplied that evidence. only from persisted region caption or extracted text. - Translate overlay and current-region labels through the five-locale UI catalog. +- Reset the selected region when the rendered source image changes, so a + stayed-open post popup cannot carry a selection into another post. +- Keep the shared login, header, footer, and button selectors defined in the + same stylesheet; image-overlay work must not remove application chrome. ## Consequences Buyers can locate panel-level evidence on the source image. Search and embedding remain bound to the existing region tables. A missing bitmap still -fails closed to the list rather than fabricating a locator. +fails closed to the list rather than fabricating a locator, and moving between +posts does not show a stale selection. The shared-chrome selector regression +also keeps the login and application shell usable while this feature evolves. diff --git a/frontend/src/App.css b/frontend/src/App.css index 9f905f0e6..6b3e6183e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,8 +1,159 @@ -#root { - max-width: 960px; - margin: 0 auto; +/* App shell, login, and shared controls. Keep these selectors aligned with + App.tsx so a visual feature cannot silently remove the product chrome. */ +.app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; + width: 100%; +} + +.app-shell > main { + flex: 1; padding: 1.5rem; - text-align: left; +} + +.login-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 1.5rem; + background: var(--bg); +} + +.login-card { + width: 100%; + max-width: 420px; + padding: 2.5rem 2rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 1.5rem; + text-align: center; +} + +.login-header h1 { + font-size: 2rem; + color: var(--color-primary); + margin: 0 0 0.4rem; +} + +.login-subtitle { + font-size: 0.9rem; + color: var(--text-muted); + margin: 0; +} + +.login-controls { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.login-controls button { + width: 100%; + min-height: 2.75rem; +} + +.login-help { + color: var(--text-muted); + font-size: 0.8rem; +} + +.app-header-logo, +.app-header-top-menu { + display: flex; + align-items: center; +} + +.app-header-logo { + gap: 0.75rem; +} + +.app-header-title { + font-size: 1.4rem; + font-weight: 700; + margin: 0; + color: var(--color-primary); +} + +.app-header-top-menu { + gap: 0.85rem; +} + +.app-user-profile { + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text-heading); + background: var(--color-table-row-hover); + padding: 0.25rem 0.75rem; + border-radius: 999px; + border: 1px solid var(--border); +} + +.mobile-drawer-trigger { + display: none; + background: transparent; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--color-text-heading); +} + +.app-footer { + margin-top: auto; + padding: 1.25rem 1.5rem; + min-height: var(--footer-min-height); + background: var(--color-footer-bg); + border-top: 1px solid var(--color-footer-border); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.82rem; + color: var(--color-footer-text); +} + +.app-footer-title { + font-weight: 700; + letter-spacing: 0.04em; + color: var(--color-primary); +} + +.app-footer-copyright { + color: var(--color-footer-text); +} + +.btn-primary, +.btn-secondary { + border-radius: var(--radius-control); + padding: 0.5rem 1.15rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease-in-out; +} + +.btn-primary { + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + border: 1px solid transparent; +} + +.btn-primary:hover { + background: var(--color-btn-primary-hover); +} + +.btn-secondary { + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + border: 1px solid var(--color-btn-secondary-border); + padding: 0.45rem 1rem; +} + +.btn-secondary:hover { + background: var(--color-btn-secondary-hover); } .centered { @@ -11,10 +162,18 @@ } .app-header { + position: sticky; + top: 0; + z-index: var(--z-header); + background: var(--color-header-bg); + border-bottom: 1px solid var(--color-header-border); display: flex; justify-content: space-between; align-items: center; + padding: 0 1.5rem; + min-height: var(--header-height); margin-bottom: 1.5rem; + box-shadow: 0 1px 4px rgb(0 0 0 / 4%); } .app-header > div { @@ -241,18 +400,21 @@ display: flex; align-items: center; justify-content: center; + z-index: var(--z-modal-backdrop); } .popup-panel { position: relative; - background: canvas; - color: canvastext; + background: var(--surface); + color: var(--text); max-width: 720px; width: 90%; max-height: 85vh; overflow-y: auto; padding: 2rem; border-radius: 12px; + z-index: var(--z-modal); + box-shadow: var(--shadow); } .popup-close { diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx index d786607fe..8016e44b1 100644 --- a/frontend/src/PostBody.test.tsx +++ b/frontend/src/PostBody.test.tsx @@ -495,4 +495,71 @@ describe("PostBody", () => { expect(screen.getByText("Before").compareDocumentPosition(screen.getByAltText("Source diagram")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(screen.getByAltText("Source diagram").compareDocumentPosition(screen.getByText("After")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); + + it("clears a selected region when the displayed post image changes", async () => { + const user = userEvent.setup(); + const firstSource = + ''; + const secondSource = ''; + const structureUnits = [ + { + unit_index: 0, + unit_kind_code: "image" as const, + unit_label: "img", + unit_text: "This post is an image. Ask questions to read its text.", + indent_level: 0, + indent_source_code: "unresolved" as const, + indent_confidence: 0, + indent_evidence: "", + }, + ]; + const region = { + region_index: 0, + x_ratio: 0.1, + y_ratio: 0.2, + width_ratio: 0.3, + height_ratio: 0.4, + status_code: "described", + extracted_text: "Region OCR", + caption: "Main panel", + tags: [], + }; + const { rerender } = render( + , + ); + + await user.click(screen.getByRole("button", { name: /Main panel/ })); + expect(screen.getByText(/Current image region: Main panel/)).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByAltText("Second diagram")).toBeInTheDocument(); + expect(screen.queryByText(/Current image region/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx index 4113eaec1..f15d82265 100644 --- a/frontend/src/PostBody.tsx +++ b/frontend/src/PostBody.tsx @@ -186,7 +186,7 @@ function renderSegment(segment: PostBodySegment, index: number, imageContent?: P case "image": return ( diff --git a/frontend/src/styles/tokens.test.ts b/frontend/src/styles/tokens.test.ts index 54432b43a..6b453f36c 100644 --- a/frontend/src/styles/tokens.test.ts +++ b/frontend/src/styles/tokens.test.ts @@ -47,6 +47,16 @@ const RETIRED_LIGHT_ONLY_HEX = [ "#721c24", ]; +const APP_CHROME_SELECTORS = [ + ".app-shell", + ".login-screen", + ".login-card", + ".app-header-title", + ".app-footer", + ".btn-primary", + ".btn-secondary", +]; + describe("design tokens", () => { it("defines every badge/accent token in both the light and dark blocks", () => { for (const token of BADGE_AND_ACCENT_TOKENS) { @@ -70,4 +80,10 @@ describe("design tokens", () => { expect(appCss, `App.css never references var(${token})`).toContain(`var(${token})`); } }); + + it("keeps the login and application chrome selectors defined", () => { + for (const selector of APP_CHROME_SELECTORS) { + expect(appCss, `${selector} must keep a buyer-facing style`).toContain(selector); + } + }); }); From 519a8c3a9d75ea3def1d6d4febaa6606a7166cbd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:19:51 +0900 Subject: [PATCH 09/10] fix: keep evidence panel above buyer header --- frontend/src/App.css | 2 +- frontend/src/styles/tokens.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 6b3e6183e..90932fc7a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1350,7 +1350,7 @@ box-shadow: -4px 0 16px rgba(0, 0, 0, 0.25); padding: 2rem 1.5rem; overflow-y: auto; - z-index: 10; + z-index: var(--z-evidence-panel); animation: slide-in-from-right 0.2s ease-out; } diff --git a/frontend/src/styles/tokens.test.ts b/frontend/src/styles/tokens.test.ts index 6b453f36c..91cbebfc0 100644 --- a/frontend/src/styles/tokens.test.ts +++ b/frontend/src/styles/tokens.test.ts @@ -86,4 +86,11 @@ describe("design tokens", () => { expect(appCss, `${selector} must keep a buyer-facing style`).toContain(selector); } }); + + it("keeps the evidence panel above the sticky header layer", () => { + expect(lightBlock).toContain("--z-header: 100;"); + expect(lightBlock).toContain("--z-evidence-panel: 250;"); + expect(appCss).toContain(".evidence-panel"); + expect(appCss).toContain("z-index: var(--z-evidence-panel);"); + }); }); From 57e75d5f8a00132849d655e1ebc7ddf56d73cff3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:06:01 +0900 Subject: [PATCH 10/10] fix: preserve image region evidence state --- frontend/src/App.css | 8 ++++- frontend/src/PostBody.test.tsx | 58 +++++++++++++++++++++++++++--- frontend/src/PostBody.tsx | 18 +++++----- frontend/src/styles/tokens.test.ts | 7 ++++ 4 files changed, 76 insertions(+), 15 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 90932fc7a..99cc50020 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -199,7 +199,7 @@ } .error { - color: #b91c1c; + color: var(--color-status-alert); } .post-list { @@ -526,6 +526,7 @@ .post-image-region-overlays { position: absolute; inset: 0; + pointer-events: none; } .post-image-region-overlay { @@ -537,6 +538,7 @@ background: var(--color-accent-info-background); cursor: pointer; appearance: none; + pointer-events: auto; } .post-image-region-overlay[aria-pressed="true"] { @@ -555,6 +557,10 @@ color: var(--text-h); } +.post-image-current-region:empty { + display: none; +} + @media (prefers-reduced-motion: no-preference) { .post-image-region-overlay { transition: border-color 160ms ease, background-color 160ms ease; diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx index 2688ac7f7..23f29f85b 100644 --- a/frontend/src/PostBody.test.tsx +++ b/frontend/src/PostBody.test.tsx @@ -377,7 +377,7 @@ describe("PostBody", () => { expect(screen.getByRole("button", { name: "Image region: Edge panel" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Overflow panel/ })).not.toBeInTheDocument(); expect(screen.queryByText(/This post is an image/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Invented box must not render/)).not.toBeInTheDocument(); + expect(screen.getByText("Invented box must not render")).toBeInTheDocument(); expect(screen.getByText("Overflow panel")).toBeInTheDocument(); overlay.focus(); @@ -467,10 +467,10 @@ describe("PostBody", () => { expect(screen.queryByRole("group", { name: "Image regions" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Image region/ })).not.toBeInTheDocument(); - expect(screen.getByText("NaN box")).toBeInTheDocument(); - expect(screen.getByText("Negative box")).toBeInTheDocument(); - expect(screen.getByText("Zero box")).toBeInTheDocument(); - expect(screen.getByText("Overflow box")).toBeInTheDocument(); + expect(screen.getAllByText("NaN box")).toHaveLength(2); + expect(screen.getAllByText("Negative box")).toHaveLength(2); + expect(screen.getAllByText("Zero box")).toHaveLength(2); + expect(screen.getAllByText("Overflow box")).toHaveLength(2); expect(screen.getByAltText("Embedded image")).toBeInTheDocument(); }); @@ -624,4 +624,52 @@ describe("PostBody", () => { expect(screen.getByAltText("Second diagram")).toBeInTheDocument(); expect(screen.queryByText(/Current image region/)).not.toBeInTheDocument(); }); + + it("clears a selected region when region evidence changes for the same image", async () => { + const user = userEvent.setup(); + const source = + ''; + const structureUnits = [ + { + unit_index: 0, + unit_kind_code: "image" as const, + unit_label: "img", + unit_text: "internal image instruction", + indent_level: 0, + indent_source_code: "unresolved" as const, + indent_confidence: 0, + indent_evidence: "", + }, + ]; + const makeImageContent = (caption: string) => [{ + unit_index: 0, + mime_type: "image/png", + status_code: "described", + extracted_text: "OCR", + caption: "Same diagram", + tags: [], + regions: [{ + region_index: 0, + x_ratio: 0.1, + y_ratio: 0.2, + width_ratio: 0.3, + height_ratio: 0.4, + status_code: "described", + extracted_text: "Region OCR", + caption, + tags: [], + }], + }]; + const { rerender } = render( + , + ); + + await user.click(screen.getByRole("button", { name: /First panel/ })); + expect(screen.getByText("Current image region: First panel")).toBeInTheDocument(); + rerender( + , + ); + + expect(screen.queryByText(/Current image region/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/PostBody.tsx b/frontend/src/PostBody.tsx index 46d13f01d..e8a8ddb57 100644 --- a/frontend/src/PostBody.tsx +++ b/frontend/src/PostBody.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { splitPostBody, type PostBodySegment } from "./postBodyDisplay"; import { t } from "./i18n"; import type { PostContentUnit, PostImageContent, PostImageRegion } from "./api"; @@ -82,6 +82,10 @@ function ImageEvidenceFigure({ const overlayRegions = sourceImageSrc ? regions.filter(hasPersistedOverlayBox) : []; const selectedRegion = overlayRegions.find((region) => region.region_index === selectedRegionIndex); + useEffect(() => { + setSelectedRegionIndex(null); + }, [sourceImageSrc, imageContent?.unit_index, imageContent?.caption, imageContent?.regions]); + return (
      {sourceImageSrc ? ( @@ -121,11 +125,9 @@ function ImageEvidenceFigure({ {imageContent?.caption || !sourceImageSrc ? (
      {imageContent?.caption || t("Embedded image")}
      ) : null} - {selectedRegion ? ( -

      - {t("Current image region")}: {regionBuyerLabel(selectedRegion)} -

      - ) : null} +

      + {selectedRegion ? `${t("Current image region")}: ${regionBuyerLabel(selectedRegion)}` : null} +

      {imageContent?.tags.length ? (

      {t("Image tags")}: {imageContent.tags.join(", ")} @@ -144,12 +146,10 @@ function ImageEvidenceFigure({ {regions.map((region) => (

    2. {region.caption ?

      {region.caption}

      : null} - {region.extracted_text && parsePipeDelimitedTable(region.extracted_text) ? ( + {region.extracted_text ? (
      {renderImageText(region.extracted_text)}
      - ) : !region.caption && region.extracted_text ? ( -

      {region.extracted_text}

      ) : !region.caption ? ( t("Unknown") ) : null} diff --git a/frontend/src/styles/tokens.test.ts b/frontend/src/styles/tokens.test.ts index 91cbebfc0..d9e8bd1e9 100644 --- a/frontend/src/styles/tokens.test.ts +++ b/frontend/src/styles/tokens.test.ts @@ -93,4 +93,11 @@ describe("design tokens", () => { expect(appCss).toContain(".evidence-panel"); expect(appCss).toContain("z-index: var(--z-evidence-panel);"); }); + + it("keeps image overlays transparent outside their accessible controls", () => { + expect(appCss).toContain(".post-image-region-overlays"); + expect(appCss).toContain("pointer-events: none;"); + expect(appCss).toContain(".post-image-region-overlay"); + expect(appCss).toContain("pointer-events: auto;"); + }); });