diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..81d0d31d8 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 / 0113). ## Source parsing and semantic units @@ -150,7 +153,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..7ae754a87 --- /dev/null +++ b/CHANGELOG.d/2.13.2-image-region-overlays.md @@ -0,0 +1,20 @@ +# 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). +- 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 new file mode 100644 index 000000000..2742949ac --- /dev/null +++ b/docs/adr/0113-buyer-image-region-overlays.md @@ -0,0 +1,38 @@ +# ADR 0113: 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. +- 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, 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/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 0b1eec4f8..99cc50020 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,4 +1,5 @@ -/* App-level Shell Layout */ +/* 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; @@ -11,7 +12,6 @@ padding: 1.5rem; } -/* Login Screen (§3.2 로그인 페이지) */ .login-screen { display: flex; flex-direction: column; @@ -63,24 +63,13 @@ font-size: 0.8rem; } -/* App Header (§2.2.1 & §2.2.2) */ -.app-header { - position: sticky; - top: 0; - z-index: var(--z-header); - background: var(--color-header-bg); - border-bottom: 1px solid var(--color-header-border); - padding: 0 1.5rem; - height: var(--header-height); +.app-header-logo, +.app-header-top-menu { display: flex; - justify-content: space-between; align-items: center; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); } .app-header-logo { - display: flex; - align-items: center; gap: 0.75rem; } @@ -92,9 +81,7 @@ } .app-header-top-menu { - display: flex; gap: 0.85rem; - align-items: center; } .app-user-profile { @@ -107,7 +94,6 @@ border: 1px solid var(--border); } -/* Drawer Menu Trigger (Mobile) */ .mobile-drawer-trigger { display: none; background: transparent; @@ -117,7 +103,6 @@ color: var(--color-text-heading); } -/* App Footer (§2.2.3 & §2.2.4) */ .app-footer { margin-top: auto; padding: 1.25rem 1.5rem; @@ -141,69 +126,19 @@ color: var(--color-footer-text); } -/* GNB Navigation (§2.3.1) */ -.buyer-gnb { - display: flex; - align-items: center; - height: var(--gnb-height); - background: var(--color-header-bg); - border-bottom: 1px solid var(--border); - padding: 0 1.5rem; - margin-bottom: 1.5rem; - gap: 1.5rem; - position: relative; - z-index: var(--z-gnb-pulldown); -} - -.buyer-gnb-item { - display: flex; - align-items: center; - height: 100%; - border: 0; - background: transparent; - color: var(--color-text); - font: inherit; - font-weight: 700; +.btn-primary, +.btn-secondary { + border-radius: var(--radius-control); + padding: 0.5rem 1.15rem; + font-weight: 600; cursor: pointer; - position: relative; - padding: 0 0.5rem; - transition: color 0.15s ease; -} - -.buyer-gnb-item:hover { - color: var(--color-text-heading); -} - -.buyer-gnb-item[aria-current="page"] { - color: var(--color-primary); -} - -.buyer-gnb-item[aria-current="page"]::after { - content: ""; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: var(--gnb-active-indicator-height); - background-color: var(--gnb-active-indicator-color); -} - -.buyer-gnb-tools { - margin-left: auto; - display: flex; - align-items: center; + transition: background-color 0.15s ease-in-out; } -/* Button Standards (§4.3) */ .btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: 1px solid transparent; - 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:hover { @@ -214,18 +149,39 @@ background: var(--color-btn-secondary-bg); color: var(--color-btn-secondary-text); border: 1px solid var(--color-btn-secondary-border); - border-radius: var(--radius-control); padding: 0.45rem 1rem; - font-weight: 600; - cursor: pointer; - transition: background-color 0.15s ease-in-out; } .btn-secondary:hover { background: var(--color-btn-secondary-hover); } -/* Language Switcher */ +.centered { + text-align: center; + padding-top: 4rem; +} + +.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 { + display: flex; + gap: 0.75rem; + align-items: center; +} + .language-switcher { display: inline-flex; align-items: center; @@ -233,25 +189,19 @@ .language-switcher select { min-height: var(--size-control-min); - padding: 0.35rem 1.8rem 0.35rem 0.65rem; + padding: 0.25rem 1.8rem 0.25rem 0.55rem; border: 1px solid var(--border); border-radius: var(--radius-control); - background: var(--surface); + background: var(--bg); color: var(--text-h); font: inherit; - font-size: 0.82rem; - cursor: pointer; + font-size: 0.8rem; } .error { color: var(--color-status-alert); } -.status-alert { - color: var(--color-status-alert); -} - -/* Post List */ .post-list { list-style: none; padding: 0; @@ -265,14 +215,172 @@ align-items: center; padding: 0.75rem 1rem; margin-bottom: 0.5rem; - border: 1px solid var(--border); - border-radius: var(--radius-control); + border: 1px solid #3333; + border-radius: 8px; background: none; cursor: pointer; text-align: left; font-size: 1rem; } +.board-surface { + display: block; +} + +.board-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + margin-bottom: 1.25rem; +} + +.board-header h2 { + margin: 0.2rem 0 0.35rem; +} + +.board-header p { + margin: 0; +} + +.board-result-count { + color: var(--text-muted); + font-size: var(--lw-font-size-meta); + white-space: nowrap; +} + +.board-controls { + display: grid; + grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(8rem, 1fr)) auto; + gap: 0.75rem; + align-items: end; + margin-bottom: 1.25rem; + padding: 0.85rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-muted); +} + +.board-controls label { + display: grid; + gap: 0.3rem; + color: var(--text-muted); + font-size: var(--lw-font-size-meta); + font-weight: 700; +} + +.board-search-help { + grid-column: 1 / -1; + margin: 0; +} + +.board-voc-type-filter { + display: flex; + flex-wrap: wrap; + gap: 0.1rem 0.85rem; + border: none; + padding: 0; + margin: 0; +} + +.board-voc-type-filter legend { + width: 100%; + padding: 0; + color: var(--text-muted); + font-size: var(--lw-font-size-meta); + font-weight: 700; +} + +.board-voc-type-filter label { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 0.35rem; + font-weight: 400; +} + +.board-voc-type-filter input[type="checkbox"] { + width: auto; + min-height: 0; +} + +.board-controls input, +.board-controls select { + min-height: var(--size-control-min); + width: 100%; + padding: 0.45rem 0.55rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text); + font: inherit; +} + +.board-reset { + min-height: var(--size-control-min); + white-space: nowrap; +} + +.board-empty { + padding: 1.25rem; + border: 1px dashed var(--border); + border-radius: 10px; + color: var(--text-muted); +} + +.board-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +.board-pagination button { + min-width: 2.25rem; + min-height: var(--size-control-min); +} + +.board-pagination button[aria-current="page"] { + border-color: var(--color-accent-border); + background: var(--color-accent-background); + color: var(--text-h); + font-weight: 700; +} + +.post-card { + margin-bottom: 0.5rem; +} + +.post-card .post-list-item { + margin-bottom: 0; +} + +.post-card-main { + display: grid; + gap: 0.25rem; + min-width: 0; +} + +.post-body-excerpt { + display: -webkit-box; + overflow: hidden; + color: var(--text); + font-size: 0.9rem; + line-height: 1.45; + text-align: left; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.post-card-badges { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + justify-content: flex-end; +} + .post-list-item[aria-current="true"], .ticket-list-item[aria-current="true"] { border-color: var(--color-accent-info); @@ -285,11 +393,10 @@ text-transform: uppercase; } -/* Popup / Modals (§3.6.1 모달 레이어 투명도 50%) */ .popup-backdrop { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.5); /* 50% opacity per guide */ + background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; @@ -318,13 +425,36 @@ border: none; font-size: var(--font-size-close); cursor: pointer; - color: var(--text-h); } -/* Posts & Evidence */ +:root { + --lw-opacity-meta: 0.7; + --lw-font-size-meta: 0.85rem; + --lw-color-warning: #b45309; +} + .post-meta { - opacity: 0.7; - font-size: 0.85rem; + opacity: var(--lw-opacity-meta); + font-size: var(--lw-font-size-meta); +} + +.post-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin: 0.75rem 0 1rem; +} + +.post-actions a, +.post-actions button { + font: inherit; + padding: 0.35rem 0.6rem; +} + +.post-action-status { + margin: -0.5rem 0 1rem; + color: var(--lw-color-warning); } .visually-hidden { @@ -350,6 +480,23 @@ white-space: pre-wrap; } +.post-body-footnote { + padding-inline-start: 1rem; + font-size: 0.9em; +} + +.post-body-table { + width: 100%; + border-collapse: collapse; + overflow-wrap: anywhere; +} + +.post-body-table td { + padding: 0.35rem 0.5rem; + border: 1px solid var(--post-image-border); + vertical-align: top; +} + .post-embedded-image { margin: 0; padding: var(--post-image-padding); @@ -364,6 +511,97 @@ 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; + pointer-events: none; +} + +.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; + pointer-events: auto; +} + +.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); +} + +.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; + } +} + +@media print { + body * { + visibility: hidden; + } + + .popup-backdrop, + .popup-panel, + .popup-panel * { + visibility: visible; + } + + .popup-backdrop { + position: absolute; + inset: 0; + display: block; + background: none; + } + + .popup-panel { + position: static; + width: auto; + max-width: none; + max-height: none; + overflow: visible; + padding: 0; + border-radius: 0; + } + + .popup-close, + .post-actions, + .post-action-status { + display: none !important; + } +} + .post-embedded-image figcaption { margin-top: 0.4rem; font-size: 0.85rem; @@ -373,7 +611,7 @@ .popup-placeholder { margin-top: 1.5rem; padding: 1rem; - border: 1px dashed var(--border); + border: 1px dashed #3336; border-radius: 8px; font-size: 0.85rem; opacity: 0.7; @@ -382,9 +620,9 @@ .popup-live-body-warning { margin: 0.75rem 0 1rem; padding: 0.65rem 0.75rem; - border-left: 3px solid var(--color-accent-orange); - background: var(--color-accent-background); - font-size: 0.85rem; + border-left: 3px solid var(--lw-color-warning); + background: color-mix(in srgb, canvas 88%, var(--lw-color-warning) 12%); + font-size: var(--lw-font-size-meta); } .cutoff-known-body { @@ -402,7 +640,7 @@ .popup-section { margin-top: 1.5rem; padding-top: 1rem; - border-top: 1px solid var(--border); + border-top: 1px solid #3332; } .popup-section h3 { @@ -451,6 +689,335 @@ font-size: 0.85rem; } +.related-posts-section { + margin-top: 1.25rem; + padding: 1rem; + border: 1px solid var(--accent-border); + border-radius: var(--radius-panel); + background: var(--accent-bg); +} + +.related-posts-context { + margin: 0 0 0.9rem; + padding: 0.75rem; + border: 1px solid var(--accent-border); + border-radius: var(--radius-control); + background: color-mix(in srgb, var(--accent-bg) 65%, var(--bg)); +} + +.related-posts-context h5 { + margin: 0 0 0.6rem; + color: var(--text-h); + font-size: 0.9rem; +} + +.related-posts-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.section-eyebrow { + margin: 0 0 0.2rem; + color: var(--accent); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.related-post-count { + flex: 0 0 auto; + padding: 0.2rem 0.55rem; + border: 1px solid var(--accent-border); + border-radius: 999px; + color: var(--text-h); + font-size: 0.75rem; + font-weight: 700; +} + +.related-post-list { + display: grid; + gap: 0.6rem; + list-style: none; + margin: 0; + padding: 0; +} + +.related-post-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + width: 100%; + gap: 0.7rem; + padding: 0.75rem 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--bg); + color: var(--text-h); + cursor: pointer; + font: inherit; + text-align: left; + transition: border-color 140ms ease, transform 140ms ease, box-shadow 140ms ease; +} + +.related-post-card:hover, +.related-post-card:focus-visible { + border-color: var(--accent); + box-shadow: var(--shadow); + outline: none; + transform: translateY(-1px); +} + +.related-post-card-static { + cursor: default; +} + +.related-post-kind, +.related-post-cta { + color: var(--text); + font-size: 0.75rem; +} + +.related-post-kind { + white-space: nowrap; +} + +.related-post-content { + display: grid; + min-width: 0; + gap: 0.25rem; +} + +.related-post-cta { + color: var(--accent); + font-weight: 700; + white-space: nowrap; +} + +@media (max-width: 640px) { + .app-header, + .app-header > div { + align-items: flex-start; + } + + .app-header, + .app-header > div { + flex-wrap: wrap; + } + + .related-post-card { + grid-template-columns: 1fr auto; + } + + .related-post-kind { + grid-column: 1 / -1; + } +} + +.buyer-gnb { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) auto; + gap: 0.45rem; + margin: 0 0 1.5rem; + padding: 0.35rem; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-muted); +} + +.buyer-gnb-tools { + display: flex; + align-items: center; + justify-content: center; + padding: 0 0.35rem; +} + +.advanced-review-tools { + margin: 0 0 1.5rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-muted); +} + +.advanced-review-tools > summary { + padding: 0.85rem 1rem; + color: var(--text-muted); + font-weight: 700; + cursor: pointer; +} + +.advanced-review-tools[open] > summary { + border-bottom: 1px solid var(--border); +} + +.advanced-review-tools > .popup-section { + margin: 1rem; +} + +.operator-action-tools { + margin-left: auto; + color: var(--text-muted); +} + +.operator-action-tools > summary { + cursor: pointer; + font-size: 0.82rem; + font-weight: 700; +} + +.operator-action-tools > button { + margin-top: 0.45rem; +} + +.buyer-gnb-item { + min-height: 2.75rem; + border: 0; + border-radius: 10px; + background: transparent; + color: var(--text-muted); + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.buyer-gnb-item:hover, +.buyer-gnb-item:focus-visible, +.buyer-gnb-item[aria-current="page"] { + background: var(--surface); + color: var(--text); + box-shadow: 0 2px 8px rgb(15 23 42 / 8%); +} + +.buyer-destination { + padding: 1.5rem; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface); +} + +.buyer-destination h2 { + margin: 0.2rem 0 0.5rem; +} + +.buyer-destination-intro { + max-width: 50rem; + color: var(--text-muted); +} + +.customer-master-list { + display: grid; + gap: 0.75rem; + margin: 1.25rem 0 0; + padding: 0; + list-style: none; +} + +.customer-master-list li { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: 12px; +} + +.customer-entity-button { + display: flex; + flex-direction: column; + gap: 0.25rem; + align-items: flex-start; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.customer-entity-button:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 4px; +} + +.customer-related-posts { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.customer-related-posts ul { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.customer-related-posts .related-post-card { + width: 100%; + border-radius: 8px; +} + +.customer-keymen { + margin-top: 1.5rem; +} + +.customer-master-list span { + color: var(--text-muted); + font-size: 0.85rem; +} + +.ask-agent-source { + display: grid; + gap: 0.45rem; + max-width: 42rem; + margin: 1.25rem 0; + font-weight: 700; +} + +.ask-agent-source select { + min-height: 2.75rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font: inherit; +} + +@media (max-width: 640px) { + .buyer-gnb { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .buyer-gnb-tools { + grid-column: 1 / -1; + justify-content: flex-end; + } + + .buyer-destination { + padding: 1rem; + } + + .board-header { + align-items: flex-start; + flex-direction: column; + } + + .board-controls { + grid-template-columns: 1fr; + } + + .board-result-count { + white-space: normal; + } +} + .lineage-home { border-top: none; padding-top: 0; @@ -471,7 +1038,7 @@ flex-direction: column; align-items: flex-start; gap: var(--space-control-gap); - font-size: 0.85rem; + font-size: var(--lw-font-size-meta); } .lineage-entity-picker select { @@ -479,8 +1046,8 @@ min-width: 12rem; border: 1px solid var(--color-border); border-radius: var(--radius-control); - background: var(--surface); - color: var(--text-h); + background: var(--color-background); + color: var(--color-text-heading); } .lineage-dag-group { @@ -494,13 +1061,13 @@ } .lineage-dag svg { - border: 1px solid var(--border); + border: 1px solid #3333; border-radius: 8px; - background: var(--surface); + background: canvas; } .lineage-dag-edge { - stroke: var(--border); + stroke: color-mix(in srgb, canvastext 35%, transparent); stroke-width: 1.5; fill: none; } @@ -510,14 +1077,14 @@ } .lineage-dag-node circle { - fill: var(--surface-muted); - stroke: var(--border); + fill: color-mix(in srgb, canvastext 8%, canvas); + stroke: color-mix(in srgb, canvastext 40%, transparent); stroke-width: 1.5; } .lineage-dag-branch circle { - fill: var(--badge-actor-organization-bg); - stroke: var(--color-accent-orange); + fill: color-mix(in srgb, orange 25%, canvas); + stroke: orange; } .lineage-dag-root circle { @@ -526,7 +1093,7 @@ .lineage-dag-node text { font-size: 11px; - fill: var(--text-h); + fill: canvastext; } .lineage-dag-node:focus { @@ -540,7 +1107,7 @@ .lineage-dag-node[aria-current="true"] circle { stroke-width: 3; - stroke: var(--text-h); + stroke: canvastext; } .keyman-list { @@ -660,12 +1227,6 @@ font-size: 0.8rem; } -.customer-group-abbreviations { - list-style: none; - padding-left: 1.25rem; - margin: 0.15rem 0 0; -} - .voc-excerpt-list { list-style: none; padding: 0; @@ -684,8 +1245,8 @@ .voc-counterparty-excerpt { margin: 0.4rem 0; padding: 0.5rem 0.75rem; - border-left: 3px solid var(--color-accent-orange); - background: var(--surface-muted); + border-left: 3px solid #c45c26; + background: #3331; } .related-keymen { @@ -720,9 +1281,7 @@ flex: 1; padding: 0.5rem; border-radius: 6px; - border: 1px solid var(--border); - background: var(--surface); - color: var(--text); + border: 1px solid var(--color-border-subtle); } .chat-section { @@ -739,9 +1298,7 @@ flex: 1; padding: 0.5rem; border-radius: 6px; - border: 1px solid var(--border); - background: var(--surface); - color: var(--text); + border: 1px solid var(--color-border-subtle); } .chat-suggestions { @@ -752,20 +1309,19 @@ } .chat-suggestion-chip { - border: 1px solid var(--border); + border: 1px solid var(--color-border-subtle); border-radius: 999px; padding: 0.15rem 0.7rem; background: none; cursor: pointer; font-size: 0.85rem; - color: var(--text); } .chat-answer { margin-top: 0.75rem; padding: 0.75rem; border-radius: 8px; - background: var(--surface-muted); + background: #3331; } .chat-question { @@ -787,7 +1343,6 @@ background: none; cursor: pointer; font-family: var(--font-family-chip); - color: var(--text); } .evidence-panel { @@ -796,8 +1351,8 @@ right: 0; bottom: 0; width: min(400px, 90vw); - background: var(--surface); - color: var(--text); + background: canvas; + color: canvastext; box-shadow: -4px 0 16px rgba(0, 0, 0, 0.25); padding: 2rem 1.5rem; overflow-y: auto; @@ -813,36 +1368,3 @@ transform: translateX(0); } } - -/* Responsive Overrides */ -@media (max-width: 1024px) { - /* Tablet Breakpoint (768px - 1024px) */ - .app-header { - padding: 0 1rem; - } - .buyer-gnb { - padding: 0 1rem; - } -} - -@media (max-width: 768px) { - /* Phone Breakpoint (<768px) */ - - .buyer-gnb { - display: none; /* Replaced by drawer on mobile */ - } - - .mobile-drawer-trigger { - display: block; - } - - .app-header { - padding: 0 1rem; - } - - .app-footer { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - } -} diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx index 4eb1c6392..23f29f85b 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"; @@ -295,6 +296,182 @@ 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.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.getByText("Invented box must not render")).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(); + expect(overlay).toHaveAttribute("aria-pressed", "true"); + await user.click(overlay); + expect(screen.queryByText("Current image region: Main panel")).not.toBeInTheDocument(); + expect(overlay).toHaveAttribute("aria-pressed", "false"); + }); + + 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.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(); }); it("renders pipe-delimited image OCR as a buyer-facing table", () => { @@ -380,4 +557,119 @@ 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(); + }); + + 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 99162c337..e8a8ddb57 100644 --- a/frontend/src/PostBody.tsx +++ b/frontend/src/PostBody.tsx @@ -1,7 +1,7 @@ +import { useEffect, 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 parsePipeDelimitedTable(text: string): string[][] | null { const parsedRows = text @@ -51,22 +51,83 @@ function renderImageText(text: string) { const SAFE_EMBEDDED_IMAGE_SOURCE = /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/i; +const RATIO_EPSILON = 1e-9; -function renderImageEvidence( - index: number, - imageContent?: PostImageContent, - sourceImage?: Extract, -) { +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 + RATIO_EPSILON && y_ratio + height_ratio <= 1 + RATIO_EPSILON; +} + +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 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); + + useEffect(() => { + setSelectedRegionIndex(null); + }, [sourceImageSrc, imageContent?.unit_index, imageContent?.caption, imageContent?.regions]); + return ( -
+
{sourceImageSrc ? ( - {imageContent?.caption +
+ {imageContent?.caption + {overlayRegions.length ? ( +
+ {overlayRegions.map((region) => { + const label = regionBuyerLabel(region); + const pressed = selectedRegionIndex === region.region_index; + return ( +
+ ) : null} +
) : null} {imageContent?.caption || !sourceImageSrc ? (
{imageContent?.caption || t("Embedded image")}
) : null} +

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

{imageContent?.tags.length ? (

{t("Image tags")}: {imageContent.tags.join(", ")} @@ -78,20 +139,20 @@ function renderImageEvidence( {renderImageText(imageContent.extracted_text)} ) : null} - {imageContent?.regions?.length ? ( + {regions.length ? (

{t("Image regions")}
    - {imageContent.regions.map((region) => ( + {regions.map((region) => (
  1. {region.caption ?

    {region.caption}

    : null} {region.extracted_text ? (
    {renderImageText(region.extracted_text)}
    - ) : region.caption ? null : ( + ) : !region.caption ? ( t("Unknown") - )} + ) : null} {region.tags.length ? ( {t("Image tags")}: {region.tags.join(", ")} @@ -137,7 +198,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)}`); @@ -229,9 +296,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; 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 650acfca8..85ef81d41 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -275,6 +275,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.": "첨부 이미지를 해독할 수 없습니다. 원문을 다시 내보내고 다시 여세요.", @@ -613,6 +615,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.": "无法解码嵌入图像。请重新导出原始文章后再打开。", @@ -966,6 +970,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.": "埋め込み画像をデコードできませんでした。原文を再エクスポートして、もう一度開いてください。", @@ -1304,6 +1310,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/frontend/src/styles/tokens.test.ts b/frontend/src/styles/tokens.test.ts index 54432b43a..d9e8bd1e9 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,24 @@ 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); + } + }); + + 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);"); + }); + + 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;"); + }); }); 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" },