Changelog
Release history
Changelog
All notable changes to frogConvert. Loosely follows Keep a Changelog and Semantic Versioning.
[3.0.0] - 2026-08-29
Compression becomes a first-class feature. It was previously invisible - every conversion quietly applied a medium preset, and the only user-facing compression was a same-format easter egg in the Convert card. There is now a dedicated Compress mode, PDFs can actually be compressed, and the setting that was always being applied is now something you can see and change.
Adding a real PDF engine paid for two things beyond compression: PostScript, EPS and Illustrator conversion - formats the app had no support for at all, from the one engine that handles them properly - and the ability to shrink what the PDF Editor saves. The PDF Editor's long edits also became cancellable, closing the last place in the app where an operation could trap you on a spinner.
Added
- Every document, format and conversion has an indexable URL. src/seo/ and a
seo-pagesvite plugin emit 118 prerendered pages at build time: one per document at/docs/<slug>/, 45 format hubs at/formats/<ext>/, 59 conversion pages at/convert/<from>-to-<to>/, and an index at/formats/. All 13 documents previously shared one URL,/docs/, whose indexable body was the wordLoading, because the docs app fetches markdown at runtime and picks the document fromlocation.hash./docs/architecture/now serves 3,157 words of prose with no JavaScript executed. Pages are gated on the live registry in public/cache.json, so a pair whose route has gone fails the build rather than shipping a page promising a conversion that no longer works.sitemap.xmlis generated with reallastmodand covers 121 URLs, where the committed one listed four. - The documentation diagrams are rendered to SVG at build time. src/seo/mermaid-svg.ts. The prerendered pages carry no executable script by design, so
markedleft ```mermaid fences as source and/docs/architecture/shipped 448 words ofstyle U fill:#6ee7b7,stroke:#059669as if it were prose. Rendering needs real text measurement, which jsdom cannot do, so it drives the puppeteer already in the dependency tree. If puppeteer cannot start the fence is left alone rather than failing the build. - Compress mode, a third app surface alongside the Converter and PDF Editor, at
/compress. Same format in, same format out, for images, animated images, audio, video and PDFs. src/components/CompressWorkspace/. Reuses the Convert card's dropzone, file-management and button styles rather than inventing a parallel visual language. - PDF compression via Ghostscript-WASM. src/handlers/ghostscript.ts. The existing canvas + pdf-lib route cannot do this job: it rasterises pages, so on a vector or text PDF it saves nothing (measured 0%) and on a scan it only "wins" by destroying the text layer. Ghostscript's
pdfwritedevice resamples embedded images and rebuilds object streams while leaving text as text. Measured on a vector-only PDF: 51.8 KB → 33.1 KB (−36%). The ~16 MB binary is fetched on first PDF compression only, never at page load, with download progress; it is deliberately excluded from the service-worker precache. - Mixed-batch orchestrator. src/core/compression/compressBatch.ts groups a batch by format so each engine initialises once, preserves input order, and applies a 98% keep-threshold - a re-encode that saves less than 2% is discarded and the original kept, so nothing is degraded for a rounding error.
docs/COMPRESS.md, including an explicit section on why a text-heavy PDF reports "no gain" and why that is correct rather than broken.- PDF compression on MCP, REST and CLI. src/handlers/ghostscript.node.ts.
convert_file/POST /convertwith matchingpdfin and out now compresses through the same engine and the same level mapping as the browser. - Canvas fallback for PDFs, used only when the Ghostscript payload cannot be fetched at all (offline, blocked). It rasterises pages, which destroys the text layer, so it always says so rather than reporting a silent saving. A fallback result that fails the keep-threshold is discarded without a warning, since no damage reached the user.
- Compressed downloads are self-describing. A shrunk file downloads as
photo-compressed.png; saved next to its source under the original name it became "photo (1).png", and nothing said which of the two was the small one. Files handed back untouched keep their original names, because labelling original bytes "-compressed" would be a lie. Batches zip ascompressed-<timestamp>.zip. - Sharing PDFs into the app now offers Compress alongside Edit and Convert. A shared scan is at least as likely headed for compression as for either of the others; previously the surface was unreachable from the share sheet entirely.
docs/ADDING_A_MODE.md- the playbook for shipping a top-level surface, distilled from building this one, with the steps Compress itself got wrong marked as the places to look hardest.- PostScript, EPS and Illustrator conversion (#19).
PS → PDF,EPS → PDFandAI → PDF, plusPDF → PSandPDF → EPS, through the same Ghostscript engine this release adds for PDF compression. Vector content stays vector: verified that aPS → PDFround trip keeps its pages, keeps its fonts, and emits no image XObject. Once a PostScript file is a PDF, everything else the app does with PDFs (PNG/JPEG, text extraction, the PDF Editor, Compress) reaches it for free through the existing route finder, with no new graph wiring. - PDF/A-2b and multi-page TIFF export, from the same engine. src/core/ghostscript/args.ts.
.aifiles state what they cost before you convert. A modern.aiis a PDF carrying a private Illustrator payload, so the artwork converts perfectly and the layers, editable text and effects do not survive. The Converter says so under the button rather than letting it be discovered afterwards.- The PDF Editor's long edits can be cancelled (#21). Merge, organize, watermark and extract are main-thread pdf-lib loops that previously parked you on a spinner with no way out but a reload. They now yield at checkpoints, carry a Cancel button, and honour Escape; a cancelled edit is a neutral outcome rather than an error.
- Real progress, from every engine that reports it. Compressing a 190 MB video showed an indeterminate spinner for minutes with nothing to distinguish work from a hang. FFmpeg, Ghostscript and ImageMagick all emit progress on stderr; none of it reached the UI. The modal now alternates a percentage with a line telling you the tab is yours - feel free to switch tabs, on its own line rather than flickering in place - on a 9s/3s cycle. It covers Convert, Compress and the PDF Editor, since all three drive the same engines. Where an engine genuinely cannot report (a single-shot WASM call with no callback), the surface says so instead of inventing a number.
- Image → PDF. The picker offered PDF - Portable Document Format from any image, because Ghostscript declares PDF writable, and the route search then found nothing - so a common conversion ended on "Conversion not available yet". It now goes through pdf-lib, which needs no engine and is already a dependency. Reported as "merging these pages creates a broken PDF": the merge was innocent, the inputs were pages of 1080 by 2400 inches, written by a tool that assumed 1 DPI and blew past the PDF spec's 14400-unit page limit by 12x.
- Bulk file actions in the PDF Editor. Files were the only collection in the app without them - a per-row
x, one file at a time, and no way to start over short of reloading. Merge, Organize and Watermark now carry Replace all and Clear in the same count row, with+ Addon its own row beneath the list, matching the Converter's and Compress's shared Files modal rather than inventing a third vocabulary. - The corpus suites reach REST and MCP. test/e2e/corpus-api.test.ts and test/e2e/corpus-mcp.test.ts, sharing test/helpers/corpusAgents.ts. The four other corpus suites all drive the browser; the agent surfaces had only route tests against fake handlers and synthetic bytes, so no real file had ever been through either. These put the same corpus and the same weighed-byte assertions through both, and cover what only these surfaces have: bytes read from and written to disk, base64 in both directions, the
-compressedsibling names a batch invents, and the guarantee that "I could not compress this" leaves the caller with their file rather than a zero-byte one. They also check the two surfaces against each other - both are thin wrappers overcompressForAgentsand each was previously only ever compared against itself, so one drifting to different options would have gone unnoticed. Compared by size, page count and extracted text rather than bytes, because Ghostscript stamps an XMPModifyDateand the same file compressed twice a second apart already differs. The API is spawned on an ephemeral port rather than its default 3000, since vitest runs files in parallel workers and a fixed port there fails as an unexplained connection refusal inside a child process. Opt-in behindFROG_CORPUS=1;bun run test:corpusruns all six. - Reset style, in the watermark settings. Watermark size, colour, opacity, rotation and repeat persist across sessions, so a value nudged once stays nudged for weeks, and nothing in the panel said you were off-default; the only way back was Clear, which also discards your files. A Reset style button now sits at the foot of the Customize block on both the desktop sidebar and the phone tray, and appears only while the style differs from its defaults - so it doubles as the missing off-default signal. The watermark text is untouched by it, being the one field you always retype. src/components/PdfWorkspace/PdfWorkspace.ts.
- CI runs the conversions, not just the route graph. A
verifyjob in .github/workflows/ci.yml converts one real sample per landing-page pair and checks bytes come back, with LibreOffice installed so the office pairs are actually exercised rather than skipped. The graph answering "a path exists" has been true for pairs that then threw on the real bytes; all four defects above were found this way and none of them would have failed a route-graph assertion. It runs besidebuildrather than after it, and takes about two and a half minutes.
Changed
- Line endings are settled, once. 8 of 335 tracked text files had drifted to CRLF; normalising 5 of them accounted for roughly 2,200 lines of this release's diff, and they would have flipped back the next time anyone edited them on Windows. A
.gitattributes(* text=auto eol=lf) pins it, with the remaining three renormalised in the same commit and verified content-identical. Read this release withgit diff --ignore-all-space. - The test suite fails a file that leaves a long-lived timer running. A timer outliving its test fires into an environment with no document and throws where nothing can catch it, so the run reports every test passing and still exits 1 - which happened twice while cutting this release. The check watches only timers our own code arms, judged by the frame that called
setTimeoutrather than the nearest frame belonging to us: the MCP SDK arms one insideclient.close()and puppeteer insidebrowser.disconnect(), and blaming those on our call site names a line with no timer in it. - Video and audio now compress over REST and MCP.
ffmpeg.wasmthrows on construction under Node, so those formats came backunsupportedfrom the agent surfaces - true about the process, not about the file.compress_fileandPOST /compressnow fall back to the same headless browserconvert_filehas used for years. If the bridge cannot be reached the file returns unshrunk with its original bytes, never empty. - A converter that fails to download says so. A dropped fetch of a one-time ~16 MB engine was reported as "didn't complete this time - try a different target format or another file": advice that cannot work, pointed at a file that was never the problem. Reported on a real EPS to PDF over a weak connection.
- Nothing downloads until you ask. The Converter and the PDF Editor used to fire a download moments after their success modal appeared. All three surfaces now wait for the button, and that button names what it will produce - "Download" or "Download 3 files (.zip)" - rather than the old "Download again", which claimed something had already happened.
- One file manager, not two. Compress used to render its own list of files with its own remove buttons and no way to add more. It opens the Converter's files modal now - paging, per-row replace, drop-more, remove all - through a small source adapter. The only configured difference is that Compress accepts a mixed batch on purpose and the Converter needs one format in.
- The compression levels are three real steps. High quality applied no resize at all, so on a large photo it reported nothing to compress while Balanced took 83% off - the whole ladder's step sat between the top two settings. The long-edge caps are now 3840 / 2560 / 1920 against quality 93 / 80 / 65.
- Cancelling a per-file PDF job keeps what it finished. Organize, watermark and extract build their output one document at a time; stopping used to discard the completed ones, so Cancel could only be paid for by redoing them.
- The waiting Converter button says what it is waiting for. "Loading formats" was wrong twice - the formats are already on screen and selectable - and it is now "Downloading converters", with a subtle breathing animation so a slow connection does not read as a frozen page, and an explicit offline state. Failures while offline say so instead of blaming the file.
- Compression is now a visible setting, in every mode. The Compression control sits at the bottom of the settings menu and rebinds to whichever value the active mode owns: converted-output quality in the Converter (default Original quality), compression strength in Compress (default Automatic, the same value as the card's own picker and kept in sync), and whether a saved PDF is also shrunk in the PDF Editor (default Original quality). Only Compress defaults to Automatic, because shrinking is the whole request there; a conversion was asked for a format change and an edit was asked for an edit, and below
highthe levels apply a long-edge cap, so an Automatic default would silently return a 4032x3024 photo at 2560 px. Hiding it anywhere made the setting look like it only existed where you last saw it. The three values are independent and separately persisted - "how much quality to give up while changing format", "how hard to compress" and "should editing this also shrink it" are different questions, and an earlier build that shared one value meant changing it in one place silently moved the others. - The PDF Editor can shrink what it saves. Merge, organize, watermark and extract route their finished PDF through the same Ghostscript engine, level mapping and 98% keep-threshold as the Compress surface. It defaults to Original quality because these are edits, not exports - you expect the same document back - and offers no Automatic, since "read the file and decide" is a good answer for a file handed over to be shrunk and a surprising one for a file handed over to be edited. The step never costs you your work: if it fails or wouldn't save enough, you get the uncompressed result.
- Ghostscript is fetched before it is needed. A PDF dropped on Compress, PDF chosen as a conversion target, or a PDF-Editor level set to anything but Original quality each start the ~16 MB download via
<link rel="prefetch">, so it overlaps with whatever you do next instead of landing on the critical path. Nothing is downloaded for users who never touch a PDF. - Level names use one quality-forward vocabulary: Automatic / Original quality / High quality / Balanced / Smallest file. The previous set mixed two scales - "No compression" is a quality statement, "Extreme compression" is an amount - and "Automatic / Match the source" was simply wrong, since matching the source is what the lossless option does. Compress offers the same words minus the do-nothing option: as a compression level, lossless can only mean "do nothing", because it targets quality 100 and the re-encode comes back larger.
- Multi-hop conversions no longer compound quality loss. One shared rule (src/core/compression/hopQuality.ts) applies the requested level to the final hop only, with intermediates at high quality. The browser and the MCP/REST/CLI surfaces previously disagreed in opposite directions; they now share the rule.
- The control is titled for its mode: Conversion compression, Compression level, PDF compression. One heading reading "Compression" in three places never said compression of what, and the card's own field was relabelled from "Compress by" (whose value, "Automatic", never completed the sentence) to "Compression level".
- The Compress card is the Convert card, measured rather than approximated: same width, margin, padding, radius, border and surface on both breakpoints, verified against
#convert-cardin Chromium at 1440x900 and 390x844. Category pills above it (Any / Image / Audio / Video / PDF) state what the surface accepts and open the file picker pre-filtered to the tapped family. The page asks "What will you compress today?". - A same-format pick in the Converter now signposts Compress. Picking png to png converts nothing; when a compressor exists for the format, the hint says "Want it smaller?" and one click switches mode, instead of ending at "you'll get your file back unchanged".
- Copy overhaul. "Squish" is gone from every user-facing string, no user-facing string contains an em dash, and each level blurb earns its space (none opens by repeating its own label; punctuation is consistent within a menu).
- One verb for stopping. Stopped, Cancelled and Canceled all appeared, sometimes stacked three deep in one modal, and one of them announced itself before anything had actually stopped. Every surface now says stopped, once, after it has.
- Plainer verbs in progress copy. "Encoded 12.4s of 20.0s of video" told you the internals; it now says converted or compressed according to what you asked for, and file rather than media.
- The PDF Editor timestamps what it saves.
merged.pdfcollided with the lastmerged.pdfin the download folder, so the browser silently producedmerged (1).pdfand nothing said which was which. Output now carries the same timestamp the Converter and Compress already used. - Pages sits above the watermark settings. Watermark was the one tab where scope came last, so on a phone the range input and its Select all / Deselect all were pushed below five controls - off-screen exactly when you are choosing which pages to mark. All three tabs now read files, then scope, then the tool's own settings.
- The file manager says what it does, and the two surfaces agree. Remove all is now Clear in the shared Files modal. The refresh button beside it is Replace all files on both the Converter and Compress: it was labelled "Replace file" on one while discarding the whole queue, and it added files on the other, so the same glyph in the same position did opposite things. Three files in and one picked left you with one on the Converter and four on Compress. Both replace now; adding keeps its own labelled home in the Files modal's Drop more files zone.
- Only one top-bar dropdown is open at a time.
- The Compress background no longer promises speed. Its emoji set carried a balloon (inflation, the opposite of the feature) and a lightning bolt, on the one surface where speed is the weakest claim in the app: a 16 MB Ghostscript fetch on first use and a ten-minute worker ceiling on video. The set is now nine things that all mean smaller. "Fast" also came out of the JSON-LD product description, the JSON-LD feature list and the docs site's meta description; privacy is the claim this app can actually defend, and it was already making it.
- The source offer lives on the docs page, not in the app footer. The footer carried a source link beside view docs, added as the mechanism satisfying the licence's source-offer requirement for the hosted app - Compress ships Ghostscript, which is AGPLv3, and serving that payload to a browser is conveying it. The offer is unchanged in substance and better in kind: the docs page links the repository and the exact commit the running build came from, which is the revision a source offer is actually about, where the footer only ever linked the repository's tip. README and COPYRIGHT previously asserted the footer link by name and would have been false the moment it went; both now describe where the offer actually is. The footer's remaining anchor also stopped calling itself
#commit-id, a name inherited from the docs page's real commit link (#commit-link) and attached here to view docs, which is not a commit and never was. - The progress modal says each thing once. Four muted lines stacked on a 390px phone, and three of them were partly restating each other: the live line read
Rasterising page 74 of 118 · 63% · 00:14, where the page count, the percentage derived from that same count and the wall clock are three notations for one fact, and the line under it opened with in progress beneath a heading already saying Compressing file 2 of 3. Aratiois now rendered only when the event carries nodetailto say it better, so the engine's own words stand alone; the elapsed clock moved to the end of the reassurance line, which is the only other line about the waiting rather than about the work, in its ownaria-hiddenspan so the sentence stays announced while the ticking half does not. src/conversion/progressStatus.ts. - One threshold decides when the clock appears, at twenty seconds. The modal held it back for ten while the PDF editor started counting from
00:00, because each surface decided for itself.ELAPSED_AFTER_MSlives inelapsedSuffix()now, so the two cannot disagree and a third surface cannot get it wrong; theshowElapsedlatch the tick loop carried to track this went with it. - The slide deck is served rather than sitting on disk.
docs/slidedeck.htmlis a complete deck, current through 3.0 - slide 12 covers Compress and Ghostscript - and nothing could reach it: it was not a vite input, so it never entereddist/, and the docs-site scanner only picks up.mdfiles carrying adocs-frontmatterblock, so it was excluded by construction rather than by a missing nav entry. Declared as a build input and linked from the docs top bar. An unbuilt file is also an unreviewed one, which is the drift risk that made this worth fixing rather than deleting. vite.config.js, docs/index.html.
Removed
bun run docs:verify, which verified nothing. scripts/verify-docs.ts compared root-level*.mdagainst same-named files indocs/; the doc set was restructured since it was written and no such pair exists any more, so the loop never executed and the script exited 0 having compared zero files. It was not dormant -.husky/pre-commitconsisted of exactly that one line, so it ran on every commit, checked nothing and passed. CONTRIBUTING had already documented the vacuous pass ("it passes trivially when no such pair exists"), which is the point at which a check has stopped earning its place. The script, thepackage.jsonentry, the hook and the two docs pointing at it are gone; husky stays installed for future hooks.
Fixed
The Docker image served the app document with no security headers. nginx's
add_headerdoes not merge across configuration levels: a location declaring anyadd_headerof its own inherits none from the server block. Seven locations in docker/nginx/default.conf carry their ownCache-Control, so seven were answering with no Content-Security-Policy, noX-Content-Type-Options, noX-Frame-Optionsand noReferrer-Policy- among themlocation = /index.html, whichlocation /'stry_fileslands on for every SPA navigation, so the app document itself was uncovered in every self-hosted deployment. Theframe-ancestors 'none'andobject-src 'none'the file spends a paragraph justifying were never applied there. The headers now live in docker/nginx/security-headers.conf, included at server level for the locations that inherit normally and again in each location that adds a header of its own. nginx itself could not be run to verify this, so the rule is pinned by a test over the config: it fails if a location adds a header without re-including the snippet, and separately checks the Dockerfile'sCOPYdestination matches the path theincludenames, since a mismatch there stops nginx booting rather than merely dropping a header.A build stamp that is not a commit SHA is no longer linked to a 404.
VITE_COMMIT_SHAis passed through verbatim unless it is a full 40-character SHA, so a self-hoster building withVITE_COMMIT_SHA=local, or a CI passing a tag, reached the docs footer as a link to/commit/local. Linking is now gated on the value's shape rather than on the single literaldev. Not a 7-or-40 length test, becausegit rev-parse --shortlengthens the abbreviation as a repository grows and real 8- and 10-character stamps exist; hex plus at least onea-fseparates a SHA from an all-digit stamp like20250828, at the cost of a genuinely all-numeric short SHA (about 4% of them) rendering as plain text. An occasionally missing link is the harmless direction to fail in; an occasionally broken one is not.The docs surface no longer costs every visitor 1.58 MB. src/docs/docs.ts statically imported
highlight.jsand, through the renderer,mermaid, which put both in the docs entry chunk - and that chunk is precached, so the weight landed on everyone at install time whether or not they ever opened the docs, and whether or not the document they opened had a diagram in it. Both are dynamic now, with mermaid fetched only once a document is known to contain a fence:docs-*.jsgoes from 1,580 KB to 70 KB, and the precache from 4,334 KiB to 2,838 KiB. Against 2,299 KiB before this release, the net cost of precaching the app shell is now about 540 KiB, which is the shell. The refactor also had to move rendering after the document is revealed - mermaid lays a diagram out from measured text, and an element stilldisplay:nonemeasures as nothing, so every<g>came out astransform="translate(undefined, NaN)". The old code got away with running before the reveal becausemermaid.run()was fire-and-forget and happened to resolve after it; awaiting it made that accident load-bearing. Enhancement now lives in src/docs/enhance.ts, revealed-then-enhanced, guarded against a second navigation overtaking it, and with renders serialised so a theme toggle mid-render cannot paint the old theme over the new one.A returning user gets a working app after a deploy. The service worker precached the HTML and none of the JavaScript it names. Measured on a real build: 258 chunks emitted to
dist/assets/, 154 precache entries, and not one of them a.jsfile -globPatternsin vite.config.js listedhtml,css, icons and fonts, overriding workbox-build's default of**/*.{js,wasm,css,html}, which includes JavaScript for exactly this reason. Soindex.htmlwas pinned in a permanent, versioned precache while the 258 hashed chunks it referenced lived in a runtime cache capped at 200 entries with a 30-day TTL. Three independent ways for the two halves to disagree, all of them structural: 258 chunks cannot fit in 200 slots; a user returning after a month found every entry expired; and a chunk for a route never visited was never cached at all. The stale HTML then requested a hash the deploy had deleted, the SPA fallback in netlify.toml answered/*with a 200 carryingindex.html, and Workbox's default cacheability check looks only at the status - so HTML was written into the runtime cache under a.jsURL, permanently, which is why only clearing site data helped. The page rendered fully, the 15s splash timeout dismissed the spinner, and nothing was bound to anything. The update prompt could not fire either: it is registered byregisterPWA()inside the bundle that had just failed to load. Entry chunks, their static-import closure, the two workers they name, the service-worker registration module and the fonts the precached CSS names are now precached alongside the HTML - 14 chunks and 7 font subsets, with the other 247 chunks left to the runtime cache so a cold install still does not pull 17 MB - and a build-time assertion fails the build if any script named by a precached HTML file, any worker named by a precached chunk, or any font named by a precached stylesheet is not itself precached./assets/,/js/and/wasm/now 404 rather than falling through to the SPA rule, in both netlify.toml and docker/nginx/default.conf; every runtime cache and the precache itself refuse an HTML body under a URL that does not name a document (src/pwa/cachePolicy.ts) - the URL half of that test is load-bearing, because the guard also covers the precache and ~125 of its ~175 entries are HTML documents, so a content-type-only version of it madeprecacheAndRoutereject its install promise withbad-precaching-response, sending the worker frominstallingstraight toredundantand leaving every returning user on the worker they already had; hashed assets areCacheFirstwith no TTL, since a content-hashed URL's bytes cannot change and a TTL on one is a scheduled outage; and two recovery paths purge caches and reload once, guarded against loops - src/pwa/staleShell.ts on Vite'svite:preloadErrorfor lazy chunks, and a dependency-free inline handler in index.html for the case where no module loaded at all.Every service-worker cache is guarded, including
wasm-v1. It is the one cache deliberately spared byRETIRED_CACHESand by both recovery paths - ~17 MB of engines behind content-stable URLs that no deploy invalidates - and it isCacheFirst, so an HTML body stored there would have been the most durable poisoning of the lot: nothing revalidates it and nothing clears it.The service worker is now covered by a test that drives a real one. test/e2e/stale-shell-recovery.test.ts builds twice, serves both deploys and drives Chromium across them, asserting the worker actually takes control. It stays out of the default run - inside it, building and driving a browser in a worker parallel with everything else once pushed the MCP suite past its 60s timeout - and runs serially in CI instead, in the job that already has the submodules and dependencies. A unit test cannot reach this class of failure: the failure is the install promise rejecting.
Both
zipsegvsubmodules are fetched from canonical GitHub URLs.image-to-txtcame fromgit.sr.ht, the only host outside GitHub in SUBMODULES.md, and every CI job on every commit and platform depended on it: it took the Windows leg of a release build down when sourcehut was unreachable, and it cannot be fetched from a restricted network at all, sobun run buildand every test gated on the full handler registry were impossible to run outside CI - which is how a service worker that could not install reached a release. The author publishes a GitHub mirror, and it is now used.espeakng.jswas a quieter problem: it was fetched throughgithub.com/TheZipCreator, an account since renamed tozipsegv, so it resolved only through GitHub's rename redirect - and a freed username can be registered by anyone, leaving that path one registration away from serving a stranger's code. Neither pin moved. An identical commit SHA hashes identical content and identical ancestry, so both are the same bytes from a different host, verified by cloning each and resolving the pinned SHA.The architecture and deployment docs match the shipped service worker. docs/ARCHITECTURE.md had described
/assets/as StaleWhileRevalidate with 200 entries and a 30-day TTL, and the precache as HTML, CSS, icons and fonts - the configuration this release replaces, presented as current - and now carries a Stale shell recovery section covering both recovery handlers and why their scopes differ. docs/DEPLOYMENT.md told self-hosters to mirror a header table that no longer held the rules that matter, and now documents the immutable/assets/*header, the requirement that missing build output 404 rather than fall through to the SPA rule, and nginx's non-mergingadd_headersemantics. docs/CONTRIBUTING.md documentsbun run test:shell.The conversion modal stopped changing size while you read it. The progress modal is auto-height and its message is one
<br>-delimited paragraph, so the row count is the height, and three separate things were changing it mid-run: the engine's own progress line was omitted rather than left blank whenever there was nothing to report, so it appeared a second into every file and vanished again at every file boundary; the phases that paint the modal directly (reading, warming up, downloading an engine, packing a ZIP) built two rows of a different shape instead of the status block's four; and the Stop button's footer, roughly 110px of modal, was mounted three phases in and torn off again before the ZIP. Measured across a three-file conversion in Chromium, the box stepped between 264px and 385px, a 121px swing, with a 23px dip at the start of each file. Every phase now renders the same four rows through onestatusHTMLhelper, empty rows reserved rather than dropped; the footer is mounted once before the first paint and disabled rather than removed for the stretches where cancelling is not offered; and a floor on the status paragraph catches the states that do not come from the helper at all, including the soft-cancel notice. Measured again the same way: 385px at every phase, on desktop and at 375px and 320px viewports.The modal holds still through a Stop, too. A soft cancel - a main-thread handler, which cannot be interrupted mid-file - grew the box from 393.59px to 432.73px the moment the engine reported its first progress detail, one beat after the user pressed Stop. src/conversion/cancellation.ts inserted a
<br>and a span the first time a detail arrived, into a paragraph whose main line already ended in a<br>, so the pair rendered as two consecutive breaks and the detail cost an empty row as well as its own. The row is now in the markup from the start and only filled. The copy did not fit either: at a 320px viewport the notice's paragraph is 250px wide, and the four-row floor pays for one row of main copy, one of detail and two of note, but the main line ran to 42 characters and wrapped, and the note to 89 and wrapped to three. Both are inside the budget now at 35 and 76, carrying the same two facts. Measured driving the real app at 1280, 375 and 320px: 393.59px throughout.Both progress spinners occupy the same box.
.loader-gooeyis 104px tall with its margins and.loader-spinnerwas 96px, and the modal swaps one for the other the moment pathfinding hands over to a handler, so every conversion stepped 8px at that transition and the hard-cancel popup sat 8px short of the run it interrupted. The ring takes the difference as margin rather than growing, so it looks the same and measures the same.pdftotxtruns natively under MCP again. src/handlers/pdfparse.ts resolved the pdf.js worker by its path inside the package, but pdf-parse declares anexportsmap with no./dist/*entry, so the resolve was blocked, the handler was left unregistered and everypdftotxtfell back to the browser bridge without saying so. The worker is now resolved as a sibling of the package's own entry point, which is exported.The Content-Security-Policy is enforced. public/_headers carries a policy with no
'unsafe-inline'and a sha256 for every inline script, stamped in at build time from the final bytes indist/. It had never been applied:netlify.tomlalso declared a CSP for/*, and netlify.toml takes precedence over_headersfor the same header on the same path, so what shipped was the permissive policy on every path. The_headerscomment block says "Enforced" and records driving violations from 8 to 0, against a policy the site was not serving. Measured on a deploy preview across eight paths before and after: 0 hashes and'unsafe-inline'present, then 5 hashes and'unsafe-inline'gone, with the app booting all 74 background handlers and the docs page reporting no console output at all.docker/nginx/default.confkeeps the permissive policy, for the reason the netlify.toml one should never have existed: a static config cannot carry hashes that change with every build.The formats index is no longer a thin page.
/formats/shipped 114 words of body. The guard against doorway pages measured the whole document, so the meta description and the social tags paid into a floor meant for prose, and the index was not in the set the guard walked at all, sincebuildLandingPagesreturns the pairs and the hubs while the index is built separately. It now measures the body and includes the index, and the page explains how the router chains conversions when no direct converter exists and why some formats can be read but not written.LibreOffice converts on Windows. The user-profile URI was built by splitting the path and running every segment through
encodeURIComponent, which turns the drive letterC:intoC%3A. LibreOffice does not reject that URI, it hangs on it, so every native conversion ran to the 120s timeout and was killed. Measured on the same file with the same binary:file:///C:/...exits 0 with an 86,639-byte PDF,file:///C%3A/...produces nothing.docxtopdfwent from a 120,040ms timeout to 2,142ms. src/handlers/libreoffice.ts now usespathToFileURL.LibreOffice no longer claims it can read EPUB. It writes EPUB but has no import filter for it, so the declared
from: truegave the route graph anepubtopdfedge that could only fail. Verified against LibreOffice 26.2 with a real 6,150-byte EPUB produced by the app's own pandoc handler: exit 1, "source file could not be loaded". It also brokemdtopdf, because the router preferredmd -> epub -> pdfover themd -> html -> pdfroute that works. Both convert now.pdftodocxworks headlessly. src/handlers/pdfparse.ts pointed pdf-parse at/js/pdf.worker.mjs, a path that exists only because the build copies the worker intodist/js/and a web server maps it. Under MCP, the REST API and the CLI there is nothing serving it, so every route starting atpdftotxtfailed with "Setting up fake worker failed". The worker pdf-parse ships is resolved from node_modules off the browser.The prerendered pages no longer replace the docs app. README's slug was empty, so a page was emitted at
/docs/, and because the plugin runs inwriteBundleit landed on top of the docs app after vite had built it./docs/#ARCHITECTURE.mdsilently served the README instead of Architecture, the in-app "API docs" action pointed into the same dead end, and the app's 1.6 MB bundle shipped with nothing referencing it. README now gets/docs/readme/like every other document; the app shell keeps/docs/and shipsnoindex, since the twelve prerendered documents are what the sitemap lists.The generated pages stopped claiming WebAssembly for things that are not. Every engine label was suffixed with ", compiled to WebAssembly", which is false for the browser canvas, pdf.js, JSZip, pdf-lib and the built-in text encoder, and so was wrong on 16 of the 59 conversion pages. Engines now carry a
wasmflag and the copy follows it. Handlers with no vetted display name are no longer printed at all, which keepsmeyda,renamezip,PdfCanvasCompress,svgForeignObjectandhtmlEmbedoff public pages;meydais the sharpest case, since it declares image formats so it can render waveforms, which is true of the route graph and wrong about the app.A diagram in ARCHITECTURE.md renders again. A node label carried a backslash-escaped quote, which mermaid rejects, so it had been failing silently in the docs app.
Returning visitors are no longer pinned to their first visit's format list. The localStorage copy of the format registry had no build identity on it, so a browser that cached it once kept serving it after a release added formats. It is stamped with
VITE_COMMIT_SHAand discarded when the build changes, the service-worker update prompt is dismissed per session rather than permanently, and/cache.jsonis revalidated on every request.The same pinning was still live in every Docker image. The build stamp that invalidates that localStorage copy is read by
git rev-parsein vite.config.js, and.dockerignorekeeps.gitout of the image build context, so the rev-parse threw there and every image ever published stamped itselfdev- one constant across all of them, which is the one value that can never look stale. The--build-arg VITE_COMMIT_SHAthe workflow already passed did not rescue it: adefineforimport.meta.env.VITE_COMMIT_SHAoverrides the value Vite derives from the environment, so the build arg was read and then discarded. An explicitVITE_COMMIT_SHAnow wins overgit rev-parse, normalised to the short form the docs footer renders as a commit link, anddocker-compose.override.ymlno longer pins the dev image to the fixed stringdocker.A damaged PDF is no longer replaced by a blank page and called a 99% saving. Ghostscript treats a corrupt PDF as something to recover rather than refuse: handed a truncated file it repairs what it can, exits 0, and writes a valid PDF containing one blank page. Every guard passed it - the return code succeeded, the
%PDF-header was real, and 2 KB is far under the 98% keep-threshold - so all four surfaces reported a saving of up to 99.9% over a blank page, and anyone who trusted that number and deleted the original lost the document. Measured on a report truncated at 40 KB, 200 KB, 1 MB and 3 MB: every one produced the same 2,183-byte blank page. Compression is now rejected unless the output still has every page the input had, which is the right invariant because resampling images and rebuilding object streams never touches the page tree - verified unchanged across every real compression in this release (3, 1, 84 and 71 pages, at every level). A file that cannot be checked against its original is refused rather than reported as a win. src/core/compression/pdfIntegrity.ts.A password-protected PDF is no longer emptied and called a saving. The same blank-page substitution as above, wearing the one disguise page count cannot see through. Ghostscript has no password, so on an encrypted document it reads the page tree, fails to decrypt the content streams, and writes out that many empty pages - the page count matches exactly, so every guard passed it. Measured in the built app on a LibreOffice password-protected file: 12,783 bytes and one page of text came back as 2,188 bytes, one blank page, zero extractable characters and no longer encrypted, reported as an 83% saving and offered for download. Compression now refuses an encrypted input and keeps the original, still encrypted. src/core/compression/pdfIntegrity.ts.
The PDF Editor no longer silently blanks a password-protected page. The same cause on the edit side: every tool loaded its source with
ignoreEncryption: true, which suppresses the throw but supplies no password, so pages copy across structurally intact and completely empty. Measured merging a password-protected file with a 4-page document: the output had all 5 pages, of which pages 2-5 carried 3,930 / 3,953 / 3,953 / 2,635 characters and page 1 carried zero, with no error and nothing on screen to suggest anything was lost. Merge, Organize, Watermark and Extract now refuse an encrypted source through one shared loader, so the answer is the same on the web, over REST and over MCP. src/tools/pdfSource.ts.Automatic stopped refusing long PDFs it could shrink by two thirds. A PDF's quality tier is read from bytes per page, which for a PDF says almost nothing about what Ghostscript can do - a long document is thin per page however heavy its images are. So a thesis landed in the
minimaltier and Automatic handed it straight back as "already compressed". Measured on a 5.1 MB, 100+ page LaTeX thesis: Automatic saved nothing while every other level shrank it,/printerby 65% to 1.8 MB. Since Automatic is the default, this withheld the saving from precisely the users who expressed no preference. PDFs now always get a try; the keep-threshold already discards any result gaining under 2%, so a genuinely minimal PDF still comes back untouched - it just gets there by measuring the output rather than predicting it. src/core/compression/tierDown.ts.A
.webmfile is recognised again, so Compress can shrink one. Dropping a WebM on Compress answered "can't compress this", and the cause was not in the compressor: one ffmpeg line can name several containers (matroska,webm), and the app asked ffmpeg about only the first of them, so every alias inherited the primary's extension. Nothing in the app claimed.webmfor reading,findMatchingFormatreturned no match, and the file was refused before any engine was consulted. The alias name is the container's own extension in ffmpeg's convention, so it is used now. Measured on a 4.8 MB clip: −34%, and an audio-dominant 3.2 MB WebM came back 29% smaller rather than inflating. Two related repairs went with it: the shipped format cache in public/cache.json was regenerated, having predated v3 and so carried noGhostscript,PdfCanvasCompressorimageToPdfentries at all (888 formats to 898); andhandlerSupportsFormatnow pairs a demuxer with a muxer that disagree about their mime, which it previously could not.An empty file is no longer described as "already compressed." Anything under the 512-byte floor took that answer, including a 0-byte file, which is not compressed - there is nothing in it. It now reports the same way as any other file that could not be processed.
Compression progress and results are announced to screen readers. The surface was visual-only: a screen-reader user got silence from "Compress" until the results replaced the view, with no way to distinguish a long batch from a stalled one. Progress now runs in the shared conversion modal, which is itself a polite live region, and the results head announces the outcome.
The Compress level dropdown was painted over by the page description.
#compress-contentcarries a transform from the entrance animation, making it an atomic stacking context, so the dropdown's ownz-indexonly ordered it within that subtree and#compress-description(a later sibling) covered the part that overflowed the box. Fixed with the sameposition/z-indexguard.ws-empty-layoutalready uses in the PDF workspace; the two magicz-index: 60values became--z-floating, since a raw number outside the documented scale is what let this drift unnoticed.PDF → EPSdoes not silently discard pages. An EPS cannot hold more than one page, and Ghostscript'seps2writeresponds to a multi-page PDF by exiting 0, warning on a stream nobody reads, and writing a file containing one page. A 3-page source round-tripped back to 1 page with no error anywhere the user could see. The route always uses Ghostscript's%dtemplate and returns one file per page.TIFF export is LZW-compressed. The
tiff24ncdevice defaults to uncompressed: the same 3-page source measured 19,583,480 B raw against 54,929 B with LZW, a factor of 356.A
.aifile reported asapplication/pdfis not routed to the plain PDF handler, which converted it fine while discarding the Illustrator payload. An exact extension match now beats a MIME-only fallback in detectFormat.ts: the browser's MIME is a guess from an OS table, an extension a format claims is a deliberate statement.The compression level does something on the PostScript routes. The distiller preset is the only lever it has there and it was never passed, so
PS → PDFproduced identical bytes at every setting - the same inert-control defect the video levels had. Measured end to end on a 10 MB image-heavy source,PS → PDFnow spans 127,981 B at Smallest file to 1,070,509 B at High quality. PDF/A takes it too, and still carries itspdfaidmarker at every preset.Extracting pages as a single PDF does not inflate the output. Adding cancellation checkpoints split
extract()'s copy loop; pdf-lib builds a fresh object copier percopyPagescall, so a font or letterhead image shared by every page was copied once per page instead of once. Measured at +132% on 30 pages sharing one image.Compress dropzone height now matches the PDF workspace footprint - it is the whole point of that page, not one field among several.
The taller Compress dropzone applies to the empty state only. Once files are in, the zone is the Converter's own 5.5rem summary row instead of a 10rem box with one line floating in it.
PDFs were unreachable from the Compress browse button. The file input's
acceptlist omitted them, so the surface's headline feature worked by drag-and-drop only.Nothing strands the surface on "Compressing…" any more. A picked file that is moved or deleted before the run (
file.arrayBuffer()rejects), an engine crash, or a failed WASM instantiation each now land back somewhere actionable; Emscripten'sinstantiateWasmhook has no error channel, so a failed instantiation previously hung the batch forever.Stopping a batch is reported as stopped, not failed, and the results never claim "nothing left to shave off" about files that were never opened. A real saving that rounds to zero reads "under 1% smaller" rather than "0% smaller".
Compression did not actually compress, on three of the five families it advertised. Four independent defects, each invisible to a green test suite because each lived in a seam between mocked units:
- The quality probe could veto an explicitly chosen level. It reads container metadata, not pixels, so "already as small as it gets" was a guess - and it was overruling an instruction. Image-heavy PDFs reported already compressed at every setting. The probe now only chooses when the user has expressed no preference; the keep-threshold, which measures the output instead of predicting it, remains the real guard.
- pdf.js detached the file.
probePdffalls back to pdf.js for documents whose page count is not in the trailer-scan window, and pdf.js takes ownership of the buffer it is handed. The caller's bytes came back at length 0, so the probe divided zero by the page count and would have handed Ghostscript an empty document. Three of the fourgetDocumentcall sites already passed a copy; the two that did not now do. - No video or audio was ever compressible. The capability check demanded a single format entry flagged both readable and writable. FFmpeg publishes a demuxer entry and a muxer entry per container, so nothing it handles ever resolved and every clip reported can't compress this.
- Video quality levels were inert.
planVideovaried only the size thresholds, all above 75 MB, so every ordinary clip fell through to a hardcoded CRF and the three levels produced byte-identical output. Video now scales quality by preset the way images and audio always have; Balanced is unchanged.
Measured in a browser on real documents: a 59-page consulting report −37% at Automatic and −56% at Smallest file; a 71-page research brief −17%; a 17 MB screen recording −86% at Smallest file against −66% at High quality.
Automatic aims at the reliable win rather than the largest one, and PDFs get their own rule. For every other format a lower preset means a smaller file; PDFs do not behave that way, because Ghostscript re-encodes embedded images. Measured on the research brief:
/screengrew it 42% and/ebookgrew it 65%, while/printershrank it 18%. Automatic therefore targets/printerfor PDFs. One definition now backs Automatic everywhere (src/core/compression/automatic.ts); it replaced three divergent copies, only some of which had learned the PDF rule.Stop now stops. Compress abandons the file being compressed instead of finishing it - measured at 1.3 s on a 17 MB video, where the previous contract meant minutes on exactly the file someone presses Stop over. The interrupted file is reported stopped, and the degraded fallback is not attempted for a file the user just cancelled.
The PDF Editor's compression step can be skipped. It previously had no cancel at all: worst case a 16 MB engine fetch, a WASM compile and the pass itself, with the 10-minute worker timeout as the only exit. The edit is already complete when that step begins, so skipping returns the finished document uncompressed. A multi-file save skips the rest too.
One worker job at a time, enforced rather than assumed. The worker client kept its cancel, force-cleanup and error hooks in single slots, justified by a comment claiming only one surface is ever active. Nothing enforced it, and there are three callers. Jobs are now serialised onto one queue.
Escape closes the compression-level dropdown. The handler was bound to the menu, so it only fired once focus was already inside it - never in the ordinary case of opening it by mistake. On a narrow screen the open menu covers the Compress button and swallows the click aimed at it, leaving the surface effectively stuck.
"Open Compress" brings your files with it instead of landing on an empty card and asking you to pick them again.
Compress no longer refuses large files. The surface read the whole batch into memory before the first engine ran, so its 500 MB cap was sized for that and capped the wrong thing: one 800 MB video was refused in order to guard against a batch of them. Inputs are now read one file at a time, at the moment each is compressed, so the resident set is a single file however large the batch. A single file may be up to 2 GB (a real ceiling: the engines are 32-bit WebAssembly builds needing working room inside a 4 GB address space), and a batch up to 1-4 GB scaled to
navigator.deviceMemory. Files over 512 MB are accepted with a heads-up about the wait rather than refused. Verified in a browser: a 655 MB video that the old cap rejected outright now compresses.Files that were never opened are no longer in the download. A format with no compressor, or a file you stopped before it was reached, is not read off disk at all - so the archive no longer carries a byte-identical copy of something already on your machine. Both are still listed in the results with their reason, and the download button names the count it will actually produce.
The mobile menu is scrollable. It previously overflowed short viewports with
position: fixedand no height bound, leaving the bottom items unreachable; Compression now sits last in it, after Theme, Mode and Formats.One 16 MB engine load, however racy the callers: concurrent first uses share a single fetch-and-compile, and a load that failed offline can be retried.
A finished worker run clears its cancellation callbacks, so a later hard-cancel can no longer terminate a worker that is busy with an unrelated job.
The Converter announced compressions that never happened. Every hop is handed
--quality, but only 7 of ~42 handlers read it, and the claim was made from the setting - before any handler ran, and regardless of the target. Weighed in the real UI, a 1,207,043-byte JPEG to ZIP came back byte-identical at every level and 126 bytes larger than the input, under the words "Compressed at Smallest file". Handlers now declareusesQuality, opt-in and absence meaning no, and the claim is made only when the hop that produced the kept artifact actually read it. A zero-hop path is excluded too, or the source node's handler would be credited for work no one did. Where a level was chosen and could not apply, the modal now says so.Video progress was pinned at 0%. FFmpeg's
out_time_uswas being read from a line the build does not emit for video, so a four-minute compression sat at zero throughout - worse than no progress bar, because it looked stalled.The merge preview no longer overflows a phone. A PDF built from screenshots has pages many times taller than they are wide, and the grid sized cards from the page rather than the viewport.
Removing a file on a phone no longer closes the sheet under your finger.
cleanup()deletes the body-appended tray, and Organize, Watermark and Extract routed theirxthrough a full tool re-render - so clearing three files meant reopening the tray three times. Worse, the scroll lock is derived from an open tray existing, so deleting one without re-deriving it leftoverflow-y: hiddenon<html>with nothing on screen to dismiss: the page could not be swiped until the tray was opened and closed again. Focus was dropped to<body>on all three tabs. All three are fixed, and the tray's Escape handler no longer outlives its tray.MP4 → WEBM converts, and finishes (#23). Two faults, one behind the other.
libvpx-vp9andlibopusboth die with a memory-access fault on a two-second clip, and ffmpeg selects exactly that pair for a.webmoutput; both are now pinned to encoders that complete. That stopped the crash and exposed the second fault underneath: libvpx's default speed encodes 1080p at about ten times realtime in this core - 205s for a 20-second clip - against the ten-minute worker ceiling, so a longer clip simply ran out of time and reported the generic failure.-cpu-used 5is 4.6x faster on the same source. Peak wasm heap was flat at 100-115 MB across every duration against a 2 GiB limit, so this was never the memory problem it looked like from the outside.The compression level reaches the WebM encoder (#25). It never did:
-crfis inert for libvpx in this core, so all four levels produced byte-identical output and every WebM came back at the encoder's own default. The reported 20-second phone video went in at 7,276 kbps and came out at 972 - an 87% cut on a format conversion, the same at every setting. The route now sets a bitrate derived from the source rather than a constant, because a fixed ladder would inflate a lean clip. Measured on six seconds of 1072x1920 at two source bitrates, output as a share of input:Level 7,067 kbps source 1,523 kbps source Original quality 78% 86% High quality 63% 70% Balanced 42% 60% Smallest file 28% 60% No level exceeds its input at either bitrate, which is the constraint that ruled out fixed targets. On the lean source the two lowest levels converge, because libvpx will not go below roughly 900 kbps at this resolution - the encoder's floor, not a bug.
Removing a file in the PDF Editor is announced to screen readers. Nothing about a removal reads as text: the row disappears and the count beside it is rebuilt rather than edited, so a sighted user watched the list get shorter while a screen-reader user got silence. The obvious markup does not work -
aria-liveon the count is inert, because that element is recreated on every update and a region that arrives already holding its message is not a change to anything the reader was watching. The app now carries one static live region and only its text moves. Identical text is not a change either, so two removals producing the same sentence would announce once; a trailing no-break space alternates, making the second textually distinct without altering a word that is read out.A status handle survives its own run no longer.
resetAll()on Compress abandoned an in-flight run's handle instead of cancelling it, leaving a 1s interval repainting a modal for a batch that no longer existed. Found by counting timers rather than reasoning about them: 7 of 51 armed by one suite were still ticking at the end.Frogsworth can be torn down.
initFrogsworthconstructed the widget and dropped the reference, which madedestroy()unreachable and left a 15s idle timer and three window listeners with no owner.destroyFrogsworth()now exists.Deferred callbacks no longer reach for globals that have gone. Every repeating and long-lived timer was swept - four intervals and six timeouts of 100ms or more - and the three that read a global from a timer nobody owns were guarded: a 200ms poll that only stops once the format graph loads, the dancing frog's hover animation, and a five-second object-URL revoke. The rest either capture their element or are cleared in a
finally, and were left alone.The success confetti no longer reaches for a document that may be gone. All three success paths - Convert, Compress and the PDF Editor - scheduled the celebration on a 150ms timer and read the popup inside it, through an accessor that resolves against
document. A timer outlives whatever scheduled it, so under load that read could land after the surrounding page had been torn down. It surfaced in CI as a run with 1,110 passing tests and nothing failing that still went red on an uncaughtReferenceError: document is not defined. The three copies are now one helper that captures the popup up front and dereferences nothing global, andtriggerConfettichecks for a document before drawing on one.Background emoji sharpen under the cursor again after the window is resized. The proximity unblur measured every emoji's centre once, at startup, and never again, while the wrappers are positioned in viewport percentages - so a resize moved all nine and left the halo lighting up coordinates nothing occupies any more. Measured across a 1600 to 900 resize: up to 644px of drift, which is most of the window. Two smaller errors went with it: the first measurement was taken while the entrance animation still held the wrappers 20px low, and it read the span, which runs the infinite float keyframe, so the anchor was a frame of an oscillation rather than a rest position. Positions are now taken from the wrapper, re-measured when the entrance settles, and re-measured on resize through a coalescing frame. Verified by hovering all nine after a resize: nine of nine sharpen. src/components/AmbientBackground/AmbientBackground.ts.
vitest runno longer boots an API server and a headless Chromium. The dev-server plugin invite.config.jsis markedapply: 'serve', which reads as "dev only" and is not: Vitest builds a Vite server in serve mode too, so every test run spawned a full API server on the fixed port 3000 plus the Chromium its bridge warms up, for a suite that wants neither. Every symptom had been attributed to something else - a second run, or abun run devin another terminal, made the spawned child die onEADDRINUSEprinting a bare "Fatal error" that reads exactly like a test failure; and thesomething prevents Vite server from exitingwarning, with its 10-second teardown stall on the end of every run since the plugin was added, was that child holding the runner open. A bare test file goes from 3.96s to 236ms. vite.config.js, guarded by test/viteConfig.test.ts.POST /compressno longer calls a file "missing" when it was sent. A zero-byte multipart upload was rejected with Missing 'file' field (must be a file upload), which sends the caller to inspect a form that has nothing wrong with it. Bun's multipart parser drops thefilenamefrom a part with no content - measured: a 1-byte part arrives asname: "one.pdf", a 0-byte part asname: undefined- and the format is derived from the name, so an empty file arrives unnameable rather than absent. Still a 400, since with no extension there is no format to route, but the reply now names the real problem and points at the JSON body shape, which handles the same file correctly and reports it as a 0% saving. src/api/routes/compress.ts.The Electron test waits for a window, not just for a debugger. It polled for the DevTools endpoint and then took
pages()[0]immediately, but those are two separate events with a real gap between them: measured,DevToolsActivePortis written about 350ms before theBrowserWindowexists, andpages()straight after connecting returns an empty array. The old code only ever worked because its one-second retry sleep happened to cover the gap; on a loaded CI runner it does not, and all four tests then died onCannot read properties of undefined (reading 'goto')- which names neither the missing window nor the race that lost it. It now waits for a page and says so plainly if none arrives. The fixed debugging port went with it: an Electron left behind by an earlier run holds that port, andconnectwould happily attach to somebody else's window, so the port is now assigned by Chromium and read back from the profile directory. test/e2e/electron-app.test.ts.The PDF editor's Extract no longer empties a password-protected source on the "Combined PDF" branch. It loaded sources with a bare
ignoreEncryption: true, which suppresses the throw and supplies no password - so a protected file contributed pages of the right size carrying nothing, and the run reported success. Its sibling per-source branch went throughextract()and refused correctly, so the two halves of one button disagreed and only one of them said so. Now usesloadEditablePdflike every other editing path; a source-level guard in src/tools/pdfSource.test.ts keeps the distinction between editing loads and the two legitimate measuring loads. src/components/PdfWorkspace/PdfWorkspace.ts.The GPLv3 text now ships. The README has declared GPL-3.0-or-later since 3.0.0 while the repository contained only the inherited GPLv2 text, which GPLv3 section 4 requires to accompany the work. LICENSE is now GPLv3, LICENSE.upstream-GPLv2 keeps the inherited text, and the new COPYRIGHT names both copyright holders - nothing in the tree previously named any.
package.jsonhad nolicensefield at all, so the published package shipped as licence-unspecified.Documentation that was wrong. Same-format conversion has not compressed since 3.0.0, but three docs still described a re-encode and a size-guard on that path.
highcaps the long edge at 3840 px, contradicted in five places. The progress line stopped alternating with its reassurance. ImageMagick is 14 MB, not 80. Seven handlers emit progress, not six. ARCHITECTURE listed the pre-3.0.0 seven MCP tools. PDF_EDITOR claimed device memory was the only limit (there are three intake caps) and that Extract always yields one PDF (it offers a choice).bun run docs:verifywas described as a link checker in two places; it compares root markdown againstdocs/twins and checks no links at all.A format token no longer resolves to whichever entry the handler list happened to reach first.
jsonmatches nineteen registry entries,pngthirty, and the agent surfaces resolve a(mime, token)pair rather than being handed theFileFormatthe user clicked, so they had to guess. The guess was "first match in handler order", and it was wrong in ways nobody could see from the outside:jsonresolved to pandoc'scsljson, a bibliography format, so every server-side json route parsed ordinary JSON as CSL andcsvtojsonemitted CSL;pdfas an output resolved to Ghostscript, which only writes PDF from PDF, somdtopdfandepubtopdfreported no path at all while their landing pages advertised them. src/mcp/core/utils.ts now ranks the candidates - an exactformatmatch above one that only matched theextension, ties keeping handler order - and tries the pairings in order rather than committing to the first, because a token can resolve to a perfectly good reader that is still a dead end for this particular target. This is the strategy scripts/verify-conversions.ts has used all along to prove the pairs work; it now lives where the shipped code can use it, behind a cap so a token with thirty readers cannot hang a request.MCP stopped asking for a path through one named handler.
convert_fileandfind_conversion_pathsearched with the handler-constrained mode while REST/convertused the simple one. The graph keys its nodes by format, not by handler, so naming a handler on the target bought no precision - it only rejected paths whose last hop came from a different handler writing the same format, and left whatever longer route survived. Measured against the Node handler set:pdftotxtranGhostscripttotifftopngtohtmltopandoc, rasterising the page to read its text, anddocxtopdfranlibreofficetoziptopngtoimageToPdf. They are nowGhostscripttopdfparseandlibreofficetolibreoffice.An unknown format asks the browser bridge instead of being turned away.
find_conversion_pathandGET /pathreturned "not available" for any token no native handler claimed, without checking the bridge, on the reasoning that the bridge could not resolve it either. That is backwards: the bridge is the side that loads the browser-only handlers.pngtosvgneedssvgTrace, which never loads under Node, so the answer was "not available" for a conversion with its own landing page.pdf-parseno longer destroys the caller's bytes. pdf.js detaches whateverArrayBufferit is given, and src/handlers/pdfparse.ts handed it the caller's own - the only one of the five pdf.js call sites in the repo not to copy. Any second use of those bytes then threwUnderlying ArrayBuffer has been detached from the view: a multi-hop route reusing them,convert_filefalling back to the browser bridge after a native failure, or one sample PDF converted to several targets in a row. Latent until the worker-resolve fix in 3.0.0 made this handler reachable under Node at all.The app can be built and fully tested without reaching the SheetJS CDN.
xlsxwas pinned to a tarball oncdn.sheetjs.com, which an allowlist permitting only the npm registry answers 403 - andbun installexits 0 regardless, leaving the package silently absent. With the submodule half fixed above, that one absence decidedhasFullRegistry, and with it six suites, among them test/e2e/stale-shell-recovery.test.ts: the only test that drives a real service worker, and the one that would have caught the install failure this release opened with. It could run in CI and nowhere else. Going back to npm'sxlsxwould have been a security regression rather than a fix - it is frozen at 0.18.5, carrying GHSA-4r6h-8v6p-xvw6 and GHSA-5pgg-2g8v-p4x9, both high and both patched in the 0.20.3 pinned here - and the SheetJS GitHub mirror stops at that same 0.18.5 across all 110 of its tags, so everything patched exists only on that CDN.xlsxis now an alias for@e965/xlsx, an npm republish, adopted on evidence rather than on trust: a CI job fetched both tarballs on a runner and found the official one matches the sha512 already inbun.lock, and the republish differing from it in exactly one file -README.md, whose relative links are rewritten to absolute ones. Every code file is byte-identical. That audit is now the standingxlsx-provenancejob, re-proving it on every run and failing on any divergence outside the readme. Measured on a restricted network that could not installxlsxat all:bun run testgoes from 1331 passed with the TMX case failing to 1343 passed and none failing, andbun run test:shellruns 5/5 in 67s having never once run outside CI.nginx parses its own config before an image ships. docker/nginx/headers.test.ts reads
default.confas text and checks that everylocationadding a header of its own re-includes the security snippet - a real regression guard, but not a syntax check. nginx had never looked at the file. A typo'd directive, a dropped semicolon or a renamed snippet passed that test, built a clean image, published to GHCR on the next master push, and failed only when someone started a container. A CI job now runsnginx -tagainst the samenginx:stable-alpinethe runtime stage uses. The config was already valid, so this closes a gap rather than fixing a break; three deliberate breakages are each rejected, the include-pointing-nowhere case being the one the text-level test cannot see at all. It runs in CI rather than as aRUNin the Dockerfile becausenginx -topens the listening sockets as well as parsing, and on a builder without IPv6 thelisten [::]:80probe would fail the image build for a reason unrelated to the config.A PDF compression pass says what it is doing, page by page. Reported from a phone as "stuck": the modal read Compressing your file... over the file's name and stopped moving. Ghostscript was working the whole time, but the pdfwrite pass is a single synchronous
callMainrun under-dQUIET, which suppresses the only progress the engine emits - and the handler discarded its stdout on top of that, passing Emscriptenprint/printErroptions this build marks unsupported and never calls. So a scan that takes seconds on a laptop and minutes on a phone had exactly one frame of feedback, indistinguishable from a hang, and a run that genuinely died left no clue where. The compression pass now runs with the flag off and reads what it prints -Processing pages 1 through 40., then aPage nper page - through a tap onconsole.loginstalled across the Emscripten factory call, which is what the loader binds its output to. The lines arrive synchronously from insidecallMainand the handler runs in a Worker, so each one reaches the modal while the pass is still running: Page 7 of 40, then Checking the result for the pdf-lib integrity guard that follows it, which was itself seconds of silence on a long document. The agent surfaces keep-dQUIET, since the MCP server speaks JSON-RPC over the same stdout. Asserted end to end in a real browser against real Ghostscript rather than only over the parser.The progress modal stopped saying the file's name twice. On Compress it read Compressing your file... / DOC-20250501-WA0012. (1).pdf / Compressing DOC-20250501-WA0012. (1).pdf / keep this tab open: four rows, three of them already-known facts, with the one row reserved for what the engine is actually doing spent on the heading and the subtitle combined. Ghostscript no longer names the file in its compression detail - every surface that runs it already shows the name - and
statusHTMLnow drops any live line that only restates the subtitle, matching a full name against the shortened form on screen. The row is still reserved rather than removed, so the box does not change height.A download that fails says so instead of looking like a crash.
downloadFileanddownloadAsZipare reached from click handlers that ignore the returned promise, so a Blob the device has no memory for, or a ZIP that could not be built, escaped as an unhandled rejection into the app-wide recovery popup - frogConvert hit an error, over a finished result, naming nothing. Both now report through a toast and returnfalse. Two more floating promises fed the same popup: pdf.js document teardown, which rejects when the worker is already gone - on a phone, exactly when the thumbnail cache is evicting - and the autosave flush, whose payload builder is caller code. And when the popup does appear it now carries the error's own first line, because on a phone it is the only place an error is visible at all.The engine download is announced once, under one name. A PDF batch read Downloading the document compressor..., then Reading your file..., then Fetching the PDF compressor (7%): the same 16 MB announced, apparently abandoned, then started again under a second name. Both halves were real. The surfaces announce the engine before calling
handler.init(), which is honest for ImageMagick and FFmpeg - they fetch their WASM inside it - and false for Ghostscript, which deliberately defers its 16 MB to first real use, so the first line claimed a download that had not started. And the name came from the format's category on the surface (document) but from the handler in the engine (PDF). The pre-init line now says Getting the ... ready, which is true whichever way an engine loads, and only the engine that actually reports a download claims one - with a real percentage. Ghostscript answers todocument compressoreverywhere, matching the vocabulary the other engines already use (image compressor, video compressor), including in the canvas-fallback warning.A batch-position prefix that could never fire is gone. The Ghostscript compression branch carried
File n of m ·in front of its page counter for the multi-file case. There is no multi-file case:compressBatchruns one file per call and it is the shared orchestrator for the browser, MCP, REST and the CLI, the PDF editor's optional pass passes one, and the multi-file callers in the convert pipelines take the conversion branch and never reach it. knip cannot see a dead branch inside a live function. The surfaces already carry the position in their own heading, which is where it belongs.
Known limits
- On an already-lean WebM source the two lowest compression levels converge, because libvpx will not encode below roughly 900 kbps at 1080p. The encoder's floor rather than a defect, and it never inflates. The level's target is also taken from the container bitrate and spent on the video stream, so "no level exceeds its input" is what every measured source did, not a hard guarantee for one where audio carries most of the bitrate.
- Cancelling a Compress batch during the degraded canvas PDF fallback (which only runs when Ghostscript is unreachable) waits for that one file: it is a main-thread handler, so there is nothing to terminate.
- The whole batch is held in memory.
- Merging a fillable PDF drops its AcroForm fields: the pages survive and the field content renders as flat page content, but it is no longer a form. Watermark and Organize keep the fields, because they write to the pages that already exist rather than copying them into a new document. Measured on a 1-page form with three fields merged with a 4-page document.
- The Compress drop zone filters on MIME type alone, so a few image types that have no same-format compressor (HEIC, AVIF) are accepted and then reported can't compress this per file. Erring this way is deliberate: the authoritative answer needs the handler registry, which loads later, and over-rejecting at the door would turn away files that can in fact be compressed.
[2.5.0] - 2026-07-15
Zip download names are now unique and content-descriptive. Repeated exports no longer overwrite the previous download or pick up browser (1)/(2) suffixes, and multi-file archives are named for the operation that produced them instead of borrowing one arbitrary source file's name.
Fixed
- Multi-file zip downloads collided on repeat. Every zip name that a convert or PDF batch produced was either day-granular or had no time component at all, so running the same export twice handed the browser an identically named file - it silently overwrote the earlier download or appended
(1),(2). The two convert archives, src/conversion/actions.ts, usedfrogConvert-${getFormattedDate()}.zip/original-files-${getFormattedDate()}.zipwheregetFormattedDate()returned onlyYYYY-MM-DD, so any two "download all" clicks on the same day collided. The organize- and extract-per-source archives, src/components/PdfWorkspace/PdfWorkspace.ts, used${firstName}_organized.zip/${firstName}_pages.zipwith no disambiguator, so they collided on every repeat. All six archive names now carry a compact ISO-8601 timestamp (see below) so they're unique to the second.
Changed
- New shared
timestampForFilename()helper. src/conversion/download.ts exportstimestampForFilename(d = new Date()), returning a compact ISO-8601 basic-format stampYYYYMMDD-HHMMSSin local time - the de-facto standard for machine-generated exports (Google Takeout,IMG_YYYYMMDD_HHMMSScamera files, log rotation): sortable, filesystem-safe (no colons), unique to the second, and without the separator noise of a fullYYYY-MM-DD_HH-MM-SSform. Lives indownload.tsbecause both the converter (actions.ts) and the PDF workspace (PdfWorkspace.ts) already import their zip helper from there. Unit-tested in src/conversion/download.test.ts. - Multi-file zip names now describe the operation, not one source file. A per-source archive only exists when there is more than one output, so naming it after
files[0](report_organized.zip) misrepresented an N-file bundle as belonging to a single "report". src/components/PdfWorkspace/PdfWorkspace.ts now emitsorganized-pdfs-<ts>.zip,extracted-pages-<ts>.zip,watermarked-pdfs-<ts>.zip, andpdfs-<ts>.zip; the converter emitsfrogConvert-<ts>.zipandoriginal-files-<ts>.zip. Example:organized-pdfs-20260715-143207.zip. The deadfirstNamebinding indoOrganizeSavePerSourcewas removed; the one indoExtractstays (it still names the single-file grouped-extract output). Individual (single-file) downloads are unaffected - only the multi-file archive names changed.
[2.4.0] - 2026-07-04
Hamburger/close icon rendering fix: the three menu bars no longer rasterize at different thicknesses on fractional display scaling, and the menu-open ✕ is larger and crosses exactly at its middle.
Fixed
- Hamburger bars render at even thickness on 125%/150% display scaling. src/components/TopBar/TopBar.css
#hamburger-btnbars sat on a 7px vertical pitch (2px bar + 5px gap). At fractional devicePixelRatios (Windows display scaling, browser zoom) 7px maps to a non-integer device-pixel step - 8.75 device px at 125% - so each bar landed on an unrelated subpixel phase: one rasterized as solid rows while another smeared across an extra half-lit row and read as visibly thinner. Pitch is now 6px (2px bar + 4pxgap); 6 × any quarter-step DPR (1, 1.25, 1.5, 1.75, 2…) has a fractional part of exactly 0 or .5 - its own mirror image - so the outer bars always rasterize identically and the glyph stays vertically symmetric. Verified by per-row pixel analysis in headless Chromium at DPR 1 / 1.25 / 1.5 / 2. (An 8px pitch would make all three bars phase-identical, but was rejected during the cut: it produced a 16×18 taller-than-wide glyph that read as oversized on the 44px mobile control. The 18×14 wide format matches canonical hamburger proportions; only the middle bar can pick up a half-shade softness, and only at ×.25/×.75 scales.) - Hamburger bar ends no longer blurry. Bars were
width: 85%of the padded content box, resolving to a fractional 15.296875px at a fractional x-offset. Now a fixed18pxat an integer offset inside the button (padding: 0- the fixed width replaces the padding-derived sizing), at both the 36px desktop and 44px coarse-pointer control sizes. - Menu-open ✕ crosses at its middle and matches the hamburger's optical size. The ✕ is the two outer bars converged with
translateYand rotated ±45°.translateY(±7px)at 125% scaling is ±8.75 device px, so the two strokes snapped to the pixel grid in opposite directions and their intersection drifted off the middle of the glyph - very visible on what was a ~12px ✕ (the 45° rotation shrank the 15.3px bars' footprint by 1/√2). NowtranslateY(±6px)(matches the new 6px bar pitch; ±6 × quarter-step DPRs land on the same subpixel phase, so the strokes snap together, not apart) plusscaleX(1.4)stretching each arm to 25.2px, so the ✕ spans ~17.8px - optically matching the 18px-wide hamburger. Ink-map analysis confirms the crossing sits exactly on the button's center row/column with all four arms mirror-symmetric at DPR 1 / 1.25 / 1.5 / 2 / 4, including at fractional page offsets, and that mobile (44px control, DPR 3) keeps the same optical size as v2.3.10. - Docs sidebar toggle aligned to the same geometry. src/styles/docs.css
#nav-togglehad the same defect worse: 1.5px bars can never fill a whole device-pixel row, so they rendered unevenly at every scale, on a fractional 5.5px pitch. Now the same 18×2px bars on a 6px pitch as the app hamburger.
[2.3.10] - 2026-05-20
Custom-cursor visibility fix for low-contrast displays.
Fixed
- Custom cursor visible on low-contrast displays. src/components/CustomCursor/CustomCursor.css bead
::afterrules in both themes were tuned for the brand surface and disappeared once the display itself was poor (cheap LCDs, glare, dim brightness, narrow viewing angle). Light mode wasrgba(255, 255, 255, 0.50)fill +rgba(0, 0, 0, 0.10)border, which read as ~50% white on a white page with a barely-there edge. Dark mode wasrgba(255, 255, 255, 0.07)fill +rgba(255, 255, 255, 0.13)border, which on#000000produced a ~7% luma delta against the page (effectively a ghost). Bead fill now lifts torgba(255, 255, 255, 0.85)(light) /rgba(255, 255, 255, 0.32)(dark), border torgba(0, 0, 0, 0.28)(light) /rgba(255, 255, 255, 0.55)(dark), and an extra0 0 0 1pxhalo ring is added to thebox-shadowstack in both themes so the bead always carries a one-pixel contrast edge regardless of the surface underneath. The rainbow::beforehighlight, the.cursor-glowaura, the interactive / active-click state transitions, and the(pointer: coarse)short-circuit are untouched. Picked from an A/B side-by-side prototype across white, black, mid-grey, gradient, brand accent, light-on-light text, dark-on-dark text, mixed-luma image content, and frosted top-bar surfaces.
[2.3.9] - 2026-05-18
Spacing fix on top of v2.3.8: the gap above the action-footer divider was double-counted because flex containers don't collapse vertical margins, so <p>'s natural 1.5rem margin-bottom plus the footer's 1.5rem margin-top stacked to ~3rem.
Fixed
- Popup-footer gap normalised to 1.5rem. src/conversion/conversion.css now zeroes the margin-bottom on the body element directly preceding
.popup-actions-footervia#popup .popup-scroll > *:has(+ .popup-actions-footer) { margin-bottom: 0 }. The footer keeps itsmargin-top: var(--space-6)as the single source of truth for the gap above the divider. Result: uniform 1.5rem above divider / 1rem below (the footer's existing padding-top) on every popup with an action footer, regardless of whether the last body element is<p>, a notice card, or a custom<div>. Previously the gap ranged from ~1.5rem to ~3rem depending on what kind of element preceded the footer; now all popups read the same.
[2.3.8] - 2026-05-13
Two polish items on top of v2.3.7: error popups no longer blame the file by default, and every popup with an action footer keeps breathing room between the body and the divider line.
Fixed
- Error popups stop blaming the file by default. src/conversion/actions.ts
showConversionFailedPopupwas emitting the same body - "The file may be corrupted, password-protected, or too complex for the converter." - for every error kind exceptnot_available, including the catch-allkind: "unknown"for errors that don't match any classification regex. EPS to PNG and similar routes that are advertised in the format graph but fail at runtime (ImageMagick WASM ships without Ghostscript) were falsely accusing the user's file. The function now branches bykind:not_availableandunknownboth render under "Conversion not available yet" with neutral, capability-gap copy and the maintainer email;input_issuekeeps the file-side copy (password / corrupt / variant);runtime_failuresays "X to Y was interrupted, try again or use a smaller file";cancelledearly-returns so a leaked cancellation can't render under a failure title. - No more duplicate "Something went wrong" line in error popups. The muted detail span was echoing the same generic string the body already conveyed. src/conversion/actions.ts
showConversionFailedPopupnow suppresses the detail span whenerror.messageequalsGENERIC_CONVERSION_ERROR_TEXTorCONVERSION_NOT_AVAILABLE_TEXT; specific messages (password, worker crashed, too-large) still surface. toUserErrorInforecognises WASM-handler capability gaps. src/components/utils/index.ts now classifiesNoDecodeDelegateForThisImageFormat,MagickDelegateError,Ghostscript,unable to load module,ImageMagick is not configured,not authorized/not authorization, andpolicy deniesaskind: "not_available". Covers ImageMagick policy.xml denials (e.g. PDF read disabled) and the Ghostscript-missing case for EPS/PS. Regression test at src/components/utils/toUserErrorText.test.ts.- Popup-footer breathing room. src/conversion/conversion.css
#popup .popup-actions-footerflippedmargin-top: autotovar(--space-6)(1.5rem). Theautowas redundant -#popupis flex-column and.popup-scrollisflex: 1 1 auto, so the wrapper already pushes the footer to the bottom - and it left the body's last paragraph sitting flush against the 1pxborder-topdivider above the action buttons. 1.5rem gap above divider, 1rem padding-top below (footer's existingpadding-top: var(--space-4)) gives a uniform breathing pocket on every popup with a footer (showAlertPopup,showConfirmPopup,showSizeWarningPopup,showFileTypeMismatchPopup,showUploadSummaryPopup,showUnsupportedFilePopup,ensureCancelButton,showPartialDownloadPopup, conversion success modal, PDF success modal).
[2.3.7] - 2026-05-13
Two small follow-ups on v2.3.6: the cold-start splash now fully owns the boot UI (the legacy thin loading bar at the top is gone), and the popup scroll architecture is simplified so the wrapper is structural-only and each popup that needs scrolling provides its own inner scroller.
Removed
#loading-bar. v2.3.3's cold-start splash overlay covers the entire viewport during boot, so the 3px breathing bar attop: 0was hidden underneath and only visible in the brief window after the splash dismissed but before phase-2 handlers finished. Deleted src/main.tsshowLoadingBar(), the two call sites, thehasLocalStorageCacheflag that gated them, and the#loading-barCSS +loading-bar-grow/loading-bar-breathe/loading-bar-finishkeyframes +--z-loading-bartoken from src/styles/global.css. The splash is now the single source of "we're booting" feedback.
Changed
.popup-scrollis structural, not a scroller. src/components/Popup/Popup.css.popup-scrollflipped fromoverflow-y: autotooverflow: visible. The wrapper still exists to anchor margin-reset selectors and to givepopupContent()a stable target to clear on rotation, but popups that genuinely need scrolling supply their own inner scroller (.type-filter-scroll,.upload-summary-list). Removes the awkward double-scroll situation where both.popup-scrolland an inner list could each show a scrollbar.
[2.3.6] - 2026-05-13
Scrollbar-inside-the-card refactor across the three modal surfaces (Popup, FilesModal, PdfWorkspace mobile trays), a handful of UX defects that surfaced under v2.3.3's cold-start splash, and security/privacy hardening on the HAR handler, share-target SW, and CSP.
Fixed
- Scrollbar no longer escapes rounded corners. src/components/Popup/Popup.ts, src/components/FilesModal/FilesModal.css, and src/components/PdfWorkspace/PdfWorkspace.css all moved from outer-element
overflow: autoto an inner.popup-scroll/.ws-tray-scroll. Outer staysoverflow: hiddenwithborder-radius; inner owns the scrollbar so it lives inside the rounded card instead of protruding past the corner. Drops thedirection: rtlhack the FilesModal was using to fake scrollbar-on-the-left. - PDF Workspace mobile tray, overlay, toolbar invisible at boot. v2.3.3 added
html.app-revealed body > * { animation: app-fade-in 0.25s forwards }to FOUC-gate the page.forwardsstuck every body-appended element at opacity 1, which beat the mobile tray's opacity-based hidden state, so the tray ghost was visible (and intercepting taps) before the kebab was opened. index.html now dropsapp-revealedonce the firstanimationendfires (with asetTimeout(350)fallback for prefers-reduced-motion), so any later body-appended element inherits no animation. Regression covered by a new test/e2e/conversion-flow.test.ts case that probes a post-boot<div style="opacity:0">plus a Watermark-tray DOM test in src/components/PdfWorkspace/PdfWorkspace.dom.test.ts. - Cancel button no longer bleeds into the next popup. src/components/Popup/Popup.ts
popupContent()now strips any non-.popup-scrolldirect children of#popupon rotation, matching the pre-refactorpopupBox.innerHTML = ""semantic.ensureCancelButtonin src/conversion/cancellation.ts appends.popup-actions-footeras a sibling of.popup-scroll; without the strip it survivedreplacePopup([...])and ghosted under the cancellation spinner and success modal. - Background-emoji proximity unblur tuned. src/components/AmbientBackground/AmbientBackground.ts now uses CORE_RADIUS=60 (fully clear) plus HALO_RADIUS=180 (ramp). The single-radius linear ramp from v2.3.5 never quite let the cursor-area emoji fully sharpen. Also dropped the
isWideMOBILE_BREAKPOINT gate:isTouchUi()is the correct gate, and the width gate was masking the effect on narrow desktop windows. #bg-visualshidden by input modality, not viewport width. public/404.html and src/styles/global.css flipped@media (max-width: 800px) { #bg-visuals: none }to@media (hover: none) and (pointer: coarse). iPad-landscape (~1024px wide, pure touch) was paying for parallax it can't trigger; narrow desktop windows now keep their visuals.- Background-emoji set refresh. index.html, public/404.html, src/main.ts: converter set drops ABC-letters and lightning for palette + package; PDF-editor set replaced printer / lock / ruler with bookmark / watermark drop / notebook (more on-the-nose for the tools).
- Long URLs in docs wrap. src/styles/docs.css
#doc-body againsoverflow-wrap: anywhere. A long unbroken URL in CONTRIBUTING.md was overflowing the doc body on narrow viewports.
Security
- HAR handler hardening. src/handlers/har.ts
sanitizeZipPathstrips..segments (both literal and percent-encoded), drive letters, and absolute-path prefixes before passing entry names to JSZip. Zip-slip surface neutralised. Also a 250 MB input cap so a pathological DevTools capture can't lock the worker onJSON.parse. Unit coverage at src/handlers/har.test.ts. - Service-worker share-target pre-parse cap. src/pwa/sw.ts rejects multipart POSTs whose
content-lengthexceeds 110% ofSHARE_TARGET_MAX_TOTAL_BYTESbefore callingformData(). The post-parse counter still runs for streams without a content-length header; this just prevents a multi-GB share from OOMing the SW on low-RAM phones before the per-byte loop has any chance to reject. - CSP-Report-Only baseline. public/_headers adds
Content-Security-Policy-Report-Onlyto production deploys. No enforce, no breakage. Surfaces inline-script and unexpected connect-src violations in browser DevTools so the eventual flip to enforced can be planned with eyes open.
Internal
ensureHandlerInitrace-fix. src/workers/handlerInit.ts wrapsFormatHandler.init()in a WeakMap-keyed in-flight promise. Concurrent callers (two routes both warming the same handler) now share one init; failures clear the cache so retry is possible. Replaces three inlineif (!handler.ready) await handler.init()sites in src/workers/conversion.worker.ts. Tests at src/workers/handlerInit.test.ts.- SUBMODULES.md lists all nine vendored submodules under
src/handlers/with pinned commit SHAs, upstream URLs, and a last-reviewed column (locked to 2026-05-13). Plus a short audit checklist for future updates. fenToJsonhandler test at src/handlers/fenToJson.test.ts closes the round-trip coverage gap.- Format cache refreshed. public/cache.json regenerated to match the current handler registrations (post-v2.3.2 additions were not yet baked in).
Docs
- ARCHITECTURE.md cross-links the PWA-entry-points and Session-persistence sections so the two "load files into the app" surfaces aren't conflated. New "Browser bridge" plus "Cancellation" subsections under MCP/REST.
- CONTRIBUTING.md gains a five-line directory cheatsheet so new contributors don't have to crack ARCHITECTURE for first-touch directories.
- CONVERTER.md splits Install / Entry points / Offline. iOS share-target caveat called out: iOS Safari's Web Share Target is limited, depending on iOS version the share menu may launch frogConvert without auto-loading the file.
- DEPLOYMENT.md cache section rewritten. Pre-built
public/cache.jsonis the production path (Docker, Netlify, Electron); manual capture viaprintSupportedFormatCache()is supported but rarely needed. - INTEGRATIONS.md corrects the MCP tool count (6 → 7; the four PDF tools are merge / organize / extract / watermark) and documents the REST API's loopback-only binding with the Origin/Host check (DNS-rebinding defense).
- PDF_EDITOR.md Extract folded into the Organize section (Extract is a sub-mode of the Organize tab, not its own tab). Output description names the watermark "combined vs zip" choice.
[2.3.5] - 2026-05-12
Restores the background-emoji unblur-on-cursor effect that's been gone since the a11y pass back in 781f9c9. Intended to land as part of v2.3.4 but branch protection blocks force-pushing the release commit, so it ships as a patch.
Fixed
- Background emojis sharpen near the cursor again. The
#bg-visuals span:hoverrule that unblurred emojis under the pointer was deleted in781f9c9because those spans had to flip topointer-events: noneto stop swallowing real clicks - once they were unhoverable, the CSS:hoverroute was dead. src/components/AmbientBackground/AmbientBackground.ts now drives the unblur from JS using thedistvalue already computed for parallax: spans within 140px of the smoothed cursor lerp fromblur(12px)/opacity:0.22towardblur(0)/opacity:0.6. The CSStransition: filter, opacityon src/styles/global.css was dropped - per-frame JS writes would lag-chase a 0.5s transition, and the parallax smoothing already supplies the easing.
[2.3.4] - 2026-05-12
Three UI alignment fixes after on-device review: the PDF editor's content arrival now mirrors the converter card's slide-up instead of popping in, the PWA "Reload now" pill no longer ships with the browser's default 3D button bevel, and the docs theme toggle uses an actual SVG icon instead of a Unicode glyph that sat off-center inside its button.
Fixed
- PDF tool content arrives with an entrance, not a pop. src/components/PdfWorkspace/PdfWorkspace.ts now adds a
.ws-content-enterclass to the layout root each render function mounts (empty dropzone, merge left+right, organize left+right, watermark left+right). The outer#pdf-tool-content.entrance.d5already fires during the page cascade but the tool UI is lazy-loaded into it afterwards, so the slide-up played on an empty container and content then appeared with no animation, most visibly on mobile where the chunk-load gap is widest. AshouldEnter(signature)gate keeps the slide from replaying on in-place updates (adding a file, toggling a watermark setting) so only tool switches and empty↔populated transitions animate. New keyframe in src/components/PdfWorkspace/PdfWorkspace.css reuses the globalslideUpsoprefers-reduced-motionneutralizes it via the existing*gate in src/styles/global.css. - PWA "Reload now" banner button no longer has a default browser bevel. src/components/ConvertCard/ConvertCard.css
.convert-notice-linkwas set to<button>in src/pwa/registerSW.ts when the update prompt was extracted from inline styles, but never reset the browser's defaultoutsetbutton border. That painted as darker arcs at the top-left and bottom-right of the pill - visible on dark mode in particular. Addedborder: noneplus an explicitcursor: pointer(now that it's a<button>and not an<a>). - Docs theme toggle icon centers in its button. src/docs/theme.ts was writing the Unicode glyphs
☼(U+263C) and☽(U+263D) into#theme-toggleviatextContent. The fallback font's glyph metrics for those characters put the visible shape in the upper half of the em-box, so even with the button flex-centered the icon read as drifting toward the top. Switched to inline Lucide SVGs (Icons.moon/Icons.sun, added to src/components/icons.ts) so the icon is geometrically centered like every other icon-only control. The HTML fallback in docs/index.html keeps☾for the no-JS case.
[2.3.3] - 2026-05-12
Lighthouse audit cleanup plus a cold-start splash so the page no longer flashes blank while phase-2 handlers are still downloading. Source maps reach DevTools, agent-readable docs are honest, and the markdown URLs advertised to crawlers actually serve markdown.
Added
- Cold-start splash overlay. index.html ships an inline
#cold-start-splashdiv (frog logo + title + indeterminate progress bar) styled via an inline<style>block so it renders before any JS or CSS chunk arrives. The overlay is dismissed when src/main.ts addsbody.app-readyat the end of the loading sequence, with a 15s safety-netsetTimeoutso a boot crash can't lock the user on the splash. The existing FOUC gate moved off inlinestyle.cssTextmutations tohtml.app-loading/html.app-revealedclasses for cleaner cascade. - public/llms.txt per the llmstxt.org spec: H1 title, blockquote summary, link sections for project / user docs / contributor docs / crawler policy. Every URL points at
/docs/*.md(verified to servetext/markdown) instead of the root-level paths that return HTML in production. Fixes Lighthouse'sllms-txtfailure (Agentic Browsing 67 → 100).
Fixed
- Source maps now resolve in DevTools. vite.config.js was building with
sourcemap: 'hidden'-.mapfiles were emitted but the//# sourceMappingURL=comment was stripped from every chunk, so DevTools couldn't auto-load them and Lighthouse'svalid-source-mapsaudit failed. Flipped tosourcemap: true. No bundle-size delta (maps were already on disk), no privacy delta (public repo). - Root-level
/README.md,/CHANGELOG.md,/SECURITY.md,/AGENTS.mdno longer 404-as-HTML. Those URLs were advertised in public/robots.txt but the Netlify SPA fallback was servingtext/html(the converter page) at them; markdown crawlers got the index page instead of the docs. Re-pointed therobots.txtcomments at/docs/<file>.md(whereviteStaticCopyactually mirrors root markdown via{ src: "*.md", dest: "docs" }). LICENSEnow ships indist/. TheviteStaticCopyglob*.mddidn't match becauseLICENSEhas no extension. Added{ src: "LICENSE", dest: "docs" }in vite.config.js sohttps://frogconvert.xyz/docs/LICENSEresolves.#loading-barrespects iOS safe-area inset. src/styles/global.css fixed-position bar was sitting attop: 0, ending up under the notch / Dynamic Island on iOS PWAs. Switched totop: env(safe-area-inset-top, 0px)..convert-noticemobile layout switches at the right breakpoint. src/components/ConvertCard/ConvertCard.css was vertical-stacking notices only below 600px, butMOBILE_BREAKPOINTis 800px everywhere else. Aligned to 800px.
Changed
- PWA update banner moved off inline styles. src/pwa/registerSW.ts was setting
position: fixed; bottom: 1rem; right: 1rem; ...vianotice.style.cssText. Extracted to.convert-notice-pwa-updatein src/components/ConvertCard/ConvertCard.css withenv(safe-area-inset-bottom)for notch handling and aslide-up-fadeentrance. - Docs topbar controls now use the
--control-sizetoken. src/styles/docs.css.topbar-btn,#theme-toggle,#nav-togglemigrated off hardcoded2remheights/widths.--topbar-hrecomputed from the same token. Addedline-height: var(--leading-none)so glyph-only buttons don't drift vertically. Drops the duplicate mobile padding rule.
Internal
.page-titlemobile sizing. src/styles/global.css bumps the title to2.25remwith extra top margin at ≤800px; src/components/TopBar/TopBar.css keeps the ≤400px font-size override but drops the now-redundant margin rules. Cleaner cascade, no visual change at supported widths.
[2.3.2] - 2026-05-09
Three quiet defects fixed: a missing icon on the Electron build, a manifest.webmanifest 404 firing on every dev / E2E / desktop session, and uncaught THREE.WebGLRenderer failures on hardware-acceleration-disabled environments.
Fixed
- Electron app icon.
electron-builderwas building NSIS / DMG / AppImage artifacts with the default Electron icon -package.jsonbuildblock had noiconfield. Added"icon": "public/icon-512.png"(cross-platform; electron-builder generates per-target sizes from the 512 PNG).BrowserWindowin src/electron.cjs also now passesicon:so the running window - taskbar on Linux, window-frame on Windows - shows the frog instead of the default Electron logo. Manifest fetch from .../manifest.webmanifest failed, code 404. index.html hardcoded<link rel="manifest" href="/manifest.webmanifest">, butvite-plugin-pwais only active for the production-web build (gated off for desktop via!isDesktopBuild, off for dev viadevOptions.enabled: false). So dev, Puppeteer E2E (vite createServerrandom port) and the Electron desktop build all hit a 404 on every page load. The static link is now removed;vite-plugin-pwaalready injects a<link rel="manifest">automatically during the production-web build, and emits nothing in the gated-off paths - so the link only ships when the file actually does.THREE.WebGLRenderer: A WebGL context could not be createdon systems where ANGLE falls back to the Microsoft Basic Render Driver (CI runners, VMs, RDP,--disable-gpuChromium). Three handlers (threejs.ts,sppd.ts,bsor/renderer.ts) instantiatedWebGLRendererwith no probe and no try/catch; three.js then logged its three internal errors and threw uncaught. New shared bootstrap src/handlers/_webgl.ts pre-flights with a cheapcanvas.getContext('webgl2'||'webgl')probe and wraps the constructor - failures surface as a single, actionable error ("WebGL is not available… enable hardware acceleration") through the normal conversion-error channel.
Privacy
- No more Google Fonts. index.html, public/404.html, docs/index.html, and docs/slidedeck.html had a
<link rel="preconnect">plus a parallel<link rel="stylesheet">tofonts.googleapis.com, loaded on every page visit. Inter is already bundled via@fontsource-variable/inter(imported in src/styles/global.css) and the system-font fallback chain in--font-sanscovers any load failure. The Google Fonts links were dropped entirely. No more font-CDN referer/IP leak. - SECURITY.md rewritten to reflect the actual behavior: MCP runs over stdio (not a port), no font CDN, no SLA / ack-within-X-days commitments. Hobby project, single maintainer.
Internal
- Unit test src/handlers/_webgl.test.ts covers the probe-fail, constructor-throw, and happy paths so the WebGL fallback can't silently regress.
[2.3.1] - 2026-05-09
Internal polish on top of v2.3.0: design tokens consolidated at :root, unified :focus-visible contract across every interactive surface, and a small Organize-view trim.
Changed
- "Add blank page" trailing card removed from Organize. It was a literal duplicate of the existing
ws-page-insert-trailing+button that already inserts a blank page at the end. Same handler (insertBlankPage(pages.length)), same affordance - the second card was dead UI. :focus-visiblerings unified onto a single contract.outline: 2px solid var(--primary); outline-offset: 2px; box-shadow: noneacrossicon-btn,cat-tab,format-option,pill-option,btn-primary,btn-secondary,btn-tertiary,ws-btn,close-btn*,pagination-btn,ws-page-card,ws-file-card,ws-wm-slider,floating-card-surface,toolbar-primary. Drops the double-shadow ring; outline-only respects forced-colors mode.--touch-targettoken (2.75rem) added at:root..close-btn-mdbumped 2.5rem → 2.75rem under(any-pointer: coarse)to hit WCAG 2.5.5.- Resume popup copy simplified.
N PDF · M pages, drops the "Undo history will reset" and "custom watermark" hints. The summary already conveys the load on next render.
Internal
- Design tokens consolidated at
:root. New--rainbow-gradient(single source for the selected-state ::before border),--transition-fast/normal/slow/spring,--ease-out-expo, full--z-*scale (z-base…z-skip-link), and--bp-*breakpoint references. Rename rather than restack - zero visual change. 11 component CSS files migrated off hardcoded durations and z-indices. - PdfWorkspace class-name pruning.
.ws-file-add/.ws-page-addremoved;.ws-dropzone(fromcreateDropzone) carries the drop-target visual;.ws-file-card/.ws-page-cardcarry grid-cell shape. Two classes compose without an add-only third. Sortabledraggableselector and click-delegation guard updated to match. - Mobile toolbar primary buttons (Merge, Watermark export, Extract, Export PDF) inherit the unified focus ring via
.toolbar-primary. floating-card-surfaceremoved fromws-page-plus- the per-card plus-buttons live on top of the page card and don't need their own surface.
[2.3.0] - 2026-05-08
frogConvert is now an installable Progressive Web App with offline support and resumable sessions. Drop a file, close the tab, come back - your work is offered back to you. Share files into frogConvert from the OS share sheet or "Open with…" menu. Conversion handlers and assets cache as you use them so repeat conversions work offline.
Added
- Service worker and Web App Manifest. Install prompt on Chromium / Edge / Safari; standalone display; iOS apple-touch-icon and status-bar styling; Android adaptive icons (maskable). Offline-ready toast on first install, dismissable update banner when a new version is available - never silent skipWaiting (
registerType: 'prompt'). - Web Share Target. A POST handler in src/pwa/sw.ts accepts multipart shares from the OS share sheet, writes the payload to a dedicated CacheStorage entry, and redirects to
/?share-target=ready. The page replays from cache and routes files into the Converter or PDF Editor based on file type. Capped at 25 files / 500 MB total per share. - File handlers (
launchQueue). "Open with frogConvert" registers for image / video / audio / PDF / text / ZIP / 7z extensions; files arrive via the sameEXTERNAL_FILES_EVENTpath as share-target. - Resume prompt. Cold-start with a saved session of the same kind shows a "Resume your last conversion?" / "Resume your PDF workspace?" popup. Same-tab reload silently restores instead. Tab-clone (Chrome "Duplicate tab") detected via BroadcastChannel and routed to the orphan path so two tabs never last-write-win on the same sessionId.
- Session persistence - Converter. Files, target format, page selection survive tab close, browser restart, and accidental mode-switches. Bytes round-trip as
Uint8Arraythrough IndexedDB. - Session persistence - PDF Editor. Files, active tool tab (Merge / Organize / Watermark), page selection, watermark settings (text, size, color, opacity, rotation, repeat-mode, page range) all persist across reload.
- Cache-size helpers.
getTotalCacheBytes,clearAllCaches,formatCacheBytes,sumCacheBytesin src/pwa/cacheControls.ts - wiring for a future Settings affordance.
Changed
- Mobile category filter auto-resets on entering
(max-width: 800px). The category strip is hidden on mobile, so leaving an active category set silently filtered the format list with no way to clear it. Thechangelistener now resets the filter when crossing into the mobile breakpoint. - Top-bar control icons redrawn on a unified 16×16 grid (
.top-control-iconclass) for visual parity across mode / theme / app-mode toggles. Theme toggle gained a proper SVG moon glyph in place of the☼codepoint, which rendered inconsistently across fonts. apple-touch-iconnow points at/apple-touch-icon-180.pnginstead of the favicon, so iOS home-screen installs get a real 180×180 icon instead of an upscaled 32×32 favicon.- Documentation pass. README headline bumped, ARCHITECTURE gained PWA + persistence sections, CLAUDE.md file map covers the new directories, CONVERTER and PDF_EDITOR mention Install / Share / Resume, DEPLOYMENT documents the SW serving headers, SECURITY notes the local CacheStorage footprint.
Fixed
- Watermark flat-page list desync after file mutations.
wmFlatPageswas rebuilt only on tab activation; removing a file from the sidebar while the Watermark tab was inactive left a stale flat-index map.onFilesMutated()now callswmSyncWithFiles()first so the next render sees a consistent view. - Centralised dirty tracking in PdfWorkspace. Per-mutation-site
markDirtycalls were drifting (some paths missed manifest-only updates after reorder). All file/state mutations now route through the shared mutation hook, so a save is never missed.
Internal
src/pwa/- service worker, registration, share-target replay, cache controls, constants. Workbox runtime caches: CacheFirst for/wasm/(30 entries, 7-day TTL, status 200 only - opaque cross-origin entries rejected), StaleWhileRevalidate for/assets/(200 entries, 30-day TTL),/js/,/docs/*.md. NavigationRoute precaches/index.htmlwith a denylist for/api,/.well-known,/docs,/headless. JS chunks runtime-cached, not precached, so install isn't a 17 MB download.- Custom share-target fetch listener installed before Workbox's
registerRoute. A multipart POST to/hasrequest.mode === "navigate"and would otherwise be eaten by the precached/index.htmlNavigationRoute. Order is load-bearing. src/components/persistence/- IndexedDB-backed session store (two stores:sessionskeyed by sessionId,fileByteskeyed by<sessionId>:<fileId>), genericcreatePersistorfactory, Converter-specific wiring. PDF Workspace inlines the same factory at src/components/PdfWorkspace/PdfWorkspace.ts.- Manifest-last write order. Bytes write before manifest, so a tab kill mid-flush leaves a stale manifest pointing only at fileIds whose bytes already landed - never a manifest referencing unwritten bytes. Quota-exceeded errors pause autosave with a single warning toast; non-quota errors (missing file, serialization) skip the id and continue.
bumpNextFileIdin src/tools/types.ts so restored sessions don't collide with fresh file ids minted in the same browser session.- Build-time PWA wiring. vite.config.js gains
vite-plugin-pwa(injectManifeststrategy,srcDir: 'src/pwa',globPatternsprecaches HTML/CSS/icons/fonts only). Disabled for desktop builds (!isDesktopBuild) since Electron runs fromapp://where a service worker is both useless and a registration footgun. - nginx + Netlify:
Service-Worker-Allowed: /on/sw.js; no-cache on/sw.jsand entry HTMLs (/index.html,/docs/index.html,/headless/index.html); immutable 1y on/wasm/*; correctapplication/manifest+jsonfor/manifest.webmanifest. - Tests. New unit suites:
registerSW.test.ts(env gating: Electron / file-protocol / no-window skip),shareTarget.test.ts(cache replay +launchQueueconsumer),cacheControls.test.ts(byte formatting + sum),sessionStore.test.ts,createPersistor.test.ts(dirty tracking, manifest-last invariant, quota pause). - Dependencies.
vite-plugin-pwa ^1.3.0,workbox-window ^7.4.1. Workbox runtime modules pulled transitively.
[2.2.1] - 2026-05-07
Audit-driven patch release. Three Critical-class data-loss paths closed, mobile-first touch and a11y sweep across both routes, watermark preview rebuilt on a synchronous bitmap cache, and power-user keyboard productivity in the PDF Editor and Format modal.
Fixed
- App-mode switch no longer destroys PDF workspace state. Toggling between Converter and PDF Editor used to call
resetAll()on the workspace, wiping loaded files, page reorder, watermark settings, and the undo history. Users who organized a long PDF and tapped the mode toggle by mistake (or to glance at the converter copy) returned to an empty workspace with no recovery. The mode-out path now callscleanup()instead - DOM listeners and the body-mounted toolbar/tray are torn down, but module state is preserved.initPdfWorkspace()re-renders on subsequent calls so coming back remounts the UI on the existing data. - Success popup no longer eats your file when closed early. The post-conversion popup launched a
setTimeout(downloadAllConvertedFiles, 400)gated onpopupBox.classList.contains("open"). Fast-clickers who tapped Done before 400 ms got confetti but no download. Blob URLs are independent of popup lifetime, so the guard was dropping the file for no reason. Removed; downloads now fire unconditionally. Confetti stays popup-anchored. - Files modal no longer replaces your file list when you drop on its background. Drops anywhere on the modal except the inner Drop more PDFs zone bubbled to UploadZone's window-level handler, which silently called
proceedWithFiles()and replacedcurrentFiles. Capture-phasedragover/droplisteners on the modal element now claim drops while open and route toaddMoreFiles(). - Mobile last grid row no longer hidden behind the fixed toolbar.
.ws-grid-cardpadding-bottomrecomputed viavar(--space-12) + var(--space-6) + var(--space-3) + env(safe-area-inset-bottom)(single-row toolbar) and+ var(--space-12) + var(--space-4)more for the Organize two-row variant, so the last row of thumbnails has 20 px of breathing room above the floating toolbar. - Mascot apology removed from Safari PDF error popup. The Safari-specific error message ended with
Frogsworth is sorry, which violated the CLAUDE.md "no mascot catchphrases" rule inside a critical-error popup. The message already names the escape route (Chrome / Firefox); the kaomoji was noise. - Em dash in
showDetectedFormatcopy replaced with a comma per the project copy rule (no em dashes in user-facing strings). - Files modal
.file-rowno longer pretends to be clickable.cursor: pointerwas set without a row-level click handler - only inner buttons were interactive. Pointer cursor dropped. .popup-actionsvs.popup-actions-footerinconsistency.showSizeWarningPopupmigrated from the legacy ad-hoc class to the shared.popup-actions-footerso size-warning, success, and error popups render their action rows identically.
Added
- Ctrl/Cmd+Click for non-contiguous page selection in the PDF Editor's Organize tab.
toggleSelection()takes a thirdctrlflag that explicitly toggles the clicked page and overrides Shift, matching the Windows / macOS multi-select convention so power users can pick or unpick a single page without disturbing a Shift range. Plain click and Shift+Click behavior unchanged. - Redo (Ctrl+Y / Ctrl+Shift+Z) in the PDF Editor. A 30-snapshot redo stack runs alongside the existing undo history. New mutating actions clear the redo branch (same convention as code editors and image tools).
cleanup()andresetAll()clear both stacks. - Arrow-key navigation across the Format modal options. ↓ from the search input pulls focus into the first visible option; ↑ from the first option pulls focus back into search. ↑/↓/Home/End move within the option list. Saves keyboard users ~70 Tab presses to reach the bottom of the All Formats list.
- Arrow-key navigation across the PDF Editor tab bar. Arrow Left / Right / Home / End move focus between Merge / Organize / Watermark inside the new tablist.
- Move ▲ / ▼ buttons in the PDF mobile tray. Touch users couldn't reorder pages because the long-press drag fought page scrolling and the move-row was hidden behind the desktop-only
body.ws-keyboard-mode. The tray now exposes Move up / Move down buttons that reuse the existingmoveSelection(), giving touch users a non-drag reorder path. - Mobile dismiss button on toasts. Toasts had click-to-dismiss but no announced affordance for screen-reader or keyboard users; a real
× Dismissbutton now lives inside every toast witharia-label="Dismiss". - Skip-link for keyboard users. The first Tab from the address bar now reveals a visible "Skip to content" link that jumps to
<main>, saving the previous ~10 Tab stops through nav controls.
Mobile
- Touch-target sweep across the app under
@media (any-pointer: coarse).--control-sizebumped from 36 px to 44 px (WCAG 2.5.5),.icon-btn,.close-btn-md,.close-btn-lg,.pagination-btn, the Files modal "Replace all"/"Remove all" buttons,.cat-tabrows,.format-optionrows, the Watermark Customize summary, and the watermark slider hit area all hit 44 × 44.(any-pointer: coarse)was chosen over(pointer: coarse)so hybrid touch laptops get touch-density even when a mouse is also present. - iOS focus-zoom killed without scaling the type system. Inputs receive a surgical
font-size: 16pxunder(any-pointer: coarse)that prevents Safari from zooming on focus. The 13 px--text-basetoken stays untouched, so the design scale is unchanged. - Watermark slider hit area extended. Slider track stays 4 px tall but the input element's hit area now spans 44 px so finger-drag on opacity / rotation actually works.
- UploadZone file-info row wraps actions to a second line on touch so the three icon buttons (manage / replace / remove) never crowd the filename.
- Mobile toolbar tracks the virtual keyboard. A
visualViewportlistener writes--kb-offsetto the document element and.ws-toolbar { bottom: ... + var(--kb-offset) }slides the Export button above the on-screen keyboard. The Watermark text input no longer hides Export behind the keyboard. - Watermark input quick-flow + empty-text passthrough - typing nothing no longer blocks export; the source PDFs are saved unchanged.
Accessibility
prefers-reduced-motionis now respected app-wide. A global CSS gate caps every animation and transition to 0.01 ms. The AmbientBackground parallax loop has a parallel JS guard since inline-style writes bypass the CSS gate. Bg-emoji floats, frog-pulse, ws-shimmer, ws-spin, dot-pulse, files-error-slide-in, and the entrance animations all stop when the system pref is on.:focus-visiblerings on every affordance that strips outline elsewhere..icon-btn,.cat-tab,.format-option,.pill-option,.btn-primary,.btn-secondary,.ws-btn,.close-btn,.pagination-btn,.ws-page-card,.ws-file-card,.ws-wm-slider- keyboard users now see a 2 px primary ring (with 2 px offset) on focus.- PDF Editor tab bar marked as a
role="tablist"withrole="tab"+aria-selected+aria-controlsper button and rovingtabindex. The active tab's id flows intoaria-labelledbyon the tabpanel. Screen readers announce "tab, 2 of 3, Organize, selected" instead of three loose buttons. - Page cards and file cards moved to
role="button"+aria-pressed+tabindex=0+aria-label(e.g. "Page 5 of 12, not pressed"). Selection state is now announced; the previous mix ofaria-checkedwithout a matching role was inert for AT. - Watermark
.ws-wm-statusgetsaria-live="polite"so SR users hear export progress and validation states. - Toast role / live-region differs by variant.
variant-errorusesrole="alert"+aria-live="assertive"; info/warn userole="status"+aria-live="polite". Severity is also conveyed beyond color:⚠icon prefix on warn / error variants (WCAG 1.4.1). - Mobile menu marked as a
role="dialog" aria-modal="true"with focus trap. Tab and Shift+Tab cycle within the menu, Escape closes, focus restores to the hamburger button. The hamburger'saria-expandednow flips with menu state. - Light-mode
--muted-foregroundbumped from#71717ato#5f5f6aso 11 / 12 px muted text passes WCAG AA (≥ 4.5 contrast). - Headlines selectable.
.page-title,.page-description, and.footer-textshedpointer-events: none(z-index already separates them from#bg-visuals). - Background-emoji mouse-trap killed.
#bg-visuals spanflipped topointer-events: none; emojis no longer steal mouse events from interactive content under them. - Native I-beam restored on text inputs while the custom cursor is active.
html.custom-cursor-active *setcursor: none !important, hiding the I-beam from<input type="text">and<textarea>. A targeted override under(pointer: fine)brings it back.
Performance
- Watermark preview rebuilt on a synchronous bitmap cache. The previous URL cache + 250 ms debounce timer is gone. Each page is now rendered once via pdfjs (lazy, on intersection-observer entry) into an
ImageBitmap, and every settings change composites that cached bitmap with a Canvas 2D watermark overlay synchronously on the next animation frame. No PDF round-trip per slider tick. LRU-bounded at 200 entries (~45 MB ceiling). Slider drag is now smooth instead of stuttering. - TopBar scroll listener rAF-coalesced so
.scrolledclass toggles fire at most once per frame instead of per scroll event (40-100× reduction in style recalcs on fast scroll).
Internal
--button-surfacetoken added to the design system: light theme maps to--secondary, dark theme overrides to--card. ~25 button definitions across 9 components consolidate onto the single token, removing the.dark .btn-secondaryoverride cascade. Top-bar buttons opt out and bind directly to--cardso they always match the base card surface.cleanup()exported from PdfWorkspace for app-mode switches that should preserve module state.- Build hardening. Puppeteer timeouts lengthened in the cache-build script and on-failure error surfacing so cold-start cache rebuilds don't fail silently in CI.
[2.2.0] - 2026-05-07
Watermark tab for the PDF editor, plus a sweep of accessibility fixes across the workspace.
Added
- Watermark tab in the PDF Editor: Stamp a text watermark on all pages or a custom range like
1-3, 8, 10-12. Style controls: size, color (hex + swatch), opacity, rotation. Toggle Repeat across page to tile the watermark with internally-computed spacing. Live preview reflects the actual export and reserves aspect-ratio so the page renders instantly without layout shift. Helvetica-only text with character-set validation. Available in the UI, MCP (pdf_watermark), and REST (POST /pdf/watermark). - Shared sidebar primitives in
PdfWorkspace.ts(makeSidebarFileRow,makeSidebarDivider,makeSectionLabel) so Merge / Organize / Watermark render the file row and divider markup from one source.
Changed
- Watermark UI unified with Merge/Organize: same active-file row at the top of the sidebar, same Select all / Deselect all pattern, same sticky-bottom mobile toolbar + tray drawer.
- Watermark MCP/REST surface narrowed to text-only:
sourcediscriminator andplacementfield removed frompdf_watermarkandPOST /pdf/watermark.text,fontSize,colorHexare now top-level fields; placement is always center. Image-source watermarks have been removed from the public API to match the UI. - Watermark UI defaults aligned with engine: the workspace now derives
fontSize(80) andopacity(0.5) fromWATERMARK_DEFAULTSin src/tools/pdfWatermark.ts instead of holding its own values (previously64/0.2). UI, MCP (pdf_watermark), and REST (POST /pdf/watermark) defaults are now identical.
Fixed
- Combined-mode watermark output filename:
doWatermarkExportCombinedno longer double-suffixes (e.g.report_watermarked_watermarked.pdf→report_watermarked.pdf). Now reusesmerge()from src/tools/pdfMerge.ts instead of an inlinePDFDocument.create()loop.
Accessibility
- Watermark tab is now keyboard- and screen-reader accessible:
- Page cards are tabbable (
tabindex=0), have programmatic names (Page A1,Page B3, etc.), and toggle onSpace/Enter(matching the Organize tab). - Sliders (
Size,Opacity,Rotation) gained a thumb-bound:focus-visiblering (the previousoutline: noneleft keyboard users with no visible focus indicator - WCAG 2.4.7). - Inputs that surface error states (
Watermark text,Color hex,Page range) now togglearia-invalidalongside the existing red border. The text input is wired to its error message viaaria-describedbyso screen readers announce why the input is invalid. - The disabled
Export PDFbutton is wired viaaria-describedbyto its status paragraph, so AT users hear why it's disabled (e.g. "Pick at least one page"). - The
Colorrow is now arole="group"labelled by the visibleColortext, tying the hex field and swatch together for AT. - Visible labels (
Text,Size,Color, etc.) link to their inputs viaaria-labelledby, eliminating drift between visible and announced names.
- Page cards are tabbable (
- PDF Workspace: cross-tab a11y improvements:
- The mobile More options tray is now a proper
role="dialog"with an accessible name, anEscapeclose handler; focus moves into the tray on open and returns to the trigger on close. - Drop-zone "Add more PDFs" cards are now keyboard-activatable (
role="button",tabindex=0,Space/Enter), with a visible:focus-visiblering. - Page cards across all tabs gained an on-brand
:focus-visiblering. - The internal
el()helper now routesroleand ARIA attributes viasetAttribute, so the workspace no longer relies on ARIAMixin IDL reflection (patchy in older Firefox/Safari and jsdom).
- The mobile More options tray is now a proper
Performance
- Watermark preview: lazy-render observer unobserves cards after first paint (subsequent re-renders go through
wmKickVisibledirectly), and the Helvetica encode probe is memoized per-text so a 300-page grid runsfont.encodeText()once per text change instead of once per visible card.
[2.1.3] - 2026-05-04
Error-copy normalization, quality-resolution unification, and palette-PNG encoding.
Added
- Unified error copy via
toUserErrorText: Worker crashes, password-protected files, parse failures, timeouts, and empty-output errors now map to consistent friendly messages across UI, REST API, and MCP. Title constants shared fromsrc/components/utils/index.ts. - PDF feedback contact line: PDF Workspace and
pdf_*MCP tools //pdf/*API surface "Still stuck, or want to share feedback? Email francois.prevot@frog.co." for non-validation failures, distinct from the format-request line on the converter side. resolveEffectiveQuality(src/core/compression/resolveEffectiveQuality.ts): API/MCP requests now match the web UI's silent same-format auto-tier-down. Cross-format requests fall back tomedium; same-format requests probe the input and pick the next lower tier; already-minimal inputs return unchanged.- Palette-PNG encoding (
src/tools/palettePng.ts): UPNG-based indexed-palette PNG encoder.pdftoimg.tsandcanvasToBlob.tsuse it at low/medium presets for document-like inputs (~3–5× smaller deflate at indistinguishable visual quality). ValidationErrorinsrc/mcp/core/fileInput.ts: tagged class for caller-supplied input failures. API/MCP catch-alls surface its message verbatim; everything else flows through the friendly normalizer.
Changed
- Deeper theme contrast: Dark-mode background
#0a0a0a→#000000with card#141414→#0a0a0a. Light-mode card#ffffff→#fdfdfdfor subtle separation from the page background. - Removed "in frogConvert" phrasing: "Not in the converter yet" → "Conversion not available yet"; "isn't in frogConvert yet" → "isn't available yet". Applied across UI, REST
/pathand/convert, MCPfind_conversion_pathandconvert_file, and the format modal's no-outputs message. - Sharpened unreadable-file copy: "Another copy might work" → "Try re-exporting it or uploading a fresh copy."
- Worker-crash detail: "The conversion stumbled while processing this file." → "The converter crashed while processing this file."
[2.1.2] - 2026-04-29
More PDF routes via LibreOffice.
Added
- LibreOffice now accepts HTML, RTF, TXT, CSV, and EPUB inputs: Unlocks alternative PDF routes such as
md → html → pdfalongside the existingmd → docx → pdf, plus directtxt → pdf,rtf → pdf,csv → pdf,html → pdf, andepub → pdfwhen LibreOffice is available (native binary or localhost API).
[2.1.1] - 2026-04-22
Audio-to-video uploadability and phase-aware progress UI.
Fixed
- Audio → video produces a real video stream: MP3 → MP4 (and MOV, MKV, M4V, AVI, FLV, TS, MTS, WebM) now embed a bundled placeholder frame so the output is accepted by YouTube and similar platforms. Previously the container held an audio track only.
Changed
- Phase-aware spinner: The pathfinding, WASM handler download, and file-reading phases now show the plain rotating spinner. The gooey spinner stays for the actual encode/compress phase so the UI reflects what the app is really doing.
[2.1.0] - 2026-04-18
Adaptive compression and live conversion feedback.
Added
- Same-format compression: Re-encodes PNG, MP4, MP3, etc. to reduce file size with a 2% safety fallback.
- Compress button: UI automatically switches to "Compress" when a same-format re-encode is detected.
- Size delta reporting: Success popups now show exact megabyte savings and percentage reductions.
- Conversion notices: Detailed cards explain handler adaptations (e.g., resolution caps or codec changes).
- Live progress: Dynamic updates showing elapsed time and handler status for conversions over 10 seconds.
- Honest cancellation: Interrupting a batch now reports exactly which files were finished.
- Adaptive sampling: Video-to-image extraction targets 300 frames based on duration instead of a fixed rate.
Changed
- Archetype-aware quality: Tailored presets for photos (Q90), PDF pages (Q87), and video frames (Q78).
- Proactive codec handling: Skips re-encoding for compatible streams (MP3/AAC/FLAC) and snaps to supported sample rates.
- PDF safeguards: Auto-shrinks documents exceeding browser safety limits (600 MP).
[2.0.0] - 2026-04-17
In-browser PDF Editor, 70+ formats, and security hardening.
Added
- PDF Workspace: Merge, reorder, rotate, and extract pages entirely in-browser using
pdf-libandpdfjs-dist. - Extended Formats: Expanded support to over 70 file formats across all conversion engines.
- Upload UX: Front-load validation with drag-reject feedback and legacy Office format hints.
- Toast component: Accessible, dismissable notifications for info, warnings, and errors.
Stability
- Security Hardening: Origin/Host validation for local API, post-body shape checking, and sandbox constraints.
- Resource Protection: Archive size caps guard against zip-bombs; HTML sanitization prevents network leaks during conversion.
- Cleanup Overhaul: try/finally cleanup for workers, aggressive subprocess termination, and stale temp dir sweeping.
- Recovery System: Global error listeners surface actionable popups instead of leaving the UI stuck.
UX & Performance
- Unified Selection: Standardized tap-to-toggle and shift-click range selection across mobile and desktop.
- Batch Summaries: Detailed modals showing added vs. skipped files with specific rejection reasons.
- MIME Priority: Preferred over filename extensions for more reliable format detection.
- Performance: TraversionGraph lookups optimized from linear time to constant time using a Map.
- Mobile Polish: Two-row PDF toolbar layout with a dynamic kebab tray for better accessibility.
[1.0.x and earlier]
Pre-changelog releases. Notable additions since forking from Convert to it!:
- MCP server and REST API for AI agents (docs/INTEGRATIONS.md).
- Quality presets (low / medium / high / lossless) for FFmpeg, ImageMagick, pdftoimg.
- LibreOffice handler for DOCX/PPTX/XLSX to PDF.
- Soft cancel and partial downloads for batch conversions.
- Format Mode system (Core / Plus / All).
- Frame extraction for animated formats and videos.
- ICO multi-size bundles.
- Web Worker offloading for heavy conversions and route finding.
- Frogsworth mascot.
- Full Vitest + Puppeteer test suite.