Split PdfViewer.svelte (469 lines) into renderer module + controls component #196

Closed
opened 2026-04-07 10:48:04 +02:00 by marcel · 7 comments
Owner

Context

PdfViewer.svelte is the largest component in the codebase at 469 lines. It mixes three concerns: PDF.js rendering logic, page/zoom controls UI, and the canvas+annotation layout.

Proposed Split

1. usePdfRenderer.svelte.ts (~160 lines)

Extract all imperative PDF.js logic:

  • loadDocument() — PDF loading via dynamic import
  • renderPage() — canvas + text layer rendering with DPR handling
  • prerender() — neighbor page pre-caching
  • Scroll-sync effect (navigate to annotation's page)
  • State: pdfDoc, currentPage, totalPages, scale, loading, error
  • Exposed methods: prevPage, nextPage, zoomIn, zoomOut

2. PdfControls.svelte (~120 lines)

The control bar markup (page nav, zoom buttons, annotation toggle):

  • Props: currentPage, totalPages, scale, showAnnotations, annotationCount, onPrev, onNext, onZoomIn, onZoomOut, onToggleAnnotations
  • Pure presentational — no business logic

3. PdfViewer.svelte becomes orchestrator (~100 lines)

  • Imports usePdfRenderer and PdfControls
  • Holds canvas/textLayer refs
  • Renders: outdated notice, PdfControls, canvas area with AnnotationLayer
  • Passes callbacks between renderer and controls

Acceptance Criteria

  • PdfViewer.svelte under 120 lines
  • PDF rendering, page navigation, zoom all work identically
  • Annotation overlay and scroll-sync unaffected
  • Transcribe mode drawing still works
  • npm run check passes
## Context `PdfViewer.svelte` is the largest component in the codebase at 469 lines. It mixes three concerns: PDF.js rendering logic, page/zoom controls UI, and the canvas+annotation layout. ## Proposed Split ### 1. `usePdfRenderer.svelte.ts` (~160 lines) Extract all imperative PDF.js logic: - `loadDocument()` — PDF loading via dynamic import - `renderPage()` — canvas + text layer rendering with DPR handling - `prerender()` — neighbor page pre-caching - Scroll-sync effect (navigate to annotation's page) - State: `pdfDoc`, `currentPage`, `totalPages`, `scale`, `loading`, `error` - Exposed methods: `prevPage`, `nextPage`, `zoomIn`, `zoomOut` ### 2. `PdfControls.svelte` (~120 lines) The control bar markup (page nav, zoom buttons, annotation toggle): - Props: `currentPage`, `totalPages`, `scale`, `showAnnotations`, `annotationCount`, `onPrev`, `onNext`, `onZoomIn`, `onZoomOut`, `onToggleAnnotations` - Pure presentational — no business logic ### 3. `PdfViewer.svelte` becomes orchestrator (~100 lines) - Imports `usePdfRenderer` and `PdfControls` - Holds canvas/textLayer refs - Renders: outdated notice, PdfControls, canvas area with AnnotationLayer - Passes callbacks between renderer and controls ## Acceptance Criteria - [ ] PdfViewer.svelte under 120 lines - [ ] PDF rendering, page navigation, zoom all work identically - [ ] Annotation overlay and scroll-sync unaffected - [ ] Transcribe mode drawing still works - [ ] `npm run check` passes
marcel added the refactor label 2026-04-07 10:48:57 +02:00
Author
Owner

👨‍💻 Felix Brandt -- Senior Fullstack Developer

Good decomposition along visual boundaries. A few things I'd want nailed down before implementation:

usePdfRenderer.svelte.ts -- API surface

  • The issue lists pdfDoc, currentPage, totalPages, scale, loading, error as exposed state. These should all be $state runes inside the module, returned as a single object. Will the module accept canvas/textLayer refs as parameters, or will it query them internally? Accepting refs as params keeps the module testable without a DOM.
  • loadDocument() does a dynamic import('pdfjs-dist') -- will the module own the CDN worker URL setup (pdfjsLib.GlobalWorkerOptions.workerSrc), or does the caller configure that? I'd keep it inside the module so the concern is fully encapsulated.
  • prerender() for neighbor pages -- does it write to the same canvas, or to offscreen canvases? If offscreen, who owns those refs? This needs to be explicit in the module contract.

PdfControls.svelte -- props count

  • The proposed props list has 10 items (currentPage, totalPages, scale, showAnnotations, annotationCount, onPrev, onNext, onZoomIn, onZoomOut, onToggleAnnotations). That exceeds the 3-param guideline from our style guide. Consider grouping:
    • A pdfState object for the read-only values (currentPage, totalPages, scale)
    • A controls object for the callbacks (onPrev, onNext, onZoomIn, onZoomOut)
    • showAnnotations, annotationCount, onToggleAnnotations as a separate annotation concern -- or a second small component (AnnotationToggle)
  • Alternatively, if PdfControls is truly pure presentational, 10 flat props is tolerable under KISS. But the annotation toggle feels like a separate visual region.

Orchestrator line count

  • 100 lines for the orchestrator is reasonable, but watch the template markup. The canvas area with AnnotationLayer overlay and the outdated-notice conditional could push past 40 lines of template. If it does, the AnnotationLayer overlay might deserve its own component.

Testing strategy

  • usePdfRenderer.svelte.ts is the most valuable thing to unit test here. Since it's a pure module returning state and methods, it can be tested with Vitest without a browser -- mock pdfjs-dist at the import boundary, assert state transitions on prevPage/nextPage/zoomIn/zoomOut.
  • PdfControls is presentational -- a render test asserting button presence and callback invocation is sufficient.
  • The orchestrator wiring is best covered by the existing E2E tests (acceptance criteria already mention "PDF rendering, page navigation, zoom all work identically").

Scroll-sync effect

  • The issue places scroll-sync (navigate to annotation's page) inside usePdfRenderer. Scroll-sync depends on annotation data, which is a concern of the parent, not the renderer. Should the module expose a goToPage(n) method instead, and let the orchestrator call it when the selected annotation changes? That keeps annotation knowledge out of the renderer module.
## 👨‍💻 Felix Brandt -- Senior Fullstack Developer Good decomposition along visual boundaries. A few things I'd want nailed down before implementation: ### usePdfRenderer.svelte.ts -- API surface - The issue lists `pdfDoc`, `currentPage`, `totalPages`, `scale`, `loading`, `error` as exposed state. These should all be `$state` runes inside the module, returned as a single object. Will the module accept canvas/textLayer refs as parameters, or will it query them internally? Accepting refs as params keeps the module testable without a DOM. - `loadDocument()` does a dynamic `import('pdfjs-dist')` -- will the module own the CDN worker URL setup (`pdfjsLib.GlobalWorkerOptions.workerSrc`), or does the caller configure that? I'd keep it inside the module so the concern is fully encapsulated. - `prerender()` for neighbor pages -- does it write to the same canvas, or to offscreen canvases? If offscreen, who owns those refs? This needs to be explicit in the module contract. ### PdfControls.svelte -- props count - The proposed props list has 10 items (`currentPage`, `totalPages`, `scale`, `showAnnotations`, `annotationCount`, `onPrev`, `onNext`, `onZoomIn`, `onZoomOut`, `onToggleAnnotations`). That exceeds the 3-param guideline from our style guide. Consider grouping: - A `pdfState` object for the read-only values (`currentPage`, `totalPages`, `scale`) - A `controls` object for the callbacks (`onPrev`, `onNext`, `onZoomIn`, `onZoomOut`) - `showAnnotations`, `annotationCount`, `onToggleAnnotations` as a separate annotation concern -- or a second small component (`AnnotationToggle`) - Alternatively, if PdfControls is truly pure presentational, 10 flat props is tolerable under KISS. But the annotation toggle feels like a separate visual region. ### Orchestrator line count - 100 lines for the orchestrator is reasonable, but watch the template markup. The canvas area with AnnotationLayer overlay and the outdated-notice conditional could push past 40 lines of template. If it does, the AnnotationLayer overlay might deserve its own component. ### Testing strategy - `usePdfRenderer.svelte.ts` is the most valuable thing to unit test here. Since it's a pure module returning state and methods, it can be tested with Vitest without a browser -- mock `pdfjs-dist` at the import boundary, assert state transitions on `prevPage`/`nextPage`/`zoomIn`/`zoomOut`. - PdfControls is presentational -- a render test asserting button presence and callback invocation is sufficient. - The orchestrator wiring is best covered by the existing E2E tests (acceptance criteria already mention "PDF rendering, page navigation, zoom all work identically"). ### Scroll-sync effect - The issue places scroll-sync (navigate to annotation's page) inside `usePdfRenderer`. Scroll-sync depends on annotation data, which is a concern of the parent, not the renderer. Should the module expose a `goToPage(n)` method instead, and let the orchestrator call it when the selected annotation changes? That keeps annotation knowledge out of the renderer module.
Author
Owner

🏗️ Markus Keller -- Senior Application Architect

Clean separation of concerns. The three-part split (imperative logic module, presentational control bar, orchestrator shell) follows the pattern we use elsewhere in the codebase. A few architectural observations:

Module boundary clarity

  • usePdfRenderer.svelte.ts is a Svelte 5 reactive module (runes in .svelte.ts files). This is the correct pattern for encapsulating imperative browser APIs behind reactive state. One thing to define explicitly: what are the inputs to this module? At minimum it needs:
    • The PDF file URL
    • Canvas and text layer element references
    • Possibly a container width for scale calculations
  • If the input set grows beyond 3-4 items, consider a config object to keep the call site clean.

Dependency direction

  • The proposed flow is: PdfViewer (orchestrator) --> usePdfRenderer (logic) + PdfControls (UI). This is correct -- logic and presentation are siblings, orchestrated by the parent. Neither should know about the other.
  • One risk: if PdfControls needs to display loading/error states from the renderer, the orchestrator becomes a pass-through for every piece of state. This is fine at ~5-6 props but watch for it becoming a prop-drilling problem. If it does, the renderer's returned state object can be spread directly.

Annotation layer ownership

  • The issue says the orchestrator "renders canvas area with AnnotationLayer." AnnotationLayer is already a separate concern (drawing rectangles, handling mouse events for transcribe mode). Is AnnotationLayer currently inlined in PdfViewer, or is it already a separate component? If inlined, this refactor is the right time to extract it. If already separate, the orchestrator just composes it -- no change needed.

No new external dependencies

  • This is a pure internal refactor with no new libraries, no new API endpoints, no schema changes. That's ideal -- the blast radius is contained to one component tree. No ADR needed.

Acceptance criteria gap

  • The acceptance criteria don't mention keyboard navigation. PDF viewers commonly support arrow keys for page navigation and +/- for zoom. If that behavior exists today, it should be listed as a preservation requirement. If it doesn't exist, that's fine -- but worth confirming so it isn't accidentally broken or accidentally expected.
## 🏗️ Markus Keller -- Senior Application Architect Clean separation of concerns. The three-part split (imperative logic module, presentational control bar, orchestrator shell) follows the pattern we use elsewhere in the codebase. A few architectural observations: ### Module boundary clarity - `usePdfRenderer.svelte.ts` is a Svelte 5 reactive module (runes in `.svelte.ts` files). This is the correct pattern for encapsulating imperative browser APIs behind reactive state. One thing to define explicitly: what are the **inputs** to this module? At minimum it needs: - The PDF file URL - Canvas and text layer element references - Possibly a container width for scale calculations - If the input set grows beyond 3-4 items, consider a config object to keep the call site clean. ### Dependency direction - The proposed flow is: `PdfViewer (orchestrator) --> usePdfRenderer (logic) + PdfControls (UI)`. This is correct -- logic and presentation are siblings, orchestrated by the parent. Neither should know about the other. - One risk: if PdfControls needs to display loading/error states from the renderer, the orchestrator becomes a pass-through for every piece of state. This is fine at ~5-6 props but watch for it becoming a prop-drilling problem. If it does, the renderer's returned state object can be spread directly. ### Annotation layer ownership - The issue says the orchestrator "renders canvas area with AnnotationLayer." AnnotationLayer is already a separate concern (drawing rectangles, handling mouse events for transcribe mode). Is AnnotationLayer currently inlined in PdfViewer, or is it already a separate component? If inlined, this refactor is the right time to extract it. If already separate, the orchestrator just composes it -- no change needed. ### No new external dependencies - This is a pure internal refactor with no new libraries, no new API endpoints, no schema changes. That's ideal -- the blast radius is contained to one component tree. No ADR needed. ### Acceptance criteria gap - The acceptance criteria don't mention keyboard navigation. PDF viewers commonly support arrow keys for page navigation and +/- for zoom. If that behavior exists today, it should be listed as a preservation requirement. If it doesn't exist, that's fine -- but worth confirming so it isn't accidentally broken or accidentally expected.
Author
Owner

🧪 Sara Holt -- Senior QA Engineer

A 469-line component splitting into three pieces is a testability win. Here's what I'd want covered:

Test strategy per artifact

Artifact Test layer What to assert
usePdfRenderer.svelte.ts Unit (Vitest) State transitions: loading -> loaded, page navigation bounds (can't go below 1 or above totalPages), zoom bounds, error state on failed load
PdfControls.svelte Unit (Vitest + testing-library) Button rendering, disabled states (prev on page 1, next on last page), callback invocation on click
PdfViewer.svelte (orchestrator) E2E (Playwright) Full integration: load a real PDF, navigate pages, zoom, toggle annotations, verify canvas renders

Regression risks to test explicitly

  • Page navigation after split: prevPage/nextPage must clamp to [1, totalPages]. If this clamping currently lives in the template (e.g., disabled={currentPage <= 1} without a guard in the handler), the extraction to usePdfRenderer must move the guard into the module, not just the UI.
  • DPR (device pixel ratio) handling: The issue mentions "canvas + text layer rendering with DPR handling." DPR logic is notoriously fragile -- a test that renders at a mocked window.devicePixelRatio = 2 and verifies the canvas dimensions are doubled would catch regressions.
  • Prerender side effects: The prerender() function for neighbor pages is a side effect. After extraction, verify it doesn't fire during initial load (causing double-render) or on unmount (causing errors on destroyed canvases).
  • Transcribe mode drawing: Listed in acceptance criteria but easy to miss in testing. This involves mouse event listeners on the canvas overlay -- verify they still fire after the component restructuring.

Testability improvement from this refactor

  • Today, testing PDF logic requires rendering the full 469-line PdfViewer. After the split, usePdfRenderer can be tested in isolation by mocking pdfjs-dist. This drops the unit test complexity significantly and removes the need for a browser environment for logic-only tests.
  • I'd recommend adding at least 5-6 unit tests for usePdfRenderer as part of this ticket, not as a follow-up. The module is the highest-risk piece since it owns all the imperative PDF.js interaction.

Acceptance criteria suggestion

  • Add: "Existing E2E tests for document detail page pass without modification." This confirms the refactor is behavior-preserving at the integration level without needing to enumerate every scenario.
## 🧪 Sara Holt -- Senior QA Engineer A 469-line component splitting into three pieces is a testability win. Here's what I'd want covered: ### Test strategy per artifact | Artifact | Test layer | What to assert | |---|---|---| | `usePdfRenderer.svelte.ts` | Unit (Vitest) | State transitions: loading -> loaded, page navigation bounds (can't go below 1 or above totalPages), zoom bounds, error state on failed load | | `PdfControls.svelte` | Unit (Vitest + testing-library) | Button rendering, disabled states (prev on page 1, next on last page), callback invocation on click | | `PdfViewer.svelte` (orchestrator) | E2E (Playwright) | Full integration: load a real PDF, navigate pages, zoom, toggle annotations, verify canvas renders | ### Regression risks to test explicitly - **Page navigation after split**: prevPage/nextPage must clamp to [1, totalPages]. If this clamping currently lives in the template (e.g., `disabled={currentPage <= 1}` without a guard in the handler), the extraction to `usePdfRenderer` must move the guard into the module, not just the UI. - **DPR (device pixel ratio) handling**: The issue mentions "canvas + text layer rendering with DPR handling." DPR logic is notoriously fragile -- a test that renders at a mocked `window.devicePixelRatio = 2` and verifies the canvas dimensions are doubled would catch regressions. - **Prerender side effects**: The `prerender()` function for neighbor pages is a side effect. After extraction, verify it doesn't fire during initial load (causing double-render) or on unmount (causing errors on destroyed canvases). - **Transcribe mode drawing**: Listed in acceptance criteria but easy to miss in testing. This involves mouse event listeners on the canvas overlay -- verify they still fire after the component restructuring. ### Testability improvement from this refactor - Today, testing PDF logic requires rendering the full 469-line PdfViewer. After the split, `usePdfRenderer` can be tested in isolation by mocking `pdfjs-dist`. This drops the unit test complexity significantly and removes the need for a browser environment for logic-only tests. - I'd recommend adding at least 5-6 unit tests for `usePdfRenderer` as part of this ticket, not as a follow-up. The module is the highest-risk piece since it owns all the imperative PDF.js interaction. ### Acceptance criteria suggestion - Add: "Existing E2E tests for document detail page pass without modification." This confirms the refactor is behavior-preserving at the integration level without needing to enumerate every scenario.
Author
Owner

🔒 Nora "NullX" Steiner -- Application Security Engineer

This is a structural refactor, not a feature change, so the attack surface should remain identical. A few things to verify:

PDF.js dynamic import

  • The issue mentions loadDocument() uses a dynamic import('pdfjs-dist'). After extraction to usePdfRenderer.svelte.ts, confirm the import path is still a static string literal, not constructed from user input. A dynamic import with a variable path (import(userInput)) would be a code injection vector. This is almost certainly fine, but worth a line-level check during review.

Canvas and text layer -- XSS surface

  • PDF.js renders text content into a text layer div for copy/paste support. The text layer uses document.createElement('span') internally (safe), but if any custom code in the current PdfViewer manipulates innerHTML of the text layer (e.g., for highlighting search terms), that logic must remain sanitized after extraction. Verify no innerHTML assignments exist that take PDF-derived content.

Worker URL configuration

  • PDF.js uses a web worker for parsing. The worker URL (pdfjsLib.GlobalWorkerOptions.workerSrc) is a sensitive configuration -- if it's set to a CDN URL, ensure it uses a versioned, integrity-checked URL (SRI hash via import map or bundler config). If it's a local file, confirm it's served from the app's own origin. After this refactor, the worker config moves into usePdfRenderer -- a good time to verify this is locked down.

Blob URL handling

  • PDF files are likely loaded via a blob URL or a presigned S3/MinIO URL. After the split, usePdfRenderer will own the URL handling. Confirm that:
    • Blob URLs are revoked after use (URL.revokeObjectURL) to prevent memory leaks that could be exploited for resource exhaustion
    • Presigned URLs are not logged or exposed in client-side state that persists beyond the component lifecycle

No new trust boundaries

  • Since this refactor doesn't introduce new API calls, new user inputs, or new data flows, the security posture is unchanged. The main risk is accidental exposure of internal state (e.g., the pdfDoc object) to components that shouldn't have it. Keep the module's return type narrow -- expose methods and primitive state, not the raw PDF.js document object.
## 🔒 Nora "NullX" Steiner -- Application Security Engineer This is a structural refactor, not a feature change, so the attack surface should remain identical. A few things to verify: ### PDF.js dynamic import - The issue mentions `loadDocument()` uses a dynamic `import('pdfjs-dist')`. After extraction to `usePdfRenderer.svelte.ts`, confirm the import path is still a static string literal, not constructed from user input. A dynamic import with a variable path (`import(userInput)`) would be a code injection vector. This is almost certainly fine, but worth a line-level check during review. ### Canvas and text layer -- XSS surface - PDF.js renders text content into a text layer div for copy/paste support. The text layer uses `document.createElement('span')` internally (safe), but if any custom code in the current PdfViewer manipulates `innerHTML` of the text layer (e.g., for highlighting search terms), that logic must remain sanitized after extraction. Verify no `innerHTML` assignments exist that take PDF-derived content. ### Worker URL configuration - PDF.js uses a web worker for parsing. The worker URL (`pdfjsLib.GlobalWorkerOptions.workerSrc`) is a sensitive configuration -- if it's set to a CDN URL, ensure it uses a versioned, integrity-checked URL (SRI hash via import map or bundler config). If it's a local file, confirm it's served from the app's own origin. After this refactor, the worker config moves into `usePdfRenderer` -- a good time to verify this is locked down. ### Blob URL handling - PDF files are likely loaded via a blob URL or a presigned S3/MinIO URL. After the split, `usePdfRenderer` will own the URL handling. Confirm that: - Blob URLs are revoked after use (`URL.revokeObjectURL`) to prevent memory leaks that could be exploited for resource exhaustion - Presigned URLs are not logged or exposed in client-side state that persists beyond the component lifecycle ### No new trust boundaries - Since this refactor doesn't introduce new API calls, new user inputs, or new data flows, the security posture is unchanged. The main risk is accidental exposure of internal state (e.g., the `pdfDoc` object) to components that shouldn't have it. Keep the module's return type narrow -- expose methods and primitive state, not the raw PDF.js document object.
Author
Owner

🎨 Leonie Voss -- UI/UX Design Lead

The split is structural, but any time the control bar component is extracted, there's a risk of subtle visual regressions. Here's what I'd watch:

PdfControls as a standalone component -- visual contract

  • When PdfControls becomes its own .svelte file, its styling must be self-contained. Verify that no parent CSS in the current PdfViewer is styling the controls via descendant selectors (e.g., .pdf-viewer .controls button). If so, those styles must move into PdfControls or be converted to Tailwind utility classes on the elements themselves.
  • The control bar's position (sticky? fixed? relative to the canvas?) must be documented in the component. If it's currently positioned via a parent flex/grid layout, the orchestrator must preserve that layout relationship.

Annotation toggle -- visual grouping

  • The issue groups the annotation toggle with page nav and zoom in PdfControls. From a UX perspective, annotation toggle is a mode switch (it changes what the user sees), while page/zoom are navigation controls. These are cognitively different actions. Consider:
    • Visual separation within PdfControls (a divider or spacing gap between nav/zoom and the annotation toggle)
    • Or extracting the annotation toggle as a separate small component, as Felix suggested

Responsive behavior preservation

  • The current PdfViewer likely handles mobile layout (stacking controls, smaller buttons, or hiding zoom on narrow viewports). After extraction, PdfControls must own its own responsive behavior. Check:
    • Does the control bar collapse or reflow at 320px?
    • Are touch targets still >= 44x44px on all buttons?
    • Does the zoom percentage text remain readable (>= 12px) at all breakpoints?

Loading and error states

  • When loading is true or error is set, the control bar should reflect this (disabled buttons, a loading indicator, or hiding entirely). After the split, these states flow from the renderer through the orchestrator to PdfControls. Verify the visual treatment of these states survives the refactor -- a missing disabled prop on a button during loading would be a regression.

Acceptance criteria suggestion

  • Add: "Visual appearance of the PDF viewer area is pixel-identical before and after the refactor at 320px, 768px, and 1440px viewports." A quick Playwright screenshot comparison would catch any layout drift from the restructuring.
## 🎨 Leonie Voss -- UI/UX Design Lead The split is structural, but any time the control bar component is extracted, there's a risk of subtle visual regressions. Here's what I'd watch: ### PdfControls as a standalone component -- visual contract - When PdfControls becomes its own `.svelte` file, its styling must be self-contained. Verify that no parent CSS in the current PdfViewer is styling the controls via descendant selectors (e.g., `.pdf-viewer .controls button`). If so, those styles must move into PdfControls or be converted to Tailwind utility classes on the elements themselves. - The control bar's position (sticky? fixed? relative to the canvas?) must be documented in the component. If it's currently positioned via a parent flex/grid layout, the orchestrator must preserve that layout relationship. ### Annotation toggle -- visual grouping - The issue groups the annotation toggle with page nav and zoom in PdfControls. From a UX perspective, annotation toggle is a **mode switch** (it changes what the user sees), while page/zoom are **navigation controls**. These are cognitively different actions. Consider: - Visual separation within PdfControls (a divider or spacing gap between nav/zoom and the annotation toggle) - Or extracting the annotation toggle as a separate small component, as Felix suggested ### Responsive behavior preservation - The current PdfViewer likely handles mobile layout (stacking controls, smaller buttons, or hiding zoom on narrow viewports). After extraction, PdfControls must own its own responsive behavior. Check: - Does the control bar collapse or reflow at 320px? - Are touch targets still >= 44x44px on all buttons? - Does the zoom percentage text remain readable (>= 12px) at all breakpoints? ### Loading and error states - When `loading` is true or `error` is set, the control bar should reflect this (disabled buttons, a loading indicator, or hiding entirely). After the split, these states flow from the renderer through the orchestrator to PdfControls. Verify the visual treatment of these states survives the refactor -- a missing `disabled` prop on a button during loading would be a regression. ### Acceptance criteria suggestion - Add: "Visual appearance of the PDF viewer area is pixel-identical before and after the refactor at 320px, 768px, and 1440px viewports." A quick Playwright screenshot comparison would catch any layout drift from the restructuring.
Author
Owner

⚙️ Tobias Wendt -- DevOps & Platform Engineer

Pure frontend refactor, no infra impact. A few things on the build/CI side:

Bundle size check

  • Extracting usePdfRenderer.svelte.ts as a separate module should not change the bundle size -- Vite's tree-shaking and code-splitting handle .svelte.ts modules the same as inline script blocks. But worth confirming: run npm run build before and after and compare the output chunk sizes in .svelte-kit/output. If pdfjs-dist suddenly ends up in a different chunk or gets duplicated, that's a regression.
  • The dynamic import('pdfjs-dist') should still produce a separate async chunk. Verify this doesn't change to a static import during the refactor, which would pull the entire PDF.js library (~400KB) into the main bundle.

CI pipeline impact

  • No new dependencies, no new services, no new environment variables. The existing CI workflow (build + check + test + E2E) covers this refactor without modification.
  • npm run check (svelte-check) is already in the acceptance criteria -- good. This will catch any type errors from the module extraction.

Dev server behavior

  • Vite HMR handles .svelte.ts modules well, but the first time a developer opens a document detail page after this change, the HMR boundary will be different (three files instead of one). If HMR doesn't pick up changes to usePdfRenderer.svelte.ts correctly during development, a full page reload may be needed. Not a blocker, but worth noting in the PR description so developers know what to expect.

Source map quality

  • After splitting, the source maps for the PDF viewer area will reference three files instead of one. This is actually an improvement for debugging -- stack traces will point to usePdfRenderer.svelte.ts:42 instead of PdfViewer.svelte:387, making production error reports more actionable.

No deployment changes needed

  • Same Docker image, same Caddy config, same health checks. This is the kind of refactor I like -- zero operational risk.
## ⚙️ Tobias Wendt -- DevOps & Platform Engineer Pure frontend refactor, no infra impact. A few things on the build/CI side: ### Bundle size check - Extracting `usePdfRenderer.svelte.ts` as a separate module should not change the bundle size -- Vite's tree-shaking and code-splitting handle `.svelte.ts` modules the same as inline script blocks. But worth confirming: run `npm run build` before and after and compare the output chunk sizes in `.svelte-kit/output`. If `pdfjs-dist` suddenly ends up in a different chunk or gets duplicated, that's a regression. - The dynamic `import('pdfjs-dist')` should still produce a separate async chunk. Verify this doesn't change to a static import during the refactor, which would pull the entire PDF.js library (~400KB) into the main bundle. ### CI pipeline impact - No new dependencies, no new services, no new environment variables. The existing CI workflow (build + check + test + E2E) covers this refactor without modification. - `npm run check` (svelte-check) is already in the acceptance criteria -- good. This will catch any type errors from the module extraction. ### Dev server behavior - Vite HMR handles `.svelte.ts` modules well, but the first time a developer opens a document detail page after this change, the HMR boundary will be different (three files instead of one). If HMR doesn't pick up changes to `usePdfRenderer.svelte.ts` correctly during development, a full page reload may be needed. Not a blocker, but worth noting in the PR description so developers know what to expect. ### Source map quality - After splitting, the source maps for the PDF viewer area will reference three files instead of one. This is actually an improvement for debugging -- stack traces will point to `usePdfRenderer.svelte.ts:42` instead of `PdfViewer.svelte:387`, making production error reports more actionable. ### No deployment changes needed - Same Docker image, same Caddy config, same health checks. This is the kind of refactor I like -- zero operational risk.
Author
Owner

Comprehensive Response to All Persona Reviews

I read the full source of PdfViewer.svelte (469 lines), AnnotationLayer.svelte (176 lines), DocumentViewer.svelte (107 lines), the existing test file PdfViewer.svelte.spec.ts (53 lines), and the blob URL creation sites. Here are concrete answers to every concern.


Felix Brandt -- Senior Fullstack Developer

usePdfRenderer API surface -- refs as params vs internal query:
The module should accept canvasEl and textLayerEl as parameters. Currently these are $state bindings at lines 40-41. Passing them in keeps the module testable without a DOM -- correct call.

Worker URL ownership:
Lines 62-71 show the dynamic import and GlobalWorkerOptions.workerSrc setup happening in onMount. This should move entirely into usePdfRenderer -- the module owns the full PDF.js lifecycle. The caller never needs to know about the worker.

Prerender -- offscreen canvases:
prerender() at lines 161-170 does NOT render to any canvas. It only calls doc.getPage(n) to warm PDF.js's internal page cache. No offscreen canvas refs needed. The module contract is simple: prerender is a fire-and-forget side effect with no output.

PdfControls -- 10 props:
Confirmed: currentPage, totalPages, scale, showAnnotations, annotationCount, onPrev, onNext, onZoomIn, onZoomOut, onToggleAnnotations = 10 props. Grouping into pdfState + controls objects is cleaner but adds indirection for a pure presentational component. I'd go with KISS here -- 10 flat props for a leaf component is tolerable. The annotation toggle is visually separated already (line 392: it's in its own conditional block, rendered only when annotations.length > 0). Extracting a separate AnnotationToggle component for ~35 lines of SVG button feels like over-splitting.

Scroll-sync placement:
Strong agree. The scroll-sync effect (lines 222-246) depends on annotations and activeAnnotationId, which are annotation concerns, not renderer concerns. The module should expose a goToPage(n: number) method, and the orchestrator calls it when activeAnnotationId changes. This keeps annotation knowledge out of the renderer. The requestAnimationFrame scroll-into-view logic (lines 240-245) also stays in the orchestrator since it queries document.querySelector for annotation DOM elements.


Markus Keller -- Senior Application Architect

Module inputs:
The module needs: (1) PDF file URL, (2) canvas ref, (3) textLayer ref. Scale is internal state. Container width is not used -- scale is a fixed multiplier (line 35: scale = 1.5), not computed from container dimensions. Input count is 3, well within the guideline.

State pass-through risk:
The orchestrator passes ~5-6 read-only values from the renderer to PdfControls. This is acceptable. If it grows, we can spread the renderer's returned state object directly as Felix suggested.

AnnotationLayer ownership:
AnnotationLayer is ALREADY a separate component (frontend/src/lib/components/AnnotationLayer.svelte, 176 lines). It is imported at PdfViewer line 4 and composed at lines 453-461. No extraction needed -- the orchestrator just keeps composing it as-is.

Keyboard navigation:
There is NO keyboard navigation in the current PdfViewer. No keydown/keypress handlers exist anywhere in the component. The only keyboard handling is in AnnotationLayer.svelte line 115-117 (Enter/Space on annotation buttons for a11y). So there is nothing to preserve or accidentally break. This does NOT need to be added as part of this refactor -- it's a separate feature if desired.


Sara Holt -- Senior QA Engineer

Existing tests:
PdfViewer.svelte.spec.ts exists with 3 tests:

  1. Navigation buttons render (prev/next)
  2. Zoom controls render (zoom in/out)
  3. Page counter displays after load ("1 / 2")

These are browser-rendered component tests using vitest-browser-svelte with a mocked pdfjs-dist. They cover button presence but NOT: disabled states, callback invocation, page bounds clamping, zoom bounds, or error states.

Page bounds clamping:
Currently implemented in the handlers themselves:

  • prevPage() line 248-250: if (currentPage > 1) -- guard is in the handler, not just the template
  • nextPage() line 252-254: if (pdfDoc && currentPage < pdfDoc.numPages) -- guard is in the handler
  • zoomOut() line 260: if (scale > 0.5) -- guard is in the handler

These guards will transfer cleanly into usePdfRenderer. The template also has disabled attributes (lines 312, 334) which are redundant UI hints -- correct pattern.

DPR handling:
Line 113: const dpr = window.devicePixelRatio || 1. Canvas is sized at viewport.width (which includes scale * dpr) and CSS-sized at viewport.width / dpr. A unit test mocking window.devicePixelRatio = 2 and asserting canvas dimensions would be valuable. Adding this to the usePdfRenderer test suite is feasible.

Prerender side effects:
prerender() is called at line 207, only inside the .then() of a successful renderPage. It won't fire during initial load (before pdfDoc is set) or on unmount. After extraction, this call sequence stays the same.

Recommended test additions for this ticket (not follow-up):

  • usePdfRenderer: prevPage clamps at 1, nextPage clamps at totalPages, zoomOut clamps at 0.5, loadDocument sets error state on rejection, loading transitions
  • PdfControls: prev disabled on page 1, next disabled on last page, callback invocation

Acceptance criteria addition -- agreed:
"Existing E2E tests in documents.spec.ts (59 PDF-related assertions) pass without modification" should be added.


Nora "NullX" Steiner -- Application Security Engineer

Dynamic import path:
Lines 64-66 use static string literals only:

import('pdfjs-dist')
import('pdfjs-dist/build/pdf.worker.min.mjs?url')

No user input anywhere near these import paths. Safe after extraction -- the strings move as-is into usePdfRenderer.

innerHTML / XSS surface:
One innerHTML assignment exists at line 144: textDiv.innerHTML = ''. This is a clearing operation (empty string), not injecting content. All text layer content is rendered by PDF.js's TextLayer class using document.createElement('span') internally. No custom innerHTML with PDF-derived content anywhere. Safe.

Worker URL:
Line 66-68: The worker URL comes from a Vite ?url import of a local file (pdfjs-dist/build/pdf.worker.min.mjs). This resolves to the app's own origin at build time -- no CDN, no external URL. Vite handles the asset hashing. After extraction into usePdfRenderer, this stays identical.

Blob URL handling:
Blob URLs are created in the page routes, NOT in PdfViewer:

  • frontend/src/routes/documents/[id]/+page.svelte line 40: fileUrl = URL.createObjectURL(blob)
  • frontend/src/routes/enrich/[id]/+page.svelte line 42: same

Neither page calls URL.revokeObjectURL(). This is a pre-existing memory leak, unrelated to this refactor. Worth a separate bug ticket but out of scope here. PdfViewer receives the URL as a prop string -- it never creates or manages blob URLs.

Return type narrowness:
Agreed. usePdfRenderer should return primitive state (currentPage, totalPages, scale, loading, error) and methods (prevPage, nextPage, zoomIn, zoomOut, goToPage, loadDocument). The raw pdfDoc object should NOT be exposed. Currently pdfDoc is only read externally for the disabled check at line 334 (disabled={!pdfDoc || ...}), which can be replaced with a boolean isLoaded in the return type.


Leonie Voss -- UI/UX Design Lead

Parent CSS / descendant selectors:
No CSS files contain .pdf-viewer, .textLayer, or .pdf-page selectors. All styling is inline Tailwind utilities on the elements themselves. The only class-based styling is textLayer (line 449) and pdf-page (line 442), and no global CSS targets these. PdfControls extraction carries zero risk of breaking inherited styles.

Control bar positioning:
The control bar (lines 305-429) uses shrink-0 inside a flex flex-col parent. It's flow-positioned (not sticky/fixed) at the top of the flex column. The orchestrator must preserve the flex flex-col wrapper, and PdfControls is just a flex child. Simple.

Annotation toggle visual separation:
The annotation toggle (lines 392-428) is already visually separated -- it's in its own flex group at the right side of the justify-between control bar. Page nav is left, zoom is center, annotation toggle is right. The existing layout achieves the cognitive separation Leonie wants without a divider element.

Responsive behavior:
There are NO breakpoint-specific styles in the PdfViewer control bar. No sm:, md:, lg: classes anywhere in lines 305-429. The controls use flex-wrap-free layout with small buttons (p-1 = 4px padding) and small text (text-xs). At 320px, the control bar is tight but doesn't overflow (3 groups in a justify-between flex row). Touch targets: the nav/zoom buttons are p-1 with a 16x16 SVG, giving ~24x24px hit area -- below the 44x44 recommendation. This is a pre-existing issue, not introduced by this refactor.

Loading/error state flow:
loading and error states (lines 433-438, 266-279) are currently used to conditionally render the canvas area and show error messages. After extraction, these flow from usePdfRenderer through the orchestrator. The control bar does NOT currently react to loading/error -- it renders regardless. If we want disabled buttons during loading, that's a new feature, not a regression.

Pixel-identical verification:
Agreed as a lightweight check. The proofshot skill can capture before/after at key viewports. Adding this to acceptance criteria is reasonable.


Tobias Wendt -- DevOps & Platform Engineer

Bundle size / dynamic import preservation:
The dynamic import at lines 64-66 uses static string literals. Moving them into usePdfRenderer.svelte.ts keeps them in a separate file, but Vite's code-splitting handles .svelte.ts modules identically to inline script. The import('pdfjs-dist') will still produce an async chunk. I'll verify with npm run build before/after and compare chunk sizes in the PR.

Static import risk:
The type imports at line 3 (import type { PDFDocumentProxy, ... }) are type-only and erased at build time. The runtime import at line 64 is dynamic. After extraction, usePdfRenderer.svelte.ts will use import type at the top and dynamic import() at runtime -- same pattern. No risk of pulling 400KB into the main bundle.

HMR boundary:
Correct that .svelte.ts modules have different HMR boundaries. Worth noting in the PR description. Not a blocker.

Source maps:
Agreed -- three focused files produce better stack traces than one 469-line file. No action needed.

No deployment changes:
Confirmed. Pure frontend refactor, same Docker image, same build pipeline.


Summary of Decisions

Topic Decision
Scroll-sync Stays in orchestrator, renderer exposes goToPage(n)
pdfDoc exposure Replace with isLoaded: boolean in return type
Props count (PdfControls) Keep 10 flat props (KISS)
AnnotationLayer Already separate, no change needed
Annotation toggle Stays in PdfControls (already visually separated)
Keyboard nav Does not exist, not in scope
Blob URL leak Pre-existing, separate ticket
Worker URL Local file via Vite ?url, stays in renderer module
Tests to add with this ticket 5-6 unit tests for usePdfRenderer, 2-3 for PdfControls
Acceptance criteria additions "E2E documents.spec.ts passes unchanged" + "visual parity at 320/768/1440px"
## Comprehensive Response to All Persona Reviews I read the full source of `PdfViewer.svelte` (469 lines), `AnnotationLayer.svelte` (176 lines), `DocumentViewer.svelte` (107 lines), the existing test file `PdfViewer.svelte.spec.ts` (53 lines), and the blob URL creation sites. Here are concrete answers to every concern. --- ### Felix Brandt -- Senior Fullstack Developer **usePdfRenderer API surface -- refs as params vs internal query:** The module should accept `canvasEl` and `textLayerEl` as parameters. Currently these are `$state` bindings at lines 40-41. Passing them in keeps the module testable without a DOM -- correct call. **Worker URL ownership:** Lines 62-71 show the dynamic import and `GlobalWorkerOptions.workerSrc` setup happening in `onMount`. This should move entirely into `usePdfRenderer` -- the module owns the full PDF.js lifecycle. The caller never needs to know about the worker. **Prerender -- offscreen canvases:** `prerender()` at lines 161-170 does NOT render to any canvas. It only calls `doc.getPage(n)` to warm PDF.js's internal page cache. No offscreen canvas refs needed. The module contract is simple: `prerender` is a fire-and-forget side effect with no output. **PdfControls -- 10 props:** Confirmed: `currentPage`, `totalPages`, `scale`, `showAnnotations`, `annotationCount`, `onPrev`, `onNext`, `onZoomIn`, `onZoomOut`, `onToggleAnnotations` = 10 props. Grouping into `pdfState` + `controls` objects is cleaner but adds indirection for a pure presentational component. I'd go with KISS here -- 10 flat props for a leaf component is tolerable. The annotation toggle is visually separated already (line 392: it's in its own conditional block, rendered only when `annotations.length > 0`). Extracting a separate `AnnotationToggle` component for ~35 lines of SVG button feels like over-splitting. **Scroll-sync placement:** Strong agree. The scroll-sync effect (lines 222-246) depends on `annotations` and `activeAnnotationId`, which are annotation concerns, not renderer concerns. The module should expose a `goToPage(n: number)` method, and the orchestrator calls it when `activeAnnotationId` changes. This keeps annotation knowledge out of the renderer. The `requestAnimationFrame` scroll-into-view logic (lines 240-245) also stays in the orchestrator since it queries `document.querySelector` for annotation DOM elements. --- ### Markus Keller -- Senior Application Architect **Module inputs:** The module needs: (1) PDF file URL, (2) canvas ref, (3) textLayer ref. Scale is internal state. Container width is not used -- scale is a fixed multiplier (line 35: `scale = 1.5`), not computed from container dimensions. Input count is 3, well within the guideline. **State pass-through risk:** The orchestrator passes ~5-6 read-only values from the renderer to PdfControls. This is acceptable. If it grows, we can spread the renderer's returned state object directly as Felix suggested. **AnnotationLayer ownership:** AnnotationLayer is ALREADY a separate component (`frontend/src/lib/components/AnnotationLayer.svelte`, 176 lines). It is imported at PdfViewer line 4 and composed at lines 453-461. No extraction needed -- the orchestrator just keeps composing it as-is. **Keyboard navigation:** There is NO keyboard navigation in the current PdfViewer. No `keydown`/`keypress` handlers exist anywhere in the component. The only keyboard handling is in `AnnotationLayer.svelte` line 115-117 (Enter/Space on annotation buttons for a11y). So there is nothing to preserve or accidentally break. This does NOT need to be added as part of this refactor -- it's a separate feature if desired. --- ### Sara Holt -- Senior QA Engineer **Existing tests:** `PdfViewer.svelte.spec.ts` exists with 3 tests: 1. Navigation buttons render (prev/next) 2. Zoom controls render (zoom in/out) 3. Page counter displays after load ("1 / 2") These are browser-rendered component tests using `vitest-browser-svelte` with a mocked `pdfjs-dist`. They cover button presence but NOT: disabled states, callback invocation, page bounds clamping, zoom bounds, or error states. **Page bounds clamping:** Currently implemented in the handlers themselves: - `prevPage()` line 248-250: `if (currentPage > 1)` -- guard is in the handler, not just the template - `nextPage()` line 252-254: `if (pdfDoc && currentPage < pdfDoc.numPages)` -- guard is in the handler - `zoomOut()` line 260: `if (scale > 0.5)` -- guard is in the handler These guards will transfer cleanly into `usePdfRenderer`. The template also has `disabled` attributes (lines 312, 334) which are redundant UI hints -- correct pattern. **DPR handling:** Line 113: `const dpr = window.devicePixelRatio || 1`. Canvas is sized at `viewport.width` (which includes `scale * dpr`) and CSS-sized at `viewport.width / dpr`. A unit test mocking `window.devicePixelRatio = 2` and asserting canvas dimensions would be valuable. Adding this to the `usePdfRenderer` test suite is feasible. **Prerender side effects:** `prerender()` is called at line 207, only inside the `.then()` of a successful `renderPage`. It won't fire during initial load (before `pdfDoc` is set) or on unmount. After extraction, this call sequence stays the same. **Recommended test additions for this ticket (not follow-up):** - `usePdfRenderer`: prevPage clamps at 1, nextPage clamps at totalPages, zoomOut clamps at 0.5, loadDocument sets error state on rejection, loading transitions - `PdfControls`: prev disabled on page 1, next disabled on last page, callback invocation **Acceptance criteria addition -- agreed:** "Existing E2E tests in `documents.spec.ts` (59 PDF-related assertions) pass without modification" should be added. --- ### Nora "NullX" Steiner -- Application Security Engineer **Dynamic import path:** Lines 64-66 use static string literals only: ```js import('pdfjs-dist') import('pdfjs-dist/build/pdf.worker.min.mjs?url') ``` No user input anywhere near these import paths. Safe after extraction -- the strings move as-is into `usePdfRenderer`. **innerHTML / XSS surface:** One `innerHTML` assignment exists at line 144: `textDiv.innerHTML = ''`. This is a clearing operation (empty string), not injecting content. All text layer content is rendered by PDF.js's `TextLayer` class using `document.createElement('span')` internally. No custom innerHTML with PDF-derived content anywhere. Safe. **Worker URL:** Line 66-68: The worker URL comes from a Vite `?url` import of a local file (`pdfjs-dist/build/pdf.worker.min.mjs`). This resolves to the app's own origin at build time -- no CDN, no external URL. Vite handles the asset hashing. After extraction into `usePdfRenderer`, this stays identical. **Blob URL handling:** Blob URLs are created in the page routes, NOT in PdfViewer: - `frontend/src/routes/documents/[id]/+page.svelte` line 40: `fileUrl = URL.createObjectURL(blob)` - `frontend/src/routes/enrich/[id]/+page.svelte` line 42: same Neither page calls `URL.revokeObjectURL()`. This is a pre-existing memory leak, unrelated to this refactor. Worth a separate bug ticket but out of scope here. PdfViewer receives the URL as a prop string -- it never creates or manages blob URLs. **Return type narrowness:** Agreed. `usePdfRenderer` should return primitive state (`currentPage`, `totalPages`, `scale`, `loading`, `error`) and methods (`prevPage`, `nextPage`, `zoomIn`, `zoomOut`, `goToPage`, `loadDocument`). The raw `pdfDoc` object should NOT be exposed. Currently `pdfDoc` is only read externally for the `disabled` check at line 334 (`disabled={!pdfDoc || ...}`), which can be replaced with a boolean `isLoaded` in the return type. --- ### Leonie Voss -- UI/UX Design Lead **Parent CSS / descendant selectors:** No CSS files contain `.pdf-viewer`, `.textLayer`, or `.pdf-page` selectors. All styling is inline Tailwind utilities on the elements themselves. The only class-based styling is `textLayer` (line 449) and `pdf-page` (line 442), and no global CSS targets these. PdfControls extraction carries zero risk of breaking inherited styles. **Control bar positioning:** The control bar (lines 305-429) uses `shrink-0` inside a `flex flex-col` parent. It's flow-positioned (not sticky/fixed) at the top of the flex column. The orchestrator must preserve the `flex flex-col` wrapper, and PdfControls is just a flex child. Simple. **Annotation toggle visual separation:** The annotation toggle (lines 392-428) is already visually separated -- it's in its own flex group at the right side of the `justify-between` control bar. Page nav is left, zoom is center, annotation toggle is right. The existing layout achieves the cognitive separation Leonie wants without a divider element. **Responsive behavior:** There are NO breakpoint-specific styles in the PdfViewer control bar. No `sm:`, `md:`, `lg:` classes anywhere in lines 305-429. The controls use flex-wrap-free layout with small buttons (`p-1` = 4px padding) and small text (`text-xs`). At 320px, the control bar is tight but doesn't overflow (3 groups in a `justify-between` flex row). Touch targets: the nav/zoom buttons are `p-1` with a 16x16 SVG, giving ~24x24px hit area -- below the 44x44 recommendation. This is a pre-existing issue, not introduced by this refactor. **Loading/error state flow:** `loading` and `error` states (lines 433-438, 266-279) are currently used to conditionally render the canvas area and show error messages. After extraction, these flow from `usePdfRenderer` through the orchestrator. The control bar does NOT currently react to loading/error -- it renders regardless. If we want disabled buttons during loading, that's a new feature, not a regression. **Pixel-identical verification:** Agreed as a lightweight check. The proofshot skill can capture before/after at key viewports. Adding this to acceptance criteria is reasonable. --- ### Tobias Wendt -- DevOps & Platform Engineer **Bundle size / dynamic import preservation:** The dynamic import at lines 64-66 uses static string literals. Moving them into `usePdfRenderer.svelte.ts` keeps them in a separate file, but Vite's code-splitting handles `.svelte.ts` modules identically to inline script. The `import('pdfjs-dist')` will still produce an async chunk. I'll verify with `npm run build` before/after and compare chunk sizes in the PR. **Static import risk:** The type imports at line 3 (`import type { PDFDocumentProxy, ... }`) are type-only and erased at build time. The runtime import at line 64 is dynamic. After extraction, `usePdfRenderer.svelte.ts` will use `import type` at the top and dynamic `import()` at runtime -- same pattern. No risk of pulling 400KB into the main bundle. **HMR boundary:** Correct that `.svelte.ts` modules have different HMR boundaries. Worth noting in the PR description. Not a blocker. **Source maps:** Agreed -- three focused files produce better stack traces than one 469-line file. No action needed. **No deployment changes:** Confirmed. Pure frontend refactor, same Docker image, same build pipeline. --- ### Summary of Decisions | Topic | Decision | |---|---| | Scroll-sync | Stays in orchestrator, renderer exposes `goToPage(n)` | | `pdfDoc` exposure | Replace with `isLoaded: boolean` in return type | | Props count (PdfControls) | Keep 10 flat props (KISS) | | AnnotationLayer | Already separate, no change needed | | Annotation toggle | Stays in PdfControls (already visually separated) | | Keyboard nav | Does not exist, not in scope | | Blob URL leak | Pre-existing, separate ticket | | Worker URL | Local file via Vite `?url`, stays in renderer module | | Tests to add with this ticket | 5-6 unit tests for `usePdfRenderer`, 2-3 for PdfControls | | Acceptance criteria additions | "E2E documents.spec.ts passes unchanged" + "visual parity at 320/768/1440px" |
Sign in to join this conversation.
No Label refactor
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#196