Skip to content

Commit a194073

Browse files
muhammad-ali-ejaseemjaskppraveen-formidoclaudepre-commit-ci[bot]
authored
UN-4503 [FEAT] Add the v1 DocumentViewer for PDFs, images and text, with highlights, toolbar and zoom (#2405)
* feat(frontend): v1 scaffold, strict TypeScript and import boundary (UN-4270) Adds frontend/src/v1/ as an isolated tree for the v1 frontend (P0-T01): - Folder taxonomy from the spec, a public entry (src/v1/index.ts, imported by v0 as `@v1`) and a cloud overlay placeholder. - src/v1/tsconfig.json: strict, no JS, unused locals/params are errors, `@v1` alias only. v0's tsconfig/jsconfig exclude src/v1. - Biome overrides for the two-way import boundary and v1 bans (no `../`, `@/`, prop-types, antd, component CSS; noExplicitAny). - scripts/check-v1.mjs for what Biome cannot express: TypeScript-only files, the folder taxonomy and import contract, PropTypes, `ant-`. - typecheck:v1, lint:v1 and test:v1 scripts; v0 scripts are unchanged. - test-fixtures/v1-boundary/ proves each rule fails and passes as specified, run with node --test so the v0 vitest suite never sees it. - .gitignore re-includes src/v1/lib/ (the Python `lib/` rule matched it). v0 build output, test counts, typecheck and Biome results are unchanged. * fix(frontend): close v1 guardrail bypasses found in self-review (UN-4270) check-v1.mjs: - Scan comments with a string/template-aware tokenizer; a `/*` or `//` inside a string or line comment no longer hides the imports after it. - Read template-literal specifiers, import.meta.glob and `import(..., opts)`. - Flag specifiers that leave src/v1 (`/`, `..`, `@/`, `src/`) itself, not only via Biome, which skips template literals. - Check `@import`/`url()` and `ant-` in styles/ CSS; report symlinks; catch built `ant-` names; treat .test/.spec .mts/.cts as test files; ignore .DS_Store; drop the unused --root option. biome.json: also ban `..`, `./../**`, `/**`, `antd/**`, `prop-types/**`. tsconfig: skipLibCheck off so v1's own .d.ts files are checked. .gitignore: re-include lib/ at any depth under src/v1. Fixtures: a generated matrix over every folder-contract pair, every import form, every outside spelling, a symlink case, and cases for each new rule; tsc cases assert error codes; output matching excludes the command text and spawn errors fail loudly. Comment and README corrections. * feat(frontend): run v1 checks in CI and tighten what pages may import (UN-4270) - ci-frontend-lint: the typecheck job also runs `typecheck:v1` and `lint:v1`, so the strict v1 tsconfig and the scripts/check-v1.mjs checks (folder contract, TypeScript-only files, PropTypes, `ant-`) gate PRs now rather than from P0-T10. - Pages may no longer import app/, test/ or the v1 entry, only the shared folders (components, shell, services, stores, hooks, lib, styles, plugins). Test files in a page may still import test/. Contract table, README and the fixture matrix updated together. * refactor(frontend): address SonarCloud findings on the v1 guardrails (UN-4270) check-v1.mjs: split the comment scanner into small helpers (cognitive complexity 40 -> under 15), drop the nested ternary and nested template literal, use String.raw for the import regexes, keep CLI flags in a Set. Fixture runner: - Cases and the expected folder contract are parsed text tables instead of repeated object literals (the 10% duplication). - Sorts use an explicit localeCompare comparator. - The contract matrix is built by a helper. - Spawns /bin/sh by absolute path and no longer needs `git init` (Biome only needs the ignore file). The git check-ignore call keeps a PATH lookup, with the reason and a NOSONAR marker. Fixture cases: re-exports rewritten as small functions and read-only props, without changing what each case exercises. * refactor(frontend): split the contract-matrix builder to cut its complexity (UN-4270) * fix(frontend): check every glob pattern and v0 -> v1 imports Biome cannot see (UN-4270) Addresses Greptile review on #2380: - import.meta.glob([...]): every pattern in the array is checked, not only the first, so an allowed first pattern no longer hides a forbidden one. - check-v1 --code also scans v0 (src/ outside v1, including the cloud src/plugins overlay) for template-literal imports, globs, require(), @/v1, src/v1, relative paths and CSS @import that reach into v1 other than through `@v1`. Biome still covers plain imports. Fixtures: Greptile's example as a case, a glob array with a forbidden later pattern, and a generated test of every v0 -> v1 form (denied and allowed). The v0 scan reports nothing on the current tree. * UN-4299 [MISC] Turn on skipLibCheck for v1: vitest 3.2 and @types/chai 5.2 both declare containSubset Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add the v1 theme model: three modes, follow-system and per-browser preference Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add the v1 portal container and the theme preference store Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add the v1 token source, the generator and tokens.css in three modes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Prefix the spacing utilities in the token table so they match the v1 Tailwind root Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add cn() configured for the v1 utility prefix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Pin the v1 prefix in cn() with a test that fails without it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Use "ui" as the v1 utility prefix: Tailwind accepts lowercase letters only Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add the prefixed v1 Tailwind root with fonts, modes, theme mapping and the focus utility Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Exclude the generated tokens.css from Biome formatting so biome ci passes src/v1/styles/tokens.css is written by scripts/build-v1-tokens.mjs and checked byte-for-byte by styles/tokens.test.ts; the formatter would rewrap it and break the check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Sort the imports in cn.ts so biome ci passes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Make the root compile test fail on unscoped selector lists and on missing variants Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add ThemeProvider: the v1 root, follow-system binding and portal sync Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Apply the stored theme before first paint with useLayoutEffect Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Enforce the v1 styling policy: raw colours, pixels, inline styles, CSS imports and !important fail lint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Scope the styling lint to class strings and cover vh/vw arbitrary values Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Keep v0's Tailwind scan out of src/v1, the lint scripts and the fixtures v0's root auto-detected sources across the whole frontend, so the v1 tree, scripts/check-v1-styles.mjs and test-fixtures/ added 12 unprefixed utilities and 8 theme variables to v0's stylesheet. With these three @source not lines the v0 build is byte-identical to the base again (239/239 files). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Teach cn the type roles so a role and a text colour no longer cancel each other Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Reject colour literals outside class strings, named-colour arbitrary values and !important after a variant Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Keep the focus ring visible in forced-colors mode and scope variants, shadows and the root fill to v1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Create the portal container before the mode changes so the theme test exercises the mode effect Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Document the v0 rules that reach v1, the focus-ring usage, the pixel escape and the current unprefixed-class rationale Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Name the theme storage key in the v1 README Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [MISC] Name the three theme modes in the v1 README Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * UN-4502 [FEAT] Add the v1 PDF loader, usePdfDocument hook and viewer states - lib/pdf/pdfjs.ts is the only v1 import of pdf.js: the existing pdfjs-dist 3.11.174 legacy build, its worker bundled with ?url, both loaded on first use. getDocument always gets isEvalSupported: false (CVE-2024-4367), and in-memory bytes are copied before the worker transfer empties them. - Page sizes are read in parallel, bounded at 16 in flight. - hooks/usePdfDocument tracks empty, loading, password (missing or wrong), unsupported, failed (with Retry) and ready. It reopens only when the source's URL, request options or bytes change, and destroys the previous load on change and on unmount. - components/patterns/document-viewer/ViewerStatus renders every state except ready. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4503 [FEAT] Render v1 PDF pages in a windowed, scrolling list - PdfPageList lays every page out at its own size from the page sizes read at load, so the scrollbar is right from the start, and gives a canvas only to the pages in view plus two on each side. - usePageWindow tracks pages in view with one IntersectionObserver on the scroll container; lib/pdf/windowing holds the window, pixel ratio and CSS size arithmetic. - PdfPage renders a frame after it enters the window, so a fast scroll draws nothing for pages it passes. Leaving the window or a zoom change cancels the render in flight, cleans up the page and zeroes the canvas. The pixel ratio is capped at 2 and lowered further for pages that would exceed the largest canvas Safari allocates. - Page geometry is set as custom properties read by ui: classes, since v1 bans the style attribute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4299 [FEAT] Let a v1 style attribute set CSS custom properties only Runtime geometry (a page's aspect ratio, a highlight's position) cannot be a token or a class. Rule S1 now accepts a style attribute whose value is an object literal with only "--name" keys, so the numbers travel through custom properties that ui: classes read (ui:aspect-(--page-aspect)); a string, a variable, a spread or a plain key still fails. Three fixtures pin it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * UN-4299 [FIX] Make check-v1-styles.mjs executable to match its shebang pre-commit's check-shebang-scripts-are-executable fails on branches stacked on this one; check-v1.mjs already carries the bit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * UN-4503 [FEAT] Size v1 PDF pages through style custom properties S1 now allows a style attribute whose keys are all custom properties, so the page size is set directly instead of through a ref and layout effect. React's CSSProperties gains a --* index signature so this typechecks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4503 [FEAT] Format PdfPage imports Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4299 [FIX] Type the custom-property keys a v1 style attribute may set React's CSSProperties has no index signature for "--*" keys, so the object literal rule S1 allows fails TS2353 under strict mode. Augment it under lib/, and typecheck the passing fixture so the augmentation is exercised. Raised by Ali on the viewer stack. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * UN-4503 [FEAT] Clean up late pages and expose page contents to assistive tech - A page whose getPage resolves after it left the render window is now cleaned up; the effect cleanup had already run with no page to free. - Pages are figures instead of role="img", whose children are presentational and would hide the highlight buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4502 Keep signed URLs out of load errors and test the loader retry pdf.js puts the full URL in MissingPDFException and UnexpectedResponseException messages, so a signed URL's token reached the screen. Build those messages from the status instead. Also cover the reset that lets Retry load pdf.js again after a failed chunk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4503 Test that a failed page render ends in the error state Covers a render task that rejects with a real error and a canvas with no 2D context, so a regression that leaves the page in rendering fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4503 Say when a page failed to render and offer Retry A page whose render failed was left blank, so it could not be told from an empty page. It now says it didn't load, with a Retry for that page. Raised by Greptile on #2405. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4502 Put the focus back in the password field after a wrong password Submitting swaps the prompt for the loading state, so the field lost focus and a keyboard user had to find it again. Raised by Greptile on #2404. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4502 Answer passwords on the same load, keep damaged pages from failing the open From review on #2404: - A password goes to pdf.js's onPassword callback on the live loading task, so a password (right or wrong) no longer downloads the file again or starts a new worker, and Retry carries no stale password. - A page whose size cannot be read borrows a neighbour's size and is logged; only a document with no readable page fails to open. - An unknown pdf.js error shows a plain message and is logged, instead of showing pdf.js's own wording. - destroy() handles the rejection pdf.js gives when the worker is gone. - The hook's request type is OpenRequest, not a shadow of fetch's Request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4503 Keep the picture through a zoom, follow pixel-ratio changes, let pages stay memoised From review on #2405: - A page draws off screen and copies the result in one step, so a zoom change keeps the old picture up instead of blanking every page; the canvas is still zeroed when the page leaves the window or unmounts. - useDevicePixelRatio follows a move to another screen or a browser zoom, and a change redraws the pages. - PdfPage calls renderOverlay itself, so a window change re-renders only the pages that entered or left it. - A page is 'rendering' from the moment it asks for the page, and a failed render is logged with its page number. - usePageWindow skips an element without data-page-index instead of reading it as page 0. - isRenderCancelled and pdfErrorName live in lib/pdf/pdfjs.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4502 Load the same pdf.js build and worker as v0, not the legacy build The legacy build put a second pdf.js engine and worker next to v0's. v1 now imports pdfjs-dist and its default worker, the copy v0 already ships. Also hands the rejected destroy promise in the loader test a handler up front, so Vitest no longer reports it as an unhandled rejection while the test waits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4504 [DEV] Add v1 PDF highlight layer and highlight normalisers (#2407) * UN-4504 [FEAT] Add v1 PDF highlight layer and highlight normalisers - lib/pdf/highlights turns every producer's shape into one Highlight {id, page, x, y, w, h, variant, tone, confidence?}, in fractions of the page box, so highlights hold their place at any zoom: - LLMWhisperer line_metadata lines, with line confidence read by type - word boxes, both as a separate list (confidence_metadata) and as the 5th element the agentic table worker sends with page_width; without page_width, x comes from the PDF page's aspect ratio rather than v0's division by page height - Agentic Prompt Studio percentage boxes Padding, zero-size and junk entries are dropped, and boxes are clamped to the page. A box spanning the page reads as a line. - HighlightLayer draws a page's highlights in default, active and low-confidence tones, as buttons reporting onHighlightClick when a handler is given. - PdfPageList takes highlights, activeHighlightId and onHighlightClick and hands each memoised page only its own highlights. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4504 [FEAT] Place v1 highlights through style custom properties Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4504 [FIX] Address review: unique word ids, word-only tone, painted-only blend, per-page active id - normaliseWords puts the line's page and position in the default id, so words on different lines no longer share ids. - Line entries keep their confidence for the label but are not toned; v0 never toned lines, and line confidence on scans sits below 0.9. - Warn with the page index when word boxes are dropped for want of a page width. - Highlights use mix-blend-multiply only once the page canvas holds a picture, so they stay visible on the dark placeholder. - Only the page holding the active highlight receives its id, so moving it re-renders two pages, not all of them. - HighlightLayer builds the style and data props once for both branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * UN-4505 [DEV] Add the v1 DocumentViewer with toolbar, zoom, keys and scroll handle (#2408) * UN-4504 [FEAT] Add v1 PDF highlight layer and highlight normalisers - lib/pdf/highlights turns every producer's shape into one Highlight {id, page, x, y, w, h, variant, tone, confidence?}, in fractions of the page box, so highlights hold their place at any zoom: - LLMWhisperer line_metadata lines, with line confidence read by type - word boxes, both as a separate list (confidence_metadata) and as the 5th element the agentic table worker sends with page_width; without page_width, x comes from the PDF page's aspect ratio rather than v0's division by page height - Agentic Prompt Studio percentage boxes Padding, zero-size and junk entries are dropped, and boxes are clamped to the page. A box spanning the page reads as a line. - HighlightLayer draws a page's highlights in default, active and low-confidence tones, as buttons reporting onHighlightClick when a handler is given. - PdfPageList takes highlights, activeHighlightId and onHighlightClick and hands each memoised page only its own highlights. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 [FEAT] Add the v1 DocumentViewer with toolbar, zoom, keys and scroll handle - DocumentViewer composes the loader, status screens, toolbar and page list: <DocumentViewer source highlights activeHighlightId onHighlightClick initialZoom ref />, with scrollToPage and scrollToHighlight on the ref. A scroll asked for while loading runs once the pages are laid out, and the active highlight is scrolled into view when it changes. - ViewerToolbar: previous/next, a page box (n / N, Enter to jump), zoom -/+, the zoom level, fit width and fit page, each a labelled button reachable with Tab. - Zoom: fit width by default, fitted from the viewer's size with a ResizeObserver; -/+ step through 25%-400% from wherever a fit left it. A zoom change keeps the same spot of the same page in view. - Keys while the pages have focus: PageUp/PageDown, Home/End, + and -; modified keys are left to the browser. - PdfPageList scrolls by the pages' laid-out offsets, which are exact before any page renders, and reports the page showing most of itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * UN-4504 [FEAT] Place v1 highlights through style custom properties Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 [FEAT] Keep fit-page steady and the page draft while scrolling - Fit page fits every page instead of the current one. With mixed page sizes, following the current page rescaled the whole document each time scrolling crossed into a page of another size. - The page box keeps a half-typed number and its focus when the current page changes; it follows the current page only while not being edited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 [FEAT] Drop a stale comment about remounting the page box Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Read the current page from the layout and tie waiting scrolls to their source The current page came from intersection ratios reported at quarter steps, so for a page more than four times the viewer's height it was arbitrary, and after a jump it lagged a frame: the toolbar showed the wrong page, PageDown could skip one, and the zoom anchor was recorded against the old page. Both now come from the laid-out page offsets, on scroll and straight after a jump. A scroll waiting for a document is dropped when another source is shown, and a highlight scroll whose highlight is not in the list yet waits and is tried again when the highlights change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Fix the end-of-document page and keep waiting scrolls made at commit Round two of the review: - the last page is current once the end is reached, so Next and PageDown no longer stick when a short last page or two whole pages show - the page in view is measured in the content's own coordinates, with one query per scroll, and again when the viewer changes height - a waiting scroll is stamped with its source and dropped only at flush time, so one made in a parent's layout effect on mount survives - the active highlight is re-armed for each new source, and clearing it drops only a request it made Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Keep the page jumped to current while the view stays there When the last few pages fit together, a jump to one of them is clamped at the end of the scroll and the end rule reported the last page again, so Previous and PageUp could not leave it. The page jumped to now stays current until the reader scrolls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Track the current page by share shown, centre highlights exactly, clamp a starting zoom - The current page is the one showing the largest share of itself, a page taller than the view counting the view as whole, so part of a tall page no longer beats a short page shown whole. - scrollToHighlight centres on the page's own top, padding included; page jumps keep the gap above the page. - A numeric initialZoom goes through clampZoom like a fitted one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4504 [FIX] Address review: unique word ids, word-only tone, painted-only blend, per-page active id - normaliseWords puts the line's page and position in the default id, so words on different lines no longer share ids. - Line entries keep their confidence for the label but are not toned; v0 never toned lines, and line confidence on scans sits below 0.9. - Warn with the page index when word boxes are dropped for want of a page width. - Highlights use mix-blend-multiply only once the page canvas holds a picture, so they stay visible on the dark placeholder. - Only the page holding the active highlight receives its id, so moving it re-renders two pages, not all of them. - HighlightLayer builds the style and data props once for both branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 [FIX] Address review: keys in fields, measured scrollbar, page reset, deferred fit layout - viewerKeyAction leaves keys typed into an input, textarea, select or contenteditable to that field. - Fit modes measure the platform scrollbar once (lib/scrollbar) instead of assuming 16 px, so a 17 px classic scrollbar no longer forces a horizontal scrollbar. - The page index resets while rendering when the document changes, so the toolbar never shows the last document's page against the new count. - In a fit mode the page list waits for the viewer's size, so pages are not laid out at 100% first and again at the fitted zoom. - sync() reads the page elements once per list, not on every scroll. - Document identity uses objectKey from lib/pdf/pdfjs, shared with pdfSourceKey. - useElementSize measures once where ResizeObserver is missing, matching the guard in PdfPageList. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 [FIX] Drop a waiting active-highlight scroll once the highlight is no longer active The retry effect runs before the effect that cancels a cleared selection, so a render that both delivers the highlight and clears it scrolled to a selection the reader had just cleared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Drop a waiting scroll as soon as the source changes A scroll queued for document A survived a switch to B and back to A while neither had opened, because the retry effect only clears a stale request once pages are shown; by then the key matched A again. It is now dropped on the source change itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Restore a typed page that is not a whole number Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * UN-4503 Follow a highlight shown after a page jump Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4506 [DEV] Show text and images in the v1 DocumentViewer, with a switcher slot (#2409) * UN-4504 [FEAT] Add v1 PDF highlight layer and highlight normalisers - lib/pdf/highlights turns every producer's shape into one Highlight {id, page, x, y, w, h, variant, tone, confidence?}, in fractions of the page box, so highlights hold their place at any zoom: - LLMWhisperer line_metadata lines, with line confidence read by type - word boxes, both as a separate list (confidence_metadata) and as the 5th element the agentic table worker sends with page_width; without page_width, x comes from the PDF page's aspect ratio rather than v0's division by page height - Agentic Prompt Studio percentage boxes Padding, zero-size and junk entries are dropped, and boxes are clamped to the page. A box spanning the page reads as a line. - HighlightLayer draws a page's highlights in default, active and low-confidence tones, as buttons reporting onHighlightClick when a handler is given. - PdfPageList takes highlights, activeHighlightId and onHighlightClick and hands each memoised page only its own highlights. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 [FEAT] Add the v1 DocumentViewer with toolbar, zoom, keys and scroll handle - DocumentViewer composes the loader, status screens, toolbar and page list: <DocumentViewer source highlights activeHighlightId onHighlightClick initialZoom ref />, with scrollToPage and scrollToHighlight on the ref. A scroll asked for while loading runs once the pages are laid out, and the active highlight is scrolled into view when it changes. - ViewerToolbar: previous/next, a page box (n / N, Enter to jump), zoom -/+, the zoom level, fit width and fit page, each a labelled button reachable with Tab. - Zoom: fit width by default, fitted from the viewer's size with a ResizeObserver; -/+ step through 25%-400% from wherever a fit left it. A zoom change keeps the same spot of the same page in view. - Keys while the pages have focus: PageUp/PageDown, Home/End, + and -; modified keys are left to the browser. - PdfPageList scrolls by the pages' laid-out offsets, which are exact before any page renders, and reports the page showing most of itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * UN-4506 [FEAT] Show text and images in the v1 DocumentViewer, with a switcher slot - DocumentViewer takes any DocumentSource: a PDF (URL or bytes), an image ({kind: "image", url}) or text ({kind: "text", text}). - TextView shows text line by line with line numbers, keeping the layout's spacing, and highlights whole lines (0-based, as LLMWhisperer numbers them) in default, active and low-confidence tones; highlighted lines report clicks and can be scrolled to. - ImageView shows an image as a one-page document: the toolbar's zoom and fits apply (zoom 1 is its natural size), page highlights draw over it, and a failed load offers Retry. - The toolbar gains a leading slot for the document switcher, shown in every state so a failed document can be switched away from; paging shows only for PDFs and zoom only for PDFs and images. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4504 [FEAT] Place v1 highlights through style custom properties Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4506 [FEAT] Size the v1 image view through style custom properties Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 [FEAT] Keep fit-page steady and the page draft while scrolling - Fit page fits every page instead of the current one. With mixed page sizes, following the current page rescaled the whole document each time scrolling crossed into a page of another size. - The page box keeps a half-typed number and its focus when the current page changes; it follows the current page only while not being edited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 [FEAT] Drop a stale comment about remounting the page box Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4506 [FEAT] Fix keys, scroll timing and highlights for text and images - Paging keys on text and images keep the browser's own scrolling; they used to be swallowed and jump to the top. Text ignores zoom keys. - Scroll requests wait until an image has loaded, not just until its view has mounted, so an early highlight scroll lands once the image is sized. - Returning to an image shown earlier waits for its reload instead of reusing the old size. - The image view draws only page 0's highlights. - Text rows are memoised, so moving the active highlight re-renders only the lines involved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Read the current page from the layout and tie waiting scrolls to their source The current page came from intersection ratios reported at quarter steps, so for a page more than four times the viewer's height it was arbitrary, and after a jump it lagged a frame: the toolbar showed the wrong page, PageDown could skip one, and the zoom anchor was recorded against the old page. Both now come from the laid-out page offsets, on scroll and straight after a jump. A scroll waiting for a document is dropped when another source is shown, and a highlight scroll whose highlight is not in the list yet waits and is tried again when the highlights change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Fix the end-of-document page and keep waiting scrolls made at commit Round two of the review: - the last page is current once the end is reached, so Next and PageDown no longer stick when a short last page or two whole pages show - the page in view is measured in the content's own coordinates, with one query per scroll, and again when the viewer changes height - a waiting scroll is stamped with its source and dropped only at flush time, so one made in a parent's layout effect on mount survives - the active highlight is re-armed for each new source, and clearing it drops only a request it made Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Keep the page jumped to current while the view stays there When the last few pages fit together, a jump to one of them is clamped at the end of the scroll and the end rule reported the last page again, so Previous and PageUp could not leave it. The page jumped to now stays current until the reader scrolls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4506 Give every text row one line-number gutter Each row sized its own number column, so line 10 started further right than line 9 and the text lost its columns. The gutter is now as wide as the longest line number, shared through a custom property. Raised by Greptile on #2409. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Track the current page by share shown, centre highlights exactly, clamp a starting zoom - The current page is the one showing the largest share of itself, a page taller than the view counting the view as whole, so part of a tall page no longer beats a short page shown whole. - scrollToHighlight centres on the page's own top, padding included; page jumps keep the gap above the page. - A numeric initialZoom goes through clampZoom like a fitted one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4506 Keep the horizontal place when jumping to a line on an image Only a box highlight moves the image view across, as on a PDF page; a line spans the image and keeps the reader's horizontal scroll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4504 [FIX] Address review: unique word ids, word-only tone, painted-only blend, per-page active id - normaliseWords puts the line's page and position in the default id, so words on different lines no longer share ids. - Line entries keep their confidence for the label but are not toned; v0 never toned lines, and line confidence on scans sits below 0.9. - Warn with the page index when word boxes are dropped for want of a page width. - Highlights use mix-blend-multiply only once the page canvas holds a picture, so they stay visible on the dark placeholder. - Only the page holding the active highlight receives its id, so moving it re-renders two pages, not all of them. - HighlightLayer builds the style and data props once for both branches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 [FIX] Address review: keys in fields, measured scrollbar, page reset, deferred fit layout - viewerKeyAction leaves keys typed into an input, textarea, select or contenteditable to that field. - Fit modes measure the platform scrollbar once (lib/scrollbar) instead of assuming 16 px, so a 17 px classic scrollbar no longer forces a horizontal scrollbar. - The page index resets while rendering when the document changes, so the toolbar never shows the last document's page against the new count. - In a fit mode the page list waits for the viewer's size, so pages are not laid out at 100% first and again at the fitted zoom. - sync() reads the page elements once per list, not on every scroll. - Document identity uses objectKey from lib/pdf/pdfjs, shared with pdfSourceKey. - useElementSize measures once where ResizeObserver is missing, matching the guard in PdfPageList. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4506 [FIX] Address review: text identity, image zoom and loading, one source key, empty text - A text source can carry an id; the view keeps its place while the id stays the same, so a live-edited text no longer remounts per keystroke. - One sourceKey drives every per-source reset (image size, page index) and keys the text and image views, replacing three separate mechanisms. - Zoom keys are ignored until an image has a size, so it still opens in its fit mode. - A scroll to a highlight on another page of an image is dropped instead of waiting for ever. - ImageView resets when its URL changes, reports a highlight scroll as not yet possible until it has loaded, and keeps the centre spot when zooming. - The image region is named Image viewer, so it no longer repeats the image's alt text. - Blank text says no text was found rather than no document. - Text lines use content-visibility, so the browser skips layout and paint for lines off screen while every line stays in the DOM. - One NO_HIGHLIGHTS constant and one page frame class are shared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 [FIX] Drop a waiting active-highlight scroll once the highlight is no longer active The retry effect runs before the effect that cancels a cleared selection, so a render that both delivers the highlight and clears it scrolled to a selection the reader had just cleared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4506 [FIX] Retry a waiting scroll when a text fills in, and anchor the first image zoom on the view - A text kept by its id changes in place, so a scroll waiting on it is retried when the text changes, not only when the highlights do. - ImageView reads its anchor from the view once the image is first laid out, instead of starting from the image's centre. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Drop a waiting scroll as soon as the source changes A scroll queued for document A survived a switch to B and back to A while neither had opened, because the retry effect only clears a stale request once pages are shown; by then the key matched A again. It is now dropped on the source change itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4505 Restore a typed page that is not a whole number Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * UN-4506 Centre a wrapped text highlight once its rows are drawn Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm --------- Co-authored-by: Jaseem Jas <jaseem@zipstack.com> Co-authored-by: pk-zipstack <praveen@zipstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 595a84c commit a194073

34 files changed

Lines changed: 5539 additions & 20 deletions

‎frontend/src/v1/components/patterns/document-viewer/DocumentViewer.test.tsx‎

Lines changed: 834 additions & 0 deletions
Large diffs are not rendered by default.

‎frontend/src/v1/components/patterns/document-viewer/DocumentViewer.tsx‎

Lines changed: 464 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import type { Highlight } from "@v1/lib/pdf/highlights";
4+
import { expect, it, vi } from "vitest";
5+
import { HighlightLayer } from "./HighlightLayer";
6+
7+
const line: Highlight = {
8+
id: "line-0",
9+
page: 0,
10+
x: 0,
11+
y: 0.25,
12+
w: 1,
13+
h: 0.02,
14+
variant: "line",
15+
tone: "default",
16+
confidence: 0.97,
17+
};
18+
const word: Highlight = {
19+
id: "word-0",
20+
page: 0,
21+
x: 0.5,
22+
y: 0.25,
23+
w: 0.1,
24+
h: 0.02,
25+
variant: "box",
26+
tone: "low-confidence",
27+
confidence: 0.62,
28+
};
29+
30+
function box(container: HTMLElement, id: string): HTMLElement {
31+
const element = container.querySelector<HTMLElement>(
32+
`[data-highlight-id="${id}"]`,
33+
);
34+
if (!element) {
35+
throw new Error(`no highlight ${id}`);
36+
}
37+
return element;
38+
}
39+
40+
it("places highlights as percentages of the page", () => {
41+
const { container } = render(<HighlightLayer highlights={[line, word]} />);
42+
const element = box(container, "word-0");
43+
expect(element.style.getPropertyValue("--hl-x")).toBe("50%");
44+
expect(element.style.getPropertyValue("--hl-y")).toBe("25%");
45+
expect(element.style.getPropertyValue("--hl-w")).toBe("10%");
46+
expect(element.style.getPropertyValue("--hl-h")).toBe("2%");
47+
expect(element.getAttribute("data-variant")).toBe("box");
48+
expect(box(container, "line-0").getAttribute("data-variant")).toBe("line");
49+
});
50+
51+
it("shows each highlight's tone, and the active one as active", () => {
52+
const { container } = render(
53+
<HighlightLayer highlights={[line, word]} activeId="line-0" />,
54+
);
55+
expect(box(container, "line-0").getAttribute("data-tone")).toBe("active");
56+
expect(box(container, "word-0").getAttribute("data-tone")).toBe(
57+
"low-confidence",
58+
);
59+
});
60+
61+
it("is not interactive without a click handler", () => {
62+
render(<HighlightLayer highlights={[line]} />);
63+
expect(screen.queryByRole("button")).toBeNull();
64+
});
65+
66+
it("reports clicks with the highlight, and labels each button", async () => {
67+
const onHighlightClick = vi.fn();
68+
render(
69+
<HighlightLayer
70+
highlights={[line, word]}
71+
activeId="word-0"
72+
onHighlightClick={onHighlightClick}
73+
/>,
74+
);
75+
const button = screen.getByRole("button", {
76+
name: "Highlight on page 1, 62% confidence",
77+
});
78+
expect(button.getAttribute("aria-current")).toBe("true");
79+
await userEvent.click(button);
80+
expect(onHighlightClick).toHaveBeenCalledWith(word);
81+
});
82+
83+
it("renders nothing for a page without highlights", () => {
84+
const { container } = render(<HighlightLayer highlights={[]} />);
85+
expect(container.firstChild).toBeNull();
86+
});
87+
88+
it("blends into the page only once the page has painted", () => {
89+
const { container, rerender } = render(
90+
<HighlightLayer highlights={[line]} painted={false} />,
91+
);
92+
// On the placeholder (dark in dark themes) multiply would hide the box.
93+
expect(box(container, "line-0").className).not.toContain(
94+
"mix-blend-multiply",
95+
);
96+
rerender(<HighlightLayer highlights={[line]} painted />);
97+
expect(box(container, "line-0").className).toContain("mix-blend-multiply");
98+
});
99+
100+
it("gives the clickable highlight the same geometry and data as the plain one", () => {
101+
const plain = render(<HighlightLayer highlights={[word]} />);
102+
const button = render(
103+
<HighlightLayer highlights={[word]} onHighlightClick={vi.fn()} />,
104+
);
105+
const a = box(plain.container, "word-0");
106+
const b = box(button.container, "word-0");
107+
expect(b.tagName).toBe("BUTTON");
108+
expect(b.getAttribute("style")).toBe(a.getAttribute("style"));
109+
expect(b.dataset).toEqual(a.dataset);
110+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { cn } from "@v1/lib/cn";
2+
import type { Highlight, HighlightTone } from "@v1/lib/pdf/highlights";
3+
4+
export interface HighlightLayerProps {
5+
/** This page's highlights. */
6+
highlights: readonly Highlight[];
7+
/** Shown in the active tone, whatever its own tone. */
8+
activeId?: string | null;
9+
/** When given, highlights are buttons that report clicks. */
10+
onHighlightClick?: (highlight: Highlight) => void;
11+
/**
12+
* Whether the page's canvas has painted. Until then the boxes sit on the
13+
* page placeholder, which is dark in dark themes, so they skip the multiply
14+
* blend (it would hide them) and show as plain translucent fills.
15+
*/
16+
painted?: boolean;
17+
}
18+
19+
const TONES: Record<HighlightTone, string> = {
20+
default: "ui:bg-primary/25",
21+
active: "ui:bg-primary/40 ui:ring-2 ui:ring-primary",
22+
"low-confidence": "ui:bg-destructive/25",
23+
};
24+
25+
function percent(fraction: number): string {
26+
return `${fraction * 100}%`;
27+
}
28+
29+
function label(highlight: Highlight, pageNumber: number): string {
30+
const confidence =
31+
highlight.confidence === undefined
32+
? ""
33+
: `, ${Math.round(highlight.confidence * 100)}% confidence`;
34+
return `Highlight on page ${pageNumber}${confidence}`;
35+
}
36+
37+
function HighlightBox({
38+
highlight,
39+
active,
40+
painted,
41+
onClick,
42+
}: {
43+
highlight: Highlight;
44+
active: boolean;
45+
painted: boolean;
46+
onClick?: (highlight: Highlight) => void;
47+
}) {
48+
// Page fractions as percentages of the page box, so the box needs no update
49+
// when the zoom changes. Runtime geometry: custom properties the classes read.
50+
const tone: HighlightTone = active ? "active" : highlight.tone;
51+
const shared = {
52+
style: {
53+
"--hl-x": percent(highlight.x),
54+
"--hl-y": percent(highlight.y),
55+
"--hl-w": percent(highlight.w),
56+
"--hl-h": percent(highlight.h),
57+
},
58+
"data-highlight-id": highlight.id,
59+
"data-variant": highlight.variant,
60+
"data-tone": tone,
61+
};
62+
const className = cn(
63+
"ui:absolute ui:top-(--hl-y) ui:left-(--hl-x) ui:h-(--hl-h) ui:w-(--hl-w) ui:rounded-sm",
64+
painted && "ui:mix-blend-multiply",
65+
TONES[tone],
66+
);
67+
if (onClick === undefined) {
68+
return <div className={className} {...shared} />;
69+
}
70+
return (
71+
<button
72+
type="button"
73+
{...shared}
74+
aria-label={label(highlight, highlight.page + 1)}
75+
aria-current={active ? "true" : undefined}
76+
onClick={() => onClick(highlight)}
77+
className={cn(
78+
className,
79+
"ui:pointer-events-auto ui:cursor-pointer ui:focus-visible:focus-ring",
80+
)}
81+
/>
82+
);
83+
}
84+
85+
/** The highlights of one page, laid over it. Sized by its page, not by itself. */
86+
export function HighlightLayer({
87+
highlights,
88+
activeId,
89+
onHighlightClick,
90+
painted = true,
91+
}: HighlightLayerProps) {
92+
if (highlights.length === 0) {
93+
return null;
94+
}
95+
return (
96+
<div className="ui:pointer-events-none ui:absolute ui:inset-0">
97+
{highlights.map((highlight) => (
98+
<HighlightBox
99+
key={highlight.id}
100+
highlight={highlight}
101+
active={highlight.id === activeId}
102+
painted={painted}
103+
onClick={onHighlightClick}
104+
/>
105+
))}
106+
</div>
107+
);
108+
}

0 commit comments

Comments
 (0)