Files
pad/web/package.json
xarmian 875bbaa24e feat(attachments): sandboxed in-pane PDF viewer with page navigation (TASK-4075) (#2333)
* build(web): pin pdfjs-dist 6.4.299 for the in-pane PDF viewer (TASK-4075)

Exact pin, no overrides. The lockfile gains @napi-rs/canvas and its 11
platform packages: pdfjs-dist declares it as an optional dependency for its
Node canvas, npm records the ideal tree for every platform, and nothing in a
browser build reaches it.

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* feat(web): 'pdf' surface renderer, exact MIME predicate, 25 MiB cap and bytes loader (TASK-4075)

canPreviewAsPdf admits exactly application/pdf. PDF_PREVIEW_MAX_BYTES is its
own 25 MiB constant with the measured receipt. createViewerBytesLoader is the
text loader's sibling: same phases, same two size gates, same staleness
fence, but it delivers bytes. Corrects the display.ts comment that called the
text set 'deliberately outside' the server mirror, which text/plain never was.

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* feat(web): AttachmentPdfPreview, a canvas-only pdf.js page viewer (TASK-4075)

pdf.js is imported on mount (its own lazy chunk) with useWasm:false, no url,
no scripting and a copy of the bytes (getDocument detaches its buffer). The
worker is our own Vite asset. A Vite plugin copies only the two no-wasm
JPX/JBIG2 fallbacks to a fixed path. DPR is capped at 2 and each page at 16 MP.
No text layer, no annotation layer, no PDF-derived markup.

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* feat(web): the Lightbox PDF arm (TASK-4075)

A third byte-loader arm beside text/table: bytes loader armed, the other two
disposed; loadKey carries the resolved size; the stage gives up its
touch-action claim; the wheel exclusion and arrow guard cover the PDF
scroller; PageUp/PageDown turn pages while Left/Right stay gallery paging.
Too-large and error reuse the text arm's copy and Retry.

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* fix(web): leave the desktop toolbar room above the PDF arm (TASK-4075)

Found in the browser: the toolbar floats over the stage's top centre and
intercepted clicks on the PDF's own page controls.

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* test(web): PDF viewer browser specs, emitted-bundle scan, fixtures (TASK-4075)

E1-E5 against the built binary, with the CSP instrument placed in the pdf.js
worker's own scope and a control leg that forces wasm back on. U8 scans the
emitted chunks for eval/Function call sites and @napi-rs. The two real JPX and
JBIG2 fixtures come from pdf.js's test corpus (Apache-2.0), origin beside them.
The fallback-arm specs that used a PDF as their no-renderer file now use a ZIP,
as the fixture's own note said they would.

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* test(web): U3 per-arm loader disposal and the late-size re-decision (TASK-4075)

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* test(web): settle the PDF e2e legs against the built binary (TASK-4075)

A PDF tile emits a single-entry set, so the gallery legs became inert-arrow and
no-sideways-movement legs (gallery paging stays a unit test). The over-cap leg
reports the size through the attachment list, since the 25 MiB upload cap equals
the 25 MiB preview cap. Dialog and popup instruments get control legs.

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* test(web): pin the loader's id fence and silent AbortError (TASK-4075)

Two mutants survived the first run: dropping the id half of the staleness fence
(only a completion after dispose() distinguishes it from the token half) and
dropping the AbortError check (only an abort the loader did not issue reaches it).

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* test(web): scope the E2 request check to other origins (TASK-4075)

The first gate run caught my own over-broad assertion: with the whole attachment
suite running in parallel, the app's background items-changes reads landed inside
the click window. What the leg pins is that nothing leaves the server's origin.

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* fix(web): bound the PDF viewer's reads, images and canvas sides (TASK-4075 r1)

F1: one shared bounded byte reader (boundedBytes.ts) behind the PDF bytes
loader and the text loader: Content-Length precheck, no whole-buffer
fallback (a non-stream body is the error phase), residual one-chunk
overshoot disclosed. F2: pdf.js maxImageSize 64 MP. F3: pageRenderSize
returns null for non-finite or non-positive input. H1: PDF_MAX_SIDE 16384.

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* docs(web): maxImageSize bounds every PDF image except JPX (TASK-4075 r2, H2)

Claim correction only, no runtime change. pdf.js checks the dictionary W/H
(pdf.worker.mjs:34283-34289); for JPXDecode the size is then re-read from the
codestream (:33055-33061, :33084). pdf.js requests a reduced decode sized to
about 2^30 bytes of output; the decoder's peak allocation is unmeasured. The
real bound is TASK-4106.

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* test(web): make the U8 @napi-rs scan cheap and give it an explicit timeout (TASK-4075 r2, S1)

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* test(e2e): an embedded TrueType font draws under the real CSP, with a stripped-program control (TASK-4075 r2)

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* test(web): record the measured U8 scan duration (TASK-4075 r2, S1)

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* test(attachments): E6 asserts the font-load-failure warning absent, with a rejecting-FontFace control; widen U8 scan (TASK-4075 r3)

H3: collect console warnings for E6 only (opt-in), assert no 'Warning: Failed to load font',
add a permanent control leg injecting a rejecting FontFace. Test name and comment claim only
what is proven; font-src vs ArrayBuffer FontFace is unmeasured.
H4: record the empirical >10% ink threshold (4375 vs 5204, 19%).
H5: U8 also flags eval?.(, Function?.( and tagged-template Function; tripwire wording, CSP named as the wall.

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* test(e2e): E6 blank-stripped page must paint, claim only no font-load failure, control reads at the same point (TASK-4075)

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* fix(web): page-number box takes 0/-/+/=; clear stale canvas on bad page size; test pins (TASK-4075 r5)

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* test(web): L2 pin observes the canvas box reset (TASK-4075 r5)

Claude-Session: https://claude.ai/code/session_01X2LCH8E34w8ma8v9Lk24Bp

* fix(web): pin the open-effect renderFailed reset and drop the redundant phase guard (TASK-4075 r5 survivors)

This applies the lead's day-40 ruling on the two fix5 mutant survivors. The analysis is on the TASK-4075 trail.

(a) l2-renderFailed-reset-removed: a reachable window exists, so the line stays and is pinned. A new document whose scroll region measures 0 wide never reaches the render effect's own reset. Without the open-effect reset, the old document's "couldn't be drawn" alert stays over the new document. The new unit test fails with that line removed (AssertionError on the stale alert).

(b) rekey-phase-guard-only-removed: no reachable state, so the clause is removed. The preview mounts only in the ready branch, and svelte's bind:this nulls pdfPreview in the same flush that unmounts it. There are no outro transitions, so a keydown can never see a non-null preview with the phase off ready. The two re-key tests stay as the pins on the null guard.

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL

* fix(web): keep an over-cap read "too large" when the stream rejects the cancel, and make E2's link clicks land (TASK-4075 r6)

This applies the lead's day-40 r6 rulings. The triage and the rulings are on the TASK-4075 trail.

r6a2-L1: once the streaming count passed the cap, readBoundedBytes awaited reader.cancel() with no guard. An errored stream rejects that cancel, so the read threw and the PDF viewer showed the generic "couldn't be loaded" instead of "too large". The bytes were still refused either way. The cancel is now best effort, the same shape as the Content-Length precheck's body.cancel(). That makes it 3 cancel sites in the change: two stream cancels, both now guarded, and pdf.js's synchronous RenderTask.cancel, which has no rejection path. There are two new tests: one at the reader, and one at the loader with a real ReadableStream whose cancel rejects. Both fail with the guard removed (the loader reports 'error', not 'too-large').

r6a-L2: E2 clicked at 0.87 of the canvas height. The JavaScript link spans 0.747-0.874, and nothing checked that the point was in view. The clicks now go to each link rect's centre, scrolled to the middle of the scroll region, and the test first asserts that elementFromPoint at that point is the canvas. These clicks prove no more than the source pins do: the viewer has no annotation layer.

r6a-L3: fix the indentation of the zoom test's closing brace.

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL

* test(web): say exactly what the rejected-cancel stream demonstrates (TASK-4075 r7-codex-L1)

Comment only. The test's stream has an underlying cancel that rejects; it is not an errored stream. An errored fetch body is one way a cancel rejects, which the comment now says instead of claiming the setup reproduces it.

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL

* docs(attachments): state boundedBytes' real peak memory (TASK-4075 r8)

Comment only. The header said peak memory is "at most max plus one chunk";
that holds only on the refusal path. An accepted read holds the chunks and
the joined copy at once (about 2 x max), and the PDF caller's .slice()
handoff adds a third copy (about 3 x max) until the chunks are collected.
Lead-ruled fix-now on the r8 triage (H-L3), under the comment-only protocol.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS
2026-10-28 09:04:35 -04:00

86 lines
2.5 KiB
JSON

{
"name": "web",
"private": true,
"version": "0.0.1",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build && node scripts/build-materializer.mjs",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install --with-deps chromium",
"check:tiptap-pins": "node scripts/check-tiptap-pins.mjs",
"audit:ci": "node scripts/ci-audit.mjs"
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@playwright/test": "^1.64.0",
"@sveltejs/adapter-static": "^4.0.0",
"@sveltejs/kit": "^3.0.1",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/svelte": "^5.2.8",
"@types/d3-scale": "4.0.9",
"@types/qrcode": "^1.5.6",
"esbuild": "0.28.2",
"fake-indexeddb": "6.2.5",
"jsdom": "^30.1.1",
"linkedom": "0.18.13",
"marked": "^18.1.1",
"svelte": "^5.57.2",
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^8.3.4",
"vitest": "5.0.4"
},
"overrides": {
"cookie": "^2.0.1",
"linkify-it": "^5.0.2",
"lodash-es": "^4.18.1",
"markdown-it": "^14.3.3",
"layercake": {
"typescript": "$typescript"
}
},
"dependencies": {
"@dagrejs/dagre": "3.1.1",
"@tiptap/core": "3.31.4",
"@tiptap/extension-bubble-menu": "^3.31.4",
"@tiptap/extension-code-block-lowlight": "^3.31.4",
"@tiptap/extension-collaboration": "3.31.4",
"@tiptap/extension-collaboration-caret": "3.31.4",
"@tiptap/extension-link": "^3.22.5",
"@tiptap/extension-placeholder": "^3.31.4",
"@tiptap/extension-table": "^3.31.4",
"@tiptap/extension-task-item": "^3.31.4",
"@tiptap/extension-task-list": "^3.31.4",
"@tiptap/pm": "3.31.4",
"@tiptap/starter-kit": "^3.31.4",
"@tiptap/suggestion": "^3.31.4",
"@tiptap/y-tiptap": "3.0.9",
"3d-force-graph": "1.80.1",
"d3-scale": "4.0.2",
"diff": "^9.0.0",
"dompurify": "^3.4.17",
"idb": "^8.0.4",
"layercake": "11.0.0",
"lowlight": "^3.3.0",
"mermaid": "^12.1.0",
"minisearch": "7.2.0",
"pdfjs-dist": "6.4.299",
"qrcode": "^1.5.4",
"svelte-dnd-action": "^0.9.80",
"three-spritetext": "1.10.0",
"tiptap-markdown": "^0.9.0",
"y-protocols": "^1.0.7",
"yjs": "^13.6.33"
}
}