VerifyKitv0.13.3

Changelog

All notable changes to VerifyKit will be documented in this file.

The format is based on Keep a Changelog.

[Unreleased]

Website and tooling only — nothing here is inside a published tarball, so it carries no version bump.

Breaking

  • (site) Every operator sign-in has to be set up again from scratch. The operator console used to be opened with a single shared key checked against a hash in the server's environment. It now has real accounts — an address, a password, a role — held in a database, and there is no migration path between the two: a shared secret is not an account and cannot be turned into one. So on the day this ships, every existing session stops working and no account exists yet. Somebody with shell access on the server has to create the first one with the seeding command described in the project's development workflow guide, and further operators are then added from inside the console. There is deliberately no self-service sign-up, so this is the only door in on a fresh database and it cannot be reached from the network. The two environment variables the old scheme read are no longer used and should be removed.

  • (site) Signing in moved to /admin-login. /dashboard/login, which the previous release introduced, permanently redirects there. The reason is the same one that created it: the sign-in form has to live outside the part of the site that is withheld from visitors without a session, and /dashboard/… is now withheld as a whole rather than page by page.

  • (site) The switch that turns the package registry on is off until somebody turns it on. It reads from the database now, and when the database has never been told either way the answer is "off" rather than "on". This is a deliberate reversal. The switch gates downloads of a paid, proprietary SDK, and the two ways of being wrong are not comparable: defaulting to "on" means a fresh or restored database quietly serves every package to anyone who asks, with nothing looking wrong from the outside and no way to un-download what went out; defaulting to "off" means installs fail loudly with a clear status until an operator flips one switch. An access gate whose default is "open" is not really a default — it is a decision to grant access taken by whoever last emptied the table.

Changed

  • (site) The operator console's data moved out of files and into a database. The CDN origin allow-list, the registry switch, sign-in rate-limiting state and the record of who changed what were four different JSON files on the server's disk, read and rewritten in place. They are now four tables, with schema migrations that are applied and verified before the application is allowed to start — a failed migration blocks the deploy rather than leaving a half-migrated database serving traffic. The old JSON files are left where they are as a historical snapshot and are read by nothing; a one-off command carries their contents across, and it now reports failure rather than success when it cannot find them, which is what "there is nothing to migrate" and "you are looking in the wrong place" used to be indistinguishable from.

  • (site) Sign-in rate limiting survives a restart again. Moving to the authentication library's own limiter briefly put the counters in memory, which hands an attacker who can wait out a deploy a fresh budget. They are back on disk — in the database this time, so they also survive the process being replaced rather than merely restarted.

  • (site) The console is a sidebar with four separate pages instead of one page holding everything. Overview, revocation logs, downloads and settings each load their own data, so three of the four no longer pay for reading the ~29 MB revocation log that only one of them displays. Every page carries its own authorisation check rather than relying on the shared frame, because the framework does not re-run a shared frame when you navigate between pages inside it — which would have let a revoked session keep reading panel data until its cookie expired. Sign-out, which had been lost in the rearrangement, is back.

  • (site) The site runs as a container stack. The application, its database, its migrations, its one-off operator commands and a nightly backup are one description that travels with the repository, instead of a process manager on one host plus steps in someone's memory. The database is reachable only from inside that stack and publishes no port at all. The application reports health on its own endpoint that actually touches the database, so "running" and "working" stop being the same word. Container logs are capped rather than growing until the disk fills — the arrangement this replaces had log rotation and losing it was an unnoticed regression.

  • (site) The browser bundle and the verification engine are served from the same place. They are generated together by one release step and are not interchangeable — the JavaScript glue is emitted for one specific binary. One of the two was being frozen into the container image while the other was read live, so publishing a release swapped the engine underneath a running site and left the glue behind; both are cached for an hour, so a customer could have paired the old glue with the new engine for that long. Both now come from the same live location, and the release check compares the bytes actually served against the bytes on disk for each.

Security

  • (site) The whole operator area is withheld, not just its pages. The interface file behind the console is served only to requests carrying a session cookie this site issued, checked by verifying its signature rather than by noticing that a cookie with the right name exists. A request without one is answered exactly as a request for a file that never existed. This survives every spelling of the path we could construct: the check is made on the fully-decoded path, after an earlier version could be stepped around by percent-encoding a single character of it.

  • (site) Changes made in the console are attributed. Adding or removing a CDN origin, flipping the registry switch and deleting logs each record who did it, from what address, and when. Deleting logs additionally requires the admin role, having previously required only a signed-in viewer.

  • (site) Roles fail closed. An account whose role is empty, unknown, or anything the application does not explicitly recognise is refused rather than admitted, and that decision is made before any check that could accidentally pass it.

  • (site) The verification engine no longer fails hard when its allow-list cannot be read. The engine is served to allow-listed origins by adding a CORS header, and computing that header reads a list from the database. If that read failed — a database that has not finished starting, most plausibly after a host reboot — the request errored instead of falling back to this route's own designed refusal, which is simply to send the bytes without the header and let the browser withhold them. Same-origin requests never took that path, so the failure was reachable only by paying customers, and only on a cold cache.

  • (site) The web-facing process no longer has write access to the database dumps. It was given the backup directory and never had a reason to read or write anything in it.

  • (site) The SMTP password behind contact-form delivery is encrypted at rest. It is stored AES-256-GCM-encrypted, keyed by a secret held only in the server's environment, so a stolen database dump — backups/ sits on the same disk as the database itself — yields ciphertext and nothing that decrypts it. Contact submissions are also rate-limited to three per IP per hour, so the enquiry-storage table this release adds cannot be filled by a script faster than an operator could ever read it.

Added

  • (site) Nightly database backups with 30-day retention. A dump per day, written to a staging name and moved into place only after it succeeds, so a failed retry cannot destroy a good dump taken earlier the same day. The restore procedure has been rehearsed against a real dump rather than assumed, and what it does not prove is written down beside it — notably that the dumps sit on the same disk as the database, which makes this "the database can be undone", not disaster recovery.

  • (site) A health endpoint, and checks that fail for the right reasons. The database check now knows about every table every migration creates, not just the first migration's — a stack where the later migrations never applied used to pass green, which is precisely the failure that check exists to notice. The container check asserts that every Markdown file any page declares is actually inside the image, by looking inside it rather than by requesting the page, because those pages are pre-rendered and keep answering correctly from build output after the file behind them has gone missing.

  • (site) Contact-form enquiries are stored in Postgres and listed in the operator console. A submission is durable — inserted — before delivery is ever attempted, so a failing or unconfigured mail server downgrades an enquiry to an Undelivered badge with a Retry button rather than losing it. The new /dashboard/contact panel lists the 200 most recent, security disclosures sorted first and flagged with their own badge, and the new /dashboard/settings/email panel is where SMTP is configured, with a one-click Send test email. The test goes to both the signed-in operator and the configured notification address, and names both, so what it proves is the delivery path real enquiries actually take rather than only that the mail server answered.

  • (site) The console says how many enquiries are waiting undelivered. A count sits on the Contact item in the sidebar from every panel, and the Overview page shows a banner while any are outstanding. Until a mail server is configured, every enquiry — including a vulnerability disclosure — is stored and undelivered, and previously nothing anywhere said so; the panel had to be opened by somebody who already suspected.

  • (site) Enquiries can be deleted from the console. Each one holds a name, an email address, a company, free text a stranger wrote, and an IP address, and there was no way to remove any of it short of hand-written SQL against the live database. Deleting is restricted to administrators and every deletion is recorded in the audit trail. Note that backups keep their own copy until they age out.

  • (site) security.txt now says which topic to choose, and links to the form with it already selected. Whether a vulnerability report is flagged, badged and sorted ahead of sales enquiries depends entirely on the reporter picking "Security vulnerability" from a nine-entry dropdown — and filing a disclosure under "Bug report" was an entirely reasonable thing to do.

Fixed

  • (site) Contact-form submissions were being lost twice over. The route used to console.log a submission and call that success; pm2's log rotation deleted the evidence after fourteen days, so an enquiry that never reached an inbox left no trace anywhere. security.txt also points vulnerability disclosures at this same form, so those were quietly going missing on the same schedule. Every submission is now a durable database row from the moment it is received, independent of whether mail delivery ever succeeds.

  • (site) An enquiry marked "Delivered" now means a message actually went out. Delivery was briefly marked at the moment an attempt started, to stop two operators retrying the same enquiry at once. That closed the double send but opened a worse hole: a server restarted mid-attempt left the enquiry marked delivered, with the Retry button gone, for mail nobody ever received — and nothing in the console indicated it. Marking and locking are now separate, so an interrupted attempt reverts to Undelivered and becomes retryable again on its own a couple of minutes later. The worst case is a duplicate email rather than a disclosure that silently never arrives.

  • (site) A visitor whose computer clock runs fast is no longer silently dropped. The spam filter compared the browser's clock against the server's and treated the difference as time-on-page; a clock running even slightly ahead produced a negative interval, which read as "submitted instantly" — a bot. Those submissions were discarded while the visitor was told "Message sent successfully".

  • (site) A submission is no longer lost without trace if the database refuses it. In that case the visitor correctly sees an error, but the enquiry existed nowhere at all. The fields are now written to the server log first, so there is still a record to recover it from.

Website and docs

  • (site) Code blocks in the docs follow the theme instead of ignoring it. In light mode every docs page rendered github-dark's palette — pale purple and pale blue on a light ground. MdxContent passed a single theme, so shiki baked those colours into the markup and no CSS could recover them; the CSS that does the switching was scoped to .shiki-container, which only the home page's Quick Start uses; and the wrapper had bg-[#111111] hardcoded in both themes, which is why the dark palette had looked tolerable there. Worth recording that the two shiki outputs are shaped differently — codeToHtml puts the light theme in a plain color and only dark in a variable, rehype-pretty-code puts both in variables — so a light rule of var(--shiki-light) applied to both collapses the first to a single inherited colour. Measured across home and five docs pages: 6–9 token colours in both themes, worst-case contrast 4.16 in light against 3.92 in dark, where light had been showing dark's colours.

  • (site) The product screenshots follow the theme too. The viewer has both themes and the site shipped only the dark captures, so a light-mode reader got a near-black slab on a white page. There is now a light frame for the hero and all six on /screenshots. Two <img> switched on the .dark class rather than a <picture> media query, because the theme is a class the reader can toggle independently of the OS. It costs nothing, which was measured rather than assumed: a loading="lazy" image inside display: none is never fetched and an eager one is, so the hero trades priority for lazy on both frames. After: the wrong theme's files are fetched zero times in either mode.

  • A guard on the eight check names. scripts/check-check-names.mjs reads the labels out of check-builders.ts and asserts every name the site lists is one the viewer renders. That drift shipped twice — an "Expiry" check that does not exist as a row, then a caption naming it again directly above a screenshot of the real eight — and a reviewer caught both. Wired into npm run release.

  • (site) The landing page is rebuilt around what the product reports rather than photographs of it. Measured on a 390px phone, the screenshot strip rendered its 2180px viewer captures at 348px — a 6.3x shrink that turned 11px UI text into under two pixels, so all three images became the same grey smudge under captions describing detail the reader could not see. The verdict bar and the eight checks are real markup now, which reads at any width, stays selectable and is indexable; the captures keep their place on /screenshots, which has the room for them. Three sections became one along the way — the strip and the Verification Pipeline were showing the same eight checks, and Technical Highlights turned out to be Features restated, with a fourth card ("Base64-embedded WASM binary") that has been false since the engine became a separate file. Desktop went from 10,464px to about 6,100px, mobile from 9,972 to 7,800, and the page now has four distinct shapes rather than five repetitions of one.

  • (site) Scroll-driven motion, and no JavaScript for it. The eight checks tick in one after another as the pipeline enters view, the hero screenshot flattens out of its tilt as the reader scrolls past it, and section content rises as it arrives. Every rule keeps its start state inside an @supports and prefers-reduced-motion guard, because a browser without animation-timeline would otherwise leave an unguarded opacity: 0 exactly where it is — blank to readers and to crawlers both. The checks container needed overflow-clip rather than overflow-hidden for any of it to work: hidden makes an element a scroll container, and a view() timeline resolves against the nearest one, so the rows were timed against a box that never scrolls.

  • (site) The site named two different sets of eight checks. The viewer reports Signer's Identity and never renders Expiry as its own row — it is an engine field folded into that check — but eight places, including the FAQ's JSON-LD, llms.txt, pricing and the docs index, listed Expiry instead. Publishing screenshots of the real list is what made the contradiction reachable.

  • (site) The dev:demo trap is documented where someone would hit it. dev-workflow.md advised stopping the production server so the dev server could have port 1365 — but both serve from apps/demo/.next, and starting next dev replaces the production build with a dev-mode directory, so BUILD_ID and static/ disappear and every content-hashed chunk the live HTML asks for returns 400. It took the live site down on 2026-08-02. README and CONTRIBUTING carry the same warning beside their dev:demo recommendations.

  • (site) The CDN engine is served only to allow-listed origins. /cdn/* is the one path by which the commercial viewer can be used without going through the private registry, and since the engine became a separate .wasm it was served with Access-Control-Allow-Origin: * — so hotlinking it from any site worked. Enforcement goes on the engine rather than the bundle, and that was measured rather than assumed: <link> and <script src> send no Origin at all, while the engine is fetched with fetch() and always carries one. An unknown origin now receives no ACAO and the browser refuses it; the bundle is inert without an engine, so the JavaScript stays open. The allow-list is the one the operator dashboard already manages, read at request time by a route handler — middleware runs on the Edge runtime and cannot open the file — so adding a customer is an entry rather than a deploy. Two limits worth stating plainly: this stops hotlinking, not self-hosting, since files served from someone else's origin involve no CORS check at all; and removing an origin takes effect only as the engine's one-hour cache expires. It stands in for enforcement until the licence server is out of testing, and is commercial hygiene rather than a licence.

  • (site) Security headers on every page, and an enforcing Content-Security-Policy. middleware.ts had been setting headers on /dashboard for a while; everywhere else had none — no HSTS, no nosniff, no Referrer-Policy, no framing rule. All five are set site-wide now, with X-Frame-Options: SAMEORIGIN rather than DENY (the dashboard keeps its own DENY) and HSTS without preload, that being a one-way door not worth taking on someone else's behalf. The CSP enforces: the obvious default-src 'self' would have broken the demo, since pdf.js fetches its worker from unpkg and wraps it in a blob to run — found by reading the network log rather than by reasoning about it, which is the argument against shipping a policy straight to enforcing. Download, print, attachment export and the docs' Mermaid diagrams build blob URLs too. It went out report-only first and was flipped once every path that could plausibly trip it came back clean: the demo's whole verification pipeline, a docs page rendering two Mermaid diagrams, print's blob: PDF in a hidden iframe, download's blob: URL on an <a download> (which really did download), and a blob: image. Then re-verified with the policy enforcing rather than reporting, since a report-only pass is evidence and not proof — the signed PDF still verifies and the document still renders, meaning the unpkg worker still loads, with no violations. Note that script-src carries 'unsafe-inline' — Next inlines 34 hydration scripts per page and nonces would force dynamic rendering, costing the 49 prerendered pages — so this is not XSS protection; what it constrains (object-src, base-uri, form-action, connectable origins) is real and cheap.

  • The keywords meta tag is gone, and the /og route with it. Google stopped using meta keywords for ranking in 2009 and Bing treats stuffing it as a negative signal; it was on all 49 pages, ten to twenty-five terms each, costing bytes in every response and maintenance whenever a page shifted subject, for nothing any engine reads. The keywords in blog and comparison JSON-LD is a different property -- schema.org Article, which Google does read -- and stays. /og built social cards on demand and had no caller left once the pre-rendered ones landed, so it is deleted; robots.txt loses the Googlebot/bingbot group that existed solely to disallow it, since a group naming a crawler replaces the wildcard for it rather than adding to it, and one kept up to date by hand is a hazard rather than dead weight.

  • The Twitter card stops crediting an account that does not exist. twitter:site and twitter:creator named @trexolab on every page; there is no such account -- confirmed with the owner after x.com/trexolab turned out to 404. Both are attribution rather than format, so the card is unaffected: summary_large_image stays, and it is what makes the 1200x630 social image render full-width. What went is a credit pointing nowhere, and an invitation to click it and find out.

  • The organisation schema stops advertising profiles that do not resolve. sameAs is how Google reconciles this organisation with the same one elsewhere, so every URL in it has to answer a logged-out request -- that being the only kind a crawler makes. Both entries did not: the GitHub repository is private and 404s anonymously, and twitter.com/trexolab redirects to an X handle that 404s too. The repository is now behind a REPO_IS_PUBLIC flag, the same shape as the existing ON_PUBLIC_NPM, so it returns on its own when the repository opens; with nothing left to list, the property is omitted rather than emitted empty. check:site now resolves every sameAs it finds.

  • npm run smoke:release verifies a release rather than the code behind it. The existing gate packs tarballs out of the working tree, which proves the code is right and says nothing about whether the release reached anyone: publishing to the internal registry is a separate step from tagging, and on 2026-07-17 the registry sat at 0.5.6 while git was at 0.5.12 because it had been skipped six times. This installs the packages the way docs/installation.md tells a customer to -- a scoped .npmrc, npm install, nothing else -- verifies a real signed PDF with what comes back, and checks that the version, the engine binary, the absence of base64 and the absence of embedded source all survived the trip, then that every manifest, Cargo.toml, the tag on HEAD and the push state agree.

[0.13.3]

Only @trexolab/verifykit-react changed. core, vanilla and plugin-revocation are rebuilt and re-versioned in lockstep with it, as they have been since the packages started shipping together, but carry no code changes of their own.

  • The Signature Properties dialog scrolls when its content does not fit. Since 0.13.0 the dialog sizes to its content and caps at calc(100% - 8px). When the content exceeded that cap it was clipped mid-row with nothing to scroll — reported from a phone, where a signature with three certificates and three timestamps ran well past a short viewport.

    The cause was a percentage height, not the cap. The tab's scrolling region was height: 100% inside a parent whose own height comes from flex layout, and browsers treat that as indefinite while laying the child out — so the percentage resolved as auto and the scroller grew to its full content instead of overflowing inside a fixed box. Measured on the built viewer: 1057px of content in a 163px box, overflow: hidden, no scrollbar. It is now a flex item (flex: 1 + min-height: 0), which gives it a definite height to overflow. The same height: 100% pattern in CertificateViewer — the Certificates and Timestamp tabs — is fixed the same way.

    Desktop was affected too, contrary to how it looked: at a 900px viewport the Details tab clipped 1057px of content into 663px. It was simply less visible than on a phone.

    min-height: 0 is now explicit on each panel in the flex chain. Every one of them sets overflow: hidden, which already implies a zero automatic minimum size, but relying on that means the dialog silently stops scrolling the day someone changes an overflow.

    Verified on the built viewer at 412×520, 900×400 and 1440×900, on all four tabs: every case either fits or has a user-scrollable region. Programmatic scrollTop is not what was checked — an overflow: hidden box moves under scrollTop while offering the reader no scrollbar, no wheel and no drag.

[0.13.2]

Only @trexolab/verifykit-react changed. core, vanilla and plugin-revocation are rebuilt and re-versioned in lockstep with it, as they have been since the packages started shipping together, but carry no code changes of their own.

  • The signature panel docks as a bottom sheet on touch devices up to 840px, not just below 560px. This is the actual fix for the Firefox-on-Android report that 0.13.1 chased. It was never a race: the md band starts at 560px, and the phone's viewer measured ~569px — nine pixels above the boundary — so it got a 272px side panel taking 48% of the screen, which is what the design said to do at that width. Chrome on the same device picks a different device pixel ratio, landed under 560, and got the sheet. Nothing about the device or its settings differed between the two.

    Width alone cannot tell a phone from a narrow window on a desktop, and the two want different layouts. The pointer can, so (pointer: coarse) now widens the sheet's band to everything below lg. With a fine pointer the rule is unchanged, so an embedded viewer in a narrow column on a desktop behaves exactly as before. At lg a side panel still leaves the document 568px or more, which is enough on either input.

    Measured on the built viewer at every band on both input kinds: 569px with touch is now a sheet and 569px with a mouse is still a side panel; 900px is a side panel on both.

  • The sheet-or-panel decision is published once, as data-sig-layout on the viewer root. base.css positions the sheet and SignatureListPanel sizes it, and both previously derived the answer from data-breakpoint independently. That was safe only while the rule was "xs or sm" in both places; with the pointer in it, a second derivation is a second chance to disagree — and disagreement means a sheet with a height and no dock. The attribute is additive and data-breakpoint is unchanged.

[0.13.1]

Only @trexolab/verifykit-react changed. core, vanilla and plugin-revocation are rebuilt and re-versioned in lockstep with it, as they have been since the packages started shipping together, but carry no code changes of their own.

  • The viewer's breakpoint is correct in the first frame it paints. useContainerSize started at lg and corrected inside a requestAnimationFrame, so the first frame every device painted was the desktop layout — and everything narrow (the signature bottom sheet, the collapsed rails) only appeared once that correction landed. The measurement now happens in a layout effect, before paint; the frame is still used to coalesce later resize bursts, where one frame of latency is worth not measuring per event.

    Instrumenting data-breakpoint at a 390px viewport showed Chromium and Firefox both publishing lg and only then xs, so the window was real in every browser — it simply closed fast enough to be invisible in most of them. After the change Chromium publishes xs with no intermediate value and docks the signature panel as a sheet.

    This was prompted by a report of Firefox on Android showing the desktop side panel instead of the sheet, with "Request desktop site" off. That failure could not be reproduced — desktop Firefox at the same width behaves correctly both before and after — so this closes a proven race rather than a confirmed cause of that report.

[0.13.0]

Only @trexolab/verifykit-react changed. core, vanilla and plugin-revocation are rebuilt and re-versioned in lockstep with it, as they have been since the packages started shipping together, but carry no code changes of their own.

  • The signature properties dialog sizes to its content instead of to the viewer. It was height: 100%, so it filled the viewer whatever tab was open — on Timestamp, a short certificate summary, that left roughly 800px of empty dialog below the last row, and the taller the display the worse it looked. Measured after: Details is 77% of the viewer's height and Timestamp 45%, each centred. ViewerDialogContent already capped at calc(100% - 8px) and its container centres on both axes, so removing the fixed height was the whole change; a 30rem floor keeps the four tabs from resizing wildly against each other and yields to the cap on a short viewport.

  • That resize is now animated. Switching tabs used to snap. Two obvious fixes do not work here and both are documented in viewer-dialog.tsx so they are not tried again: transition: height does nothing because the dialog is height: auto and a tab swap changes only the content, so no property ever transitions; and pinning the old height before setting the new one fails too, deceptively — getAnimations() reports a running transition while the box never moves, because React has already committed the new content and laid it out at its final size before a layout effect can run. The start and end are handed to element.animate() explicitly instead. Under prefers-reduced-motion: reduce no animation is created and the height snaps.

  • New optional prop: ViewerDialogContent takes animateHeightKey. Change it when the dialog's content changes enough to resize it — the properties dialog passes its active tab. Any dialog that omits it behaves exactly as before, so this is additive.

  • The certificate Summary panel stops floating its pills. An unheaded row sat between two cards carrying three unrelated things at once: the certificate's role, its key algorithm, and every extended key usage — while plain Key Usage had a card of its own, putting the two halves of one question in two different places. Role and algorithm now sit under a divider inside the Validity card, and both kinds of key usage share one card under separate labels, "Permitted key operations" and "Extended key usage".

[0.12.0] -- 2026-07-31

The engine stops travelling as a string, and the packages stop shipping their own source. Both came out of an SEO audit of the website, which is an odd place to find either -- the audit was looking at what pages weigh, found 1.8 MB of JavaScript on pages that never verify anything, and followed it into the packaging.

Between 0.3.1 and 0.11.0 the WASM binary was base64-encoded into the JavaScript. That needed no bundler support at all, which is why it was chosen, and it charged for it three times over: base64 is four bytes per three, so 1.1 MB became 1.5 MB; a string literal cannot be split out of the chunk holding it, so every route importing the package carried the engine whether or not it verified anything; and the bytes had to be decoded before compilation could begin. It ships as a .wasm file again. Bundlers have handled that natively for years, so the "nothing to configure" property survives -- arrived at differently.

The second finding is not about size. All four packages built with sourcemaps on, and a JS sourcemap carries sourcesContent: a verbatim copy of every file that went into the bundle. Each package publishes the whole of dist/, so the tarballs contained 13,689 KB of TypeScript across 863 entries. For a licensed product that is the thing being licensed.

A minor rather than a patch. main now points at a Node-specific build and exports grows a node condition, so a tool that reads neither exports nor browser resolves somewhere new. Any bundler from the last several years reads one of them, and no call site changes. On a 0.x version ^ locks the minor, so a project on ^0.11.x stays where it is until it asks for this one. docs/migration.md has the upgrade notes.

The website entries at the end are marked (site) and are not inside any published tarball.

Changed

  • The WASM engine ships as a .wasm file instead of a base64 string. Nothing in the API moves and, for anyone using a bundler, nothing in the setup does either: Vite, webpack, Next.js, Rollup and Parcel all resolve the reference wasm-bindgen makes and emit the binary as a content-hashed asset. What changes is what that costs. The file is 1.1 MB rather than 1.5 MB, WebAssembly.instantiateStreaming compiles it while it downloads rather than after a base64 decode, and it is cached apart from the JavaScript, which changes far more often. Node needed the one genuinely different path -- wasm-bindgen resolves the binary through fetch(new URL(…, import.meta.url)) and Node's fetch rejects file: URLs -- so there is now a separate Node build that reads it from the installed package, selected by a node condition in exports rather than by a runtime check, which keeps node:fs/promises out of the graph every browser bundler has to resolve. setWasmUrl() and createVerifier({ wasmUrl }) are unchanged and remain the way to load it from somewhere else. Two things are worth checking on upgrade: that your server sends .wasm as application/wasm, since browsers refuse to stream-compile anything else and fall back with only a console warning; and that any deploy step which copies build output by extension is not leaving the engine behind.
  • @trexolab/verifykit-vanilla no longer inlines the engine either, and takes a wasmUrl option. This package could not simply follow the change above: Vite builds it in library mode, which inlines assets unconditionally -- confirmed by building with assetsInlineLimit: 0, which produces a byte-identical chunk -- so the binary was being rewritten into a data:application/wasm;base64,… URL whatever the rest of the SDK did. dist/ now contains verifykit_core_wasm_bg.wasm and the bundle resolves it against its own script URL, so keeping the two in one directory needs no configuration, which is what copying the whole of dist/ already gets you. verifykit.umd.js drops from 2.9 MB to 1.3 MB, and the engine is fetched only when a document is verified. If you serve this from a CDN, two things change. The .wasm must be uploaded alongside the JS — where they cannot sit together, pass wasmUrl to VerifyKit.create(). And the host must send Access-Control-Allow-Origin on the .wasm: a <script src> needs no CORS, so a cross-origin CDN worked by accident for as long as the engine was a string inside that script, and the moment it became a file the bundle fetches, every embedder outside the serving origin gets a blocked request — after the viewer has loaded, so it fails at the point of verifying rather than at the point of loading. Give the JS and the .wasm the same cache lifetime too, since the wasm-bindgen glue and its binary are generated together and are not interchangeable across releases.

Security

  • Sourcemaps are no longer published. All four packages shipped them, and a JS sourcemap is not line mappings alone -- sourcesContent embeds a verbatim copy of every source file in the bundle. Across the four tarballs that was 13,689 KB of TypeScript in 863 entries: 107 source files from @trexolab/verifykit-react, 320 from @trexolab/verifykit-vanilla (which inlines its dependencies, so pdf.js, React and scheduler came too), and the loader from @trexolab/verifykit-core. Nothing was exploitable and no key or credential was among it; it was simply the source of a licensed product, handed to everyone who installed it. Declaration maps are kept -- they reference source paths but embed no code, and they are what gives an editor go-to-definition. A release-time check now fails the build if any published sourcemap embeds source, because this is invisible in a diff and was for several versions.

Fixed

  • dts.sourcemap is set explicitly in the two tsdown-built packages. Turning sourcemaps off left them still being written, on the ESM side only, which looked like the bundler ignoring the flag. It was not: rolldown-plugin-dts derives its own sourcemap setting from declarationMap in tsconfig and ORs it ahead of the build's, so with declaration maps on it won every time -- and only for ESM, because the CJS declarations are built through a separate pass. This is why sourcemap: false, --no-sourcemap and the documented default all appeared to do nothing.

Documentation

  • The docs describe the engine as a file. Ten pages said the opposite -- that the binary is base64-embedded and no .wasm needs to be served -- across the introduction, quick start, installation, deployment, architecture, security and troubleshooting guides, the examples, and both API references. docs/vanilla.md had gone the other way and promised sibling-file resolution that did not yet happen. All of them now match what ships, note that the file must be served as application/wasm, and cover the failure mode this introduces: a deploy step that copies by extension and drops the engine. The pre-0.3.1 migration entry keeps its history and is marked superseded.

Added

  • (site) Every page has a pre-rendered social card. The pages pointed og:image at /og?title=…&description=…, which builds an image for any pair of strings and is therefore an unbounded set of crawlable URLs -- 60 of bingbot's last 107 requests to the site went there, against 28 for actual pages. Disallowing it for Googlebot and bingbot fixed the crawl budget and silently cost the previews, because it was the only image any page declared: the max-image-preview:large those same pages ask Google for had nothing to show, and Discover, which requires a large image, could not consider them. Each page now points at one of 49 files rendered at build time, which is a finite set needing no rule to keep crawlers out of it. The on-demand route stays for ad-hoc cards and stays closed to the two indexers.
  • (site) /blog, /contact and /demo declare their place in the site. All three were missing BreadcrumbList, which is one of the few rich results Google still shows. /demo omitted it deliberately, reasoning that it draws no visible trail and the guidelines say not to mark up what a reader cannot see -- but that guidance is aimed at markup claiming something the page does not support, and where a page sits is not that. The six hand-written builders across the site are now one function, which puts item on every crumb: a list may omit that only on its final element, and a middle crumb without a URL is what invalidated the breadcrumb on all 28 doc pages once before.

[0.11.0] -- 2026-07-30

Two follow-ups to the scroll-mode work in 0.10.0, both of them decisions rather than repairs: the horizontal wheel now follows the pages, and wrapped is gone.

0.10.0 made the whole of a zoomed page reachable in horizontal mode by handing the wheel back to the browser. That was right about the reachability and wrong about the axis -- the browser scrolls Y and only falls back to X once there is no Y left, so on a tall page a plain wheel never advanced through the document. Wrapped, meanwhile, was fixed in 0.10.0 and then removed here, which is not a contradiction: it worked, and working was not enough to make it distinguishable from vertical at the zoom the viewer opens on.

A minor rather than a patch, because 'wrapped' comes off the ScrollMode union and that is a name leaving the public surface. Nothing throws if the string arrives anyway -- every runtime read of scrollMode is one of two plain equality tests, so an unrecognised mode lays out as vertical and a stale persisted value degrades rather than breaks. On a 0.x version ^ locks the minor, so a project on ^0.10.x stays where it is until it asks for this one. docs/migration.md has the swap.

Changed

  • In horizontal scroll mode, a plain wheel now scrolls across and Shift+wheel scrolls down. 0.10.0 fixed horizontal by handing the wheel back to the browser, which was right about the axis being reachable and wrong about which axis the reader wanted. The browser's rule is "scroll Y, and only fall back to X once there is no Y left", so on a page zoomed taller than the window a plain wheel scrolled down the page and never advanced through the document -- in a layout that reads left-to-right, a wheel that does not move you along it reads as a wheel that is broken. The axes now follow the layout: plain wheel moves through the pages, Shift+wheel moves within a page taller than the window, Ctrl/Cmd+wheel still zooms and is not touched at all, since that gesture belongs to the zoom plugin. This is a deliberate departure from pdf.js, which does no wheel remapping anywhere -- verified against the copy in this repository, whose only deltaX/deltaY reader is the zoom normaliser. It applies to horizontal alone; vertical and single-page keep the platform behaviour, which is what every other scroll container on the page does. Two details that are easy to get wrong are handled: the dominant axis is read rather than deltaY alone, because some browsers deliver Shift+wheel pre-swapped as deltaX and others as deltaY with the modifier set; and deltaMode is normalised, because Firefox's mouse wheel reports lines rather than pixels and an unnormalised three-line notch would move the page three pixels. The event is claimed only while the target axis can still move, so at either end the wheel goes back to whatever the viewer is embedded in and a host page can keep scrolling past it.

Removed

  • The wrapped scroll mode, and 'wrapped' from the ScrollMode union. It flowed pages left-to-right and broke onto a new row when the column ran out, so how many pages shared a row was decided by the zoom. That is the problem: the viewer opens at fit-width, and a page fitted to its column is by definition as wide as the column, so exactly one fits. Wrapped and vertical were pixel-identical until the reader zoomed out to roughly 60%, and only below about 25% did it become the page grid it was for. Measured on a twelve-page document in a 1024px column: one page per row at 120%, two at 60%, six at 25%. Nobody finds a mode they cannot see, and the thumbnail sidebar already answers the question a page grid was there to answer -- on a product whose documents are mostly a few pages of signed PDF, it was answering it better. The menu entry, the layout branch, the legacy toolbar menu entry and the demo's settings option all go with the type. A 'wrapped' string that still reaches the viewer -- from persisted settings, or a consumer not yet rebuilt -- lays out as vertical rather than throwing, so the removal cannot break a running app on contact; docs/migration.md has the swap.

[0.10.0] -- 2026-07-30

Three of the viewer's four scroll modes did not work. Vertical -- the default, and so the one that gets looked at -- was correct; horizontal could not be scrolled down, wrapped was a second copy of vertical under a different name, and single-page opened each page at the offset the previous one was left at. Each has its own cause and its own entry below.

A minor rather than a patch, for two reasons. It adds names to the public surface: PageNavigationPluginOptions in both @trexolab/verifykit-react and @trexolab/verifykit-vanilla, an optional argument to pageNavigationPlugin(), and a pageNavigation key on DefaultLayoutPluginOptions and on vanilla's VerifyKitOptions. And the floating page control those options govern is on by default, so upgrading puts something on screen that was not there before. On a 0.x version ^ locks the minor, so a project on ^0.9.x stays where it is until it asks for this one -- which is the right way round for a change that is visible without having been asked for. Everything added is optional, and every existing call keeps compiling untouched.

The remaining entries are documentation and website only and marked as such. None of that is inside a published tarball.

Security

  • (site) The operator dashboard's interface is no longer served to visitors who are not signed in. The page itself has always required a session and has never handed data to anyone without one -- but the interface it renders once you are signed in was being built into a file the site served to anybody who asked for it. Nothing in that file was a credential or a customer record; it was the panel's own layout and the names of the endpoints behind it, all of which reject an unauthenticated caller. It should not have been readable all the same, and it no longer is: the file is withheld unless the request carries a session this site issued, and a request without one gets the same answer as a request for a file that does not exist. Signing in has moved to /dashboard/login, which is what makes this possible -- the sign-in form and the panel used to be built into one file, so withholding it took the sign-in form down with it. Sessions issued before this change are ended, so signing in once more is expected. (Superseded: sign-in is at /admin-login since the operator platform rebuild -- /dashboard/login now redirects there. See [Unreleased].)

  • (site) Sign-in attempts are limited, and failures are recorded. There was previously no ceiling on how many times the dashboard's key could be guessed. Repeated failures from one source now lock it out for a period that lengthens with each further attempt, with a separate ceiling for attempts spread across many sources so that no single one has to reach its own limit for the pattern to be caught; an address that has signed in successfully before is exempt from the second, so the ceiling cannot be used to lock the operator out. Every failure is written to a capped log. The sign-in form now distinguishes "too many attempts, wait" from "wrong key", which are not the same problem. (Superseded: there is no shared "dashboard key" any more -- operators have individual accounts, and rate limiting is BetterAuth's, persisted in Postgres. The escalating-lockout and capped-log scheme described here was removed with the code that implemented it. See [Unreleased].)

  • (site) Request logs record the address the proxy saw, not one the caller can choose. The forwarding header this site reads is appended to by the proxy, so a caller that sends its own value has that value show up first and be recorded as the source. Three request logs took it at face value, which made the addresses shown in the dashboard suggestions rather than facts and would have made anything keyed on them trivial to sidestep. All of them now read the hop the proxy itself added.

  • (site) The dashboard sends framing, referrer and indexing headers. X-Frame-Options: DENY and Referrer-Policy: no-referrer alongside the no-store caching and noindex it already sent.

  • (site) Package downloads now go through the registry endpoint, so the switch that turns the registry off actually turns it off. The packaged releases were kept in the site's static directory, and a file in that directory is served straight from disk without the endpoint in front of it ever running -- which is where the on/off switch is read. The switch was off, the endpoint was correctly refusing requests, and a direct request for a package still returned it in full. The packages have been moved somewhere only the endpoint can reach, so the switch governs every download; the published addresses that consumers install from are unchanged.

Added

  • A floating previous/next control in single-page scroll mode. Single-page is the one mode where scrolling cannot reach the next page -- every page but the current one is display: none -- so the toolbar at the top of the viewer was the only way forward, which is a long way from where the reader's eye is once they have read to the bottom of a page. A pill carrying previous, "n / total" and next now fades in over the bottom of the page while the pointer is anywhere over the document, and fades out when it leaves. It is portalled into the document area rather than rendered where the overlay slot sits, so it centres over the page itself and not over the whole viewer, which would put it visibly off-centre whenever the sidebar or the signature panel is open. A device that cannot hover keeps it permanently, since there is no hover there to reveal it. While hidden it is visibility: hidden rather than merely transparent, so the two buttons leave the focus order with it and tabbing cannot land on a control that is not on screen. It lives in pageNavigationPlugin, so a viewer that does not install page navigation does not get it, and pageNavigationPlugin({ floatingNav: false }) -- reachable as defaultLayoutPlugin({ pageNavigation: { floatingNav: false } }) -- turns it off for one that does. @trexolab/verifykit-vanilla takes the same object as a top-level pageNavigation option, alongside the zoom one it already had: the control ships to both packages, so the switch had to reach both rather than leaving a vanilla embed with a control it could not turn off.

  • (docs) Three comparison pages, written from the vendors' own documentation rather than from feature grids. /compare held two pages and 173 words of its own, which is a hub with nothing in it, and the two commercial SDKs a buyer actually shortlists against -- Apryse and Nutrient -- appeared nowhere. /compare/verifykit-vs-apryse, /compare/verifykit-vs-nutrient and /compare/pdf-signature-verification-sdk now cover them, each with the API names quoted so every row is checkable against the source it came from, and each opening with what the other product does that VerifyKit does not: Apryse and Nutrient both sign, edit, redact and OCR across Web, iOS, Android and server, and VerifyKit does none of it. The rows that go the other way are the ones drawn from their guides: Nutrient's PAdELevel enum has b_b, b_t and b_lt and no B-LTA; neither vendor's verification guide documents a per-signature revocation field; Nutrient's signature validation needs two licence components, and without Form Viewing and Filling getSignaturesInfo() "resolves with an empty signatures array -- even on a signed document", with showSignatureValidationStatus defaulting to NEVER on top of that; Apryse's needs fullAPI: true plus a separate Digital Signature package. Where a guide is silent the row says "not documented" and the page says outright that this is a question to put to the vendor, not a proven absence -- and Apryse's GetUnsupportedFeatures, which VerifyKit has no equivalent for, and Nutrient's certificateCheckTime, which is more configurable than ours, are both stated as points against us. The third page is the one for someone who has not shortlisted yet: the eight questions a verifier has to answer separately, then the four categories -- open-source JS libraries, EU DSS (complete and free, and Java, which is the whole constraint), the two platforms, and VerifyKit -- with an honest matrix underneath and a section on when verification is not the job at all.

  • (docs) Where the verdict is drawn, on all three comparison pages. Most web viewers report a signature by floating an HTML badge over the page, and a comparison that omits this leaves out the part a reviewer notices first. VerifyKit puts the tick, cross or question mark inside the PDF canvas, on the signature field itself, in the widget's own rectangle at its scale and rotation -- and shows the verdict that signature actually got from the eight checks, not the grey placeholder the signing tool baked in. The properties that make it safe to switch on are stated with it: verification runs on the original bytes, what reaches the renderer is display-only so a download returns the untouched file, and password-protected documents are supported rather than skipped or corrupted. Over that canvas every signature field, signed or unsigned, is an individually addressable overlay tinted by that signature's status with the eight checks behind it. The limit is stated just as plainly, because it is a real reason to buy something else: renderForms is off, so every other AcroForm field renders read-only. This is a verification viewer, not a form filler.

Changed

  • (docs) The published documentation now describes behaviour, not implementation. A reference manual for a licensed product should tell you what the software does, what it guarantees and where it stops -- not how it is built inside. Pages that had drifted into the second category were rewritten into the first: the architecture reference now describes the four packages, what each publishes and what you build against, in place of a file-by-file map of the source tree; the guides describe what the viewer draws and under what conditions, in place of the document structures it writes; and history entries state what changed and who it affects, in place of the internal names involved. Nothing a customer relies on was removed -- every public API name, every documented guarantee, every stated limitation and the full Adobe-parity behaviour table are unchanged, and where a detail was genuinely load-bearing for integration (which ciphers are supported, why the package ships unminified, why pdf.js is pinned) it is still stated outright. Two things are new rather than removed: a bill of materials for the WASM binary is offered to customers for security review, and the custom-icon path now points at us instead of at build steps no published tarball contains.

  • (docs) Every comparison page is now reachable from the content that shares its subject. The related-reading block added in 0.9.0 ranked blog posts only, so the comparison pages sat behind a single link from the /compare hub. It now ranks blog posts and comparison pages separately and interleaves them, comparison first each round, so a comparison cannot be crowded out by a better-tagged post; each link carries a "Blog" or "Compare" chip so the destination is legible before the click. Measured across the tag graph: 48 inbound related-reading links now point at /compare/*, between 6 and 13 per page, from 21 of the 28 doc pages, all 9 blog posts and the sibling comparisons. /pricing links four of them directly.

  • (docs) /compare and /pricing say enough to be worth landing on. The comparison hub had 173 words and the pricing page 222, both of them almost entirely link text. /compare now opens with the eight questions a verification result has to answer separately and states the "not documented" convention before a reader hits their first table. /pricing gains a "What every licence includes" section -- the whole eight-check pipeline, all 134 trust roots, every package, browser and Node -- which exists because the tier grid implied a cut-down tier that does not exist, and a box linking four of the comparisons. /pricing also emits a BreadcrumbList, the one structured-data type it can state truthfully; it was the last top-level page without one, and the reasoning for why Product/Offer still cannot go there is in the comment above it.

  • (docs) Titles and descriptions across every page now fit what Google displays. 42 of the 46 pages then live carried a description past 155 characters, the longest at 287, and Google truncates the snippet mid-word past roughly that point -- what was being cut was the end of the sentence, which is where the distinguishing detail sat. Two blog titles and /demo ran past 60 characters including the | VerifyKit SDK suffix the root layout appends, which is the number that matters and is easy to miss when reading the source string alone. Every page is now inside both limits, measured with the suffix included and with HTML entities decoded first, and the rule is written down so the next page added is measured the same way.

  • (docs) dateModified and sitemap lastmod come from one source. Blog and comparison pages reported their publication date as dateModified and as lastmod, which told Google that nothing on the site had ever been revised -- including pages that had been substantially rewritten. Both now come from lastModifiedISO(), the Markdown file's mtime floored at its publication date, so a page and its sitemap entry cannot disagree, and a file whose mtime carries no signal falls back to the publication date rather than to the build time. Stamping the deploy time on unchanged text is a claim Google can check, and one that fails is a reason to ignore the field site-wide.

Fixed

  • Horizontal scroll mode can be scrolled down. Zoom a page in horizontal mode until it is taller than the window and the bottom of it was simply unreachable: no scrollbar, no wheel, no touch gesture got to it. The scroll container pinned overflow-y: hidden in that mode alone. The content was laid out the whole time and still moved when scrolled programmatically -- measured at 160% zoom in a 729px-tall column, scrollHeight was 1561 against a clientHeight of 729, so 832px of page existed below the fold with nothing able to reach it -- which is why the page looked cropped rather than broken. Both axes now scroll in every mode, and the wheel behaves as it does anywhere else on the web: plain wheel scrolls down, Shift+wheel scrolls across, Ctrl/Cmd+wheel still zooms, all three verified with real wheel events rather than synthesised ones. Nothing was added to intercept the wheel; removing the hidden was enough, because the browser's own handling was never the problem.

  • Wrapped scroll mode wraps. It was flex-direction: column with flex-wrap: wrap, and a column flex container whose height is content-driven never runs out of room to wrap out of -- so it never wrapped, and every page stacked in one column exactly as vertical mode does. The two modes were byte-identical in layout, which is why this survived: switching to it changed nothing visible, so it read as a mode that had not been implemented rather than one that was broken. Pages now flow left to right and break onto a new row when the column runs out -- verified at 25% zoom, where two pages sit side by side at the same y, against the old behaviour that put the second 339px below the first. A page wider than the column still gets a row to itself, which is the same arrangement as before but arrived at for the right reason.

  • Single-page navigation opens each page at the top of it. Going to the next page while scrolled down left the view at exactly the offset the previous page was left at -- read to the bottom of page 1, press next, and page 2 opened already scrolled past its own top with no indication that anything above was missed. scrollToPage navigates by scrollIntoView, and in single-page mode the page being navigated to is display: none at the moment it is called, so the call did nothing at all and the scroll position simply stayed where it was. Swapping which page is mounted is the whole of the navigation in that mode, so the view now returns to the top instead. The horizontal offset is deliberately kept: a reader zoomed into the right-hand column of a page wants the same column of the next one, not a jump back to the left margin.

  • Centring a page can no longer push it out of reach. Not observed, and prevented while the modes above were being fixed. Both row modes centre their pages, and a flex item centred inside a box smaller than itself overflows equally at both ends -- the top and left halves of that overflow landing in negative scroll space, which no scroll container can reach. What has been holding this off is min-width: fit-content on the page box, which grows it to the widest page so the centring has nothing to overflow; that is load-bearing and now says so in a comment, since it reads like a cosmetic line. safe centring backs it up, and degrades to flex-start -- equally unable to clip -- on a browser that does not support it. Verified across all four modes at 306% zoom, where the page overflows the column on every side: no page has any part of itself at a negative offset at the scroll origin, or beyond the far edge at the end of the scroll range.

  • (docs) The trust store has 134 roots, not 119. Twenty-four statements across ten files -- docs/architecture.md, core-concepts.md, examples.md, introduction.md, troubleshooting.md, security.md, three blog posts and the home page's feature and FAQ copy -- said the WASM binary embeds 119 AATL roots. It embeds 134, of which 16 are the Indian CCA hierarchy and 118 are AATL. The old number was both wrong and wrong in the way that matters: it undercounted by exactly the certificates that decide whether a DigiLocker, Income Tax, MCA or GST document verifies, since none of those chain to an AATL root. Every occurrence now reads 134, with the split named wherever there is room for it.

[0.9.0] -- 2026-07-29

Pinch zoom on touch devices, and the removal of the legacy PdfViewer that held the only pinch code this repository has ever had. The two are one change: the viewer every consumer actually renders had no touch-zoom path at all, and the hook that did exist sat behind an export nothing rendered.

A minor rather than a patch, because the removal takes three names off the public surface -- PdfViewer, PdfViewerHandle and usePinchZoom. Nothing else moves: <Viewer />, <CoreViewer />, every plugin and every other export are untouched, and for a project using them the upgrade is drop-in. On a 0.x version ^ locks the minor, so a project on ^0.8.x stays where it is until it asks for this one -- docs/migration.md has the details.

The remaining entries are documentation only and marked as such: the reference pages and the site that renders them. None of it is inside a published tarball.

Added

  • Two-finger pinch zoom, in the viewer consumers actually render. Reported as pinch doing nothing on a phone. It turned out there was nothing to do it: zoomPlugin handled Ctrl/Cmd+wheel, the toolbar controls and the fit modes, and had no touch path whatsoever, so the gesture reached the browser, found no listener, and stopped. The usePinchZoom hook the docs pointed at belonged to the legacy PdfViewer (removed below), which nothing in this repository mounts -- not the demo, not packages/vanilla, whose bootstrap renders <Viewer> as well, and not the quick-start. Measured against the live 0.8.0 site at 390x844 with touch emulation: DOMDebugger.getEventListeners on the real scroll container returned no pinch listener at all, and a genuine two-finger sequence delivered two pointerdown, twelve pointermove and two pointerup with no pointercancel -- the browser never took the gesture away, nobody was listening for it. The same geometry sent as Ctrl+wheel moved the zoom from 68% to 78% and the canvas from 418px to 479px; sent as touch, both stayed exactly where they were. Compounding it, canvas-layer.tsx sets touch-action: pan-x pan-y on the scroll container, and that list omits pinch-zoom, so the browser's own pinch was suppressed there too and there was no fallback behind the missing handler.

    Pinch now lives in zoomPlugin itself, which is the single point both the React and the vanilla packages pass through, so it arrives for every consumer of defaultLayoutPlugin with no opt-in and no hook to wire up. It shares the cursor-anchored zoom math with Ctrl+wheel rather than carrying a second copy -- the anchor is the midpoint between the fingers, clamped into the page area so a finger straying over the toolbar mid-gesture cannot send the re-centring somewhere nobody pointed -- and it honours the plugin's existing minScale / maxScale, stopping at 25% and 1000% by default instead of running past them. Zoom leaves fit mode as it should: the label changes from "Fit width" to a percentage. The gesture is claimed by calling preventDefault() on the touchstart that brings the second finger down, not by flipping touch-action, which the browser has already latched by that point -- that was the structural defect in the removed hook, never observable because the code never ran. The touchmove listener is bound for the duration of a pinch and released with it, rather than living on the container, because a non-passive touchmove listener takes ordinary one-finger scrolling off the compositor fast path; single-finger panning is untouched, and canvas-layer.tsx keeps its touch-action as it was. Verified by dispatching real touch streams: a 100px to 200px finger spread takes the zoom from 52% to exactly 104%, repeated pinches clamp at exactly 1000% and 25%, every event of a one-finger drag comes back defaultPrevented: false with the window's touch-listener list identical to before the drag, and the listener set after a completed pinch is identical to the set before it.

  • (docs) /demo says what it does, for someone who has not read the rest of the site. The page carried 137 visible words under an <h1> reading "Live Demo" -- a heading that describes the page's role in this site's navigation and answers nothing for the person who arrived holding a signed PDF and a question. It now opens with "Verify a PDF Signature" and runs to 1,476 words below the tool: what the eight checks each test and what a failure in that specific one means for your document (deliberately not the home page's eight descriptions, which describe what the SDK implements rather than what a red cross is telling you), what a verified signature does not claim, the four signature subfilters that are recognised, and six questions written for this page rather than reused from /faq. The privacy section is the one that mattered most to get right, and it is narrower than what the page used to claim: "Processed entirely in your browser -- nothing leaves your device" and "All processing happens in your browser" were both false in the default configuration, because revocationEnabled is on and the signer's certificate is relayed through /api/revocation to reach a CA whose CRL and OCSP endpoints send no CORS headers. Every claim is now scoped to the document -- your PDF is never uploaded, which is true and checkable -- with a section that names the one request that does leave, what it carries, and how to switch it off. The title drops "Live Demo" for "Verify a PDF Signature Online -- Without Uploading It" and stays clear of the word "free", since the layout appends "| VerifyKit SDK" to every title and the SDK is not; the offer is spelled out in the body, where there is room to say which half is which. No FAQPage node was added for the question accordion -- the site emits none, for the reasons under Removed.

  • (docs) Doc pages link to the blog posts that share their subject. The blog was reachable only from itself: every post was linked from /blog or a sibling post and from nowhere else, so a reader who wanted background on what a doc page was describing had no path to it. Doc pages and blog posts now tag themselves from one vocabulary, and each page ends with the posts it overlaps, ordered by how much of the subject they share. The relationship is derived from those tags rather than kept as a hand-written list, so a post added later reaches the pages it belongs on without anyone editing anything, and a page with no real overlap — the changelog, the glossary, the migration guide — correctly shows no block at all.

  • (docs) The Adobe Reader messages a reader actually sees now have pages that answer them. Of the six user-visible strings Acrobat shows on a signed PDF, four appeared nowhere on the site and a fifth only inside a table in docs/api/react.md. docs/troubleshooting.md has 23 sections and not one of them was written for "my PDF shows an Adobe error" -- every one addresses "my VerifyKit integration is broken", which is a different person with a different problem. Two posts cover the two messages that were missing an explanation, and both are written from the engine's actual behaviour rather than around it. Why Adobe Says "Altered or Corrupted" is the more useful of the two, because the message is about byte coverage and not about tampering: a PDF is designed to be appended to, so byteRangeCoversWholeFile: false is the expected state for every signature but the last in a countersigned document, and appending DSS/LTV data after signing is a permitted update. The engine already separates the four cases behind that one message -- hash mismatch, DSS-only bytes beyond coverage, unsigned content beyond coverage, and an earlier signature in a multi-signature file -- returning invalid, valid, warning and valid respectively, with the reason in integrityCheck.detail. Adobe: "At Least One Signature Has Problems" covers the banner, which is a summary of the worst signature in the document rather than a diagnosis, and routes to the specific check: it is the only page covering the five causes the other two Adobe posts do not -- expiry evaluated at signing time, the missing RFC 3161 timestamp that makes an aged certificate start failing, revocation and the attempted flag that separates "not checked" from "checked and inconclusive", weak algorithms, and EKU. troubleshooting.md gains a table mapping each exact Adobe string to the field that produces it, plus the integrityCheck / byteRangeCoversWholeFile matrix. Both posts tag into the shared vocabulary, so the docs pick them up through the existing related-reading block with no list to maintain.

Removed

  • The legacy PdfViewer, and the usePinchZoom hook only it used. PdfViewer predates the plugin architecture that landed in 0.3.1 and was superseded by <Viewer> / <CoreViewer> when it did. Nothing in this repository has rendered it since: the demo, packages/vanilla's bootstrap and every documented example mount <Viewer>, and grep for usePinchZoom returned exactly two hits, PdfViewer.tsx and the export barrel. Six modules go -- viewer/PdfViewer.tsx, its four pdf-viewer-* helpers, and hooks/usePinchZoom.ts -- taking three names off the public surface: PdfViewer, PdfViewerHandle and usePinchZoom. That is the whole of the breaking change, and a project that imports none of the three is unaffected. Eight internal fragments went with them, checked against the pre-removal tree rather than assumed: LeftTabStrip, the CtxItem / CtxDivider context-menu primitives, PdfViewerContextMenu, ToolbarSection, ErrorPanel, HighlightPopover and FullscreenExitButton were each reachable only from a deleted module, and the PdfViewerHandle interface described the imperative handle of a component that no longer exists. None of the eight was ever exported from the package root. LoadingProgressBar and TabStripButton, which CoreViewer does use, stay.

    Two things that look like collateral are not. The five types PdfViewer re-exported -- SigWidget, Highlight, ScrollMode, SpreadMode and CursorTool -- are declared in viewer/pdf-viewer-state.ts, which stays, and they are still exported from the package root under the same names, so an import of any of them keeps compiling untouched. And the 22 modules left under viewer/, which CoreViewer and the plugins share, all remain: the outline, thumbnail and attachment panels, the find bar, the password, properties and about dialogs, the keyboard-shortcut help, ViewerToolbar, PdfPage, and the error boundary named PdfViewerErrorBoundary despite being the one CoreViewer mounts. Anyone still on PdfViewer moves to <Viewer> with defaultLayoutPlugin; docs/migration.md has the prop-by-prop mapping, and pinch zoom is one of the things that move brings with it.

  • (docs) Structured data for four rich results that Google no longer awards. The site asked for each of them and could never have received any: the HowTo on /docs/quick-start and the home page (Google withdrew that rich result on 2023-09-14 -- its documentation page now redirects to the changelog entry announcing the removal), the FAQPage on /faq and the home page (restricted to government and health sites in 2023, deprecated 2026-05-08, documentation withdrawn 2026-06-15), and the SearchAction in the site-wide WebSite node, which requested the sitelinks search box that Search dropped on 2024-11-29 -- and pointed at /docs?q=, which is not a search endpoint at all: that route reads no searchParams and serves the same page for every value of q. All four were dead weight in the page source and in the Search Console reports. Every question, step, and answer they duplicated is unchanged: the FAQ still renders from FAQ_ITEMS, the quick-start steps still render on the home page, and both remain ordinary indexable text.

  • (docs) The SoftwareApplication that was invalid on all 44 URLs, and the WebApplication on /demo. Google's Software App feature requires three things -- name, offers.price, and one of aggregateRating / review -- and treats WebApplication the same way. Both nodes had name and nothing else, so neither could ever produce the app rich result they were added for; they only reported as invalid items, the site-wide one on every page. Neither missing property can be supplied honestly. VerifyKit is licensed by quote, so there is no price to state -- every paid tier on /pricing routes to /contact, and the only free thing on the site is the in-browser demo, not the SDK -- and there are no customer reviews, which Google warns about directly: ratings "not by actual users may result in manual action". That is the same reasoning /pricing has always applied to Product. /demo now describes itself as the WebPage it is. Invalid structured data costs the rich result, not indexing or ranking, so removing markup that could not be completed loses nothing and empties the report.

Fixed

  • (docs) The home page, the FAQ and one blog post stop promising a network silence the SDK does not keep. /demo was rewritten to say only what its code backs, which left the rest of the site making the older, larger claim: "no data leaves the browser", "No server round-trips", "no third-party services", and on /faq, the same "All processing happens in your browser" sentence that had just been removed from the demo for being false. Checking them turned up something wider than the demo's revocation proxy -- enableAIA defaults to true in @trexolab/verifykit-core, so out of the box the verifier chases missing intermediate certificates by calling the caIssuers URLs inside the chain, straight from the browser. Every install has been making third-party requests, revocation plugin or not. The home page's own FAQ answer had already half-admitted this, listing AIA and revocation as "the only optional network requests" one sentence after declaring there were none. What survives scrutiny is now what the site says: the document never leaves the device -- verified, nothing on any path carries PDF bytes -- and there is no telemetry and no analytics, which is true to the letter, since the packages contain no beacon, no analytics import and no phone-home of any kind. Alongside that, the two calls that do go out, what each carries, and how to switch them off. "Runs entirely client-side" is untouched throughout: the verification itself does, and these requests fetch its inputs rather than perform any part of it.

  • (docs) The demo's heading is visible on a phone, and the page has one <main> instead of two. Two faults found while rewriting /demo, neither introduced by it. At 768px and below the sidebar is an overlay drawer at z-index: 30 and the status bar sits above it at 31 -- deliberately, so the bar's own toggle stays clickable -- which costs the drawer its first rows, and the top row is the page heading. Geometry gave no sign of it: getBoundingClientRect put the h1 at y=80 with the drawer unscrolled and nothing clipped, and it was still invisible. document.elementFromPoint at the heading's first line returned a topbar chip, which is what proved it. The drawer's padding now starts below the bar, measured from a --ds-topbar-h variable the bar is given rather than from whatever height it happens to compute to, so the offset and the bar cannot drift apart; white-space: nowrap came off the title, which was safe while it read "Live Demo" and is not now. Separately, .ds-main was a <main> inside the root layout's <main>: a second landmark in one document, which leaves assistive technology and content extractors choosing between two candidates. It is a div now, styled by the same class, so nothing moved.

  • (docs) The doc URLs crawlers still ask for now lead somewhere. Googlebot is still fetching four URLs that 404, and all four have a live equivalent: /docs/getting-started and /docs/core-api were renamed to /docs/quick-start and /docs/api/core before the site had any redirects, and /docs/architecture.md and /docs/deployment.md are residue from the cross-reference bug that rendered all 59 doc-to-doc links as paths to the Markdown file instead of the route it becomes. That rendering was fixed, but a crawler that walked the site while it was broken keeps the .md URLs and keeps asking. Each now redirects to the page it means, so the request ends on content rather than on an error and whatever value those links carry arrives somewhere. The .md rule is written once per depth -- /docs/:page.md and /docs/:dir/:page.md -- rather than as a single :path*: a repeating param captures api/core as one string while the destination needs an array to put the slash back, so the nested case throws, and naming both depths sidesteps that instead of depending on how it is handled. Verified against every .md form of every doc page and against the live routes, which all still answer 200.

  • (docs) robots.txt stops hiding the stylesheet and stops spending the crawl budget on pictures. Two faults, found by reading a month of bingbot requests in the proxy log rather than by inspecting the file. Disallow: /_next/ covered /_next/static/, which is where the CSS, the JS chunks and the fonts are -- 20 references on a typical doc page -- so every crawler that obeyed us was told it could not fetch the stylesheet and then asked to judge a page it was not allowed to lay out. Google asks directly that CSS and JS not be blocked, and Bing renders too; /_next/static/ is now allowed, which is a longer match than the Disallow beside it and therefore wins on both engines, while /_next/data/ and /_next/image stay shut. Separately, /og renders a social card per title and description and is linked from the og:image of all 46 pages, so crawlers work through it: 60 of bingbot's last 107 requests went to /og, against 28 for actual pages -- on a site Bing visits about four times a day, more than half the allowance was going on images with no indexable text, and it had reached 22 of the 46 URLs in a month. /og is now closed to Googlebot and bingbot only. It stays open to *, which is what Facebook, LinkedIn, Twitter and Slack match -- they read robots.txt as well, and without the image there is no preview card. That group repeats the /api/ and /_next/ rules rather than adding to them, since a crawler obeys only the group naming it and an omitted rule would read as permission.

  • (docs) "N min read" is measured from the article instead of typed next to it. Nine of the eleven labels were wrong, all in the same direction -- a 678-word post was advertised as a 7 minute read, a 696-word one as 8 minutes, and the 816-word PAdES explainer as 9. At the low end that implies a reading speed of about 90 words per minute, roughly half of what an adult reader manages on technical prose, so a reader deciding whether to open a page was being told it would cost twice what it does. Nobody had guessed badly: the figure was typed once, correctly enough at the time, and then stayed put through every revision that shortened the prose around it, because nothing connected the two. The only two labels that were accurate were the two written last week, which is what a hand-maintained number always looks like -- right until it ages. The registries now carry only the fields that cannot be derived, and readingTime is computed at build time from the Markdown file itself at 200 words per minute, resolving the path the same three ways the post body already does. A file that cannot be read yields an empty label rather than a confident "0 min read", and the five render sites drop the separator with it, so a renamed Markdown file shows one thing less instead of something false.

  • (docs) The logo in every Organization node is one Google can see. logo.svg is drawn for a dark surface: its checkmark -- which is the V of the VK monogram, and the whole "verify" idea the mark is built on -- is #FEFDFD, about a 1.02:1 contrast ratio against pure white. Google renders the Organization logo on white, so what it had to work with was a shield with a stray K in it. All five structured-data references (the home page's Organization, orgLd(), trexoLabOrgLd(), blogPostingLd() and the blog index's Blog.publisher) now point at logo-on-white.svg, identical geometry with the checkmark in the site's light-mode text colour, behind a single ORG_LOGO_URL constant so the five cannot drift apart. The visible mark is untouched: the app icon and the PWA manifest still use logo.svg, which is correct for the surfaces they appear on.

  • (docs) Titles stop naming the site twice and stop running past the width a result is drawn in. /pricing rendered as "Pricing & Licensing -- VerifyKit SDK | VerifyKit SDK" and /contact as "Contact Us -- VerifyKit SDK Enquiry | VerifyKit SDK": both spelled the brand out in their own title and then had the root layout's %s | VerifyKit SDK template append it again. Google lists "site name duplication in the title element" among the reasons it discards a page's title and writes its own, so these two pages were arguing for a title they could not keep. Separately, 12 of the 44 titles ran past 60 characters -- the longest 98 -- and Google truncates the displayed title "as needed, typically to fit the device width", so the words that told one blog post apart from another were the ones being cut. The blog and comparison registries now hold a title written for the result page rather than for the page itself; the visible <h1> still comes from the Markdown and is unchanged, since a reader who has already clicked is not paying for the extra words. The brand suffix stays everywhere it was: Google asks for "only the site name at the start or end of each title", and the fault was saying it twice, not saying it at all. The home page keeps its own full title -- app/page.tsx is the root layout's own segment rather than a child of it, so no template reaches it -- and now leads with the words matching the one query in Search Console that squarely fits it, "sdk for verifying pdf files", which sits at position 22.

  • (docs) Doc pages describe themselves with the properties Google asks an article for. The TechArticle on each of the 28 doc pages carried headline, description and author but neither image nor dateModified, both of which Google lists as recommended and both of which the site already knew: the page's generated OG card, and the modification time of the Markdown file behind it. The article now states both -- dateModified from the same file mtime the sitemap's lastmod uses, so the two can never contradict each other, and omitted rather than guessed when that mtime carries no signal. datePublished stays out: nothing in the repository records when a doc page first appeared. The stale about reference went with it -- it pointed at #software, the id of the SoftwareApplication node that no longer exists.

  • (docs) Doc pages emit a valid breadcrumb again. Every one of the 28 pages carried a BreadcrumbList whose third entry was the sidebar section -- "Guides", "API Reference", "Overview", "Resources" -- as a bare name with no item. That mirrored the visible trail, where the section is plain text rather than a link, but item may only be omitted on a list's final entry, so the section crumb made the whole list invalid: Google's URL inspection reports Missing field "item" and shows no breadcrumb rich result at all. Nor was there a URL to supply -- sections have no route, and /docs renders introduction.md, which has no per-section heading to anchor to. The machine-readable trail is now the URL hierarchy it has to be, / -> /docs -> the page, and on /docs itself it stops at /docs instead of repeating that URL under a second name. The visible breadcrumb is unchanged and still names the section.

  • (docs) The docs sidebar no longer links to a demo that does not exist. Its "Demo" group offered a Vanilla JS Demo entry pointing at /demo/vanilla, a route that was removed while the pdfjs-dist compatibility work was going on and never came back. The link stayed, so every one of the 28 doc pages carried a link to a 404 -- a dead end for a reader and a crawled 404 for a search engine, on the highest-linked path on the site. The entry is gone; /demo is unaffected. Every internal link on every page in the sitemap now resolves.

  • (docs) The sitemap stops claiming that every page changed at build time. 35 of the 44 URLs carried lastmod: <build timestamp>, so each deploy told search engines that the entire site had just been rewritten -- including pages untouched for months. Google only honours lastmod while it stays verifiably accurate and disregards the field site-wide once it does not, which is the opposite of what a sitemap is for. Doc pages now carry the modification time of the Markdown file behind them, the blog and comparison indexes carry the date of their newest entry, and the pages with no content date to draw on -- home, demo, pricing, FAQ, contact -- carry no lastmod at all, which is both legal and what Google asks for when there is nothing accurate to say.

  • (docs) Cross-references between doc pages resolve on the site again. The docs are Markdown files that get read in two places -- in the repository, where a cross-reference is a path to a neighbouring file (./installation.md#pdfjs-worker-setup), and on the docs site, where the same file is a route (/docs/installation#pdfjs-worker-setup). The source carried the file paths and the site rendered them verbatim, so all 59 cross-page links on the site pointed at .md URLs that 404. Rewriting the Markdown would have fixed the site and broken every reader who opens these files in an editor or on GitHub, so the mapping now happens once at render time, in MdxContent: a relative link is resolved against the file being rendered and looked up in the doc-page registry, which handles the extra depth under docs/api/ and keeps the #anchor. A link that lands on no known page is left alone and warned about during the build, so a reference to a page that does not exist is loud rather than a silent 404. Six links written the other way round -- site-absolute /docs/..., working online and dead in the repository -- were converted to file paths so one form now works in both places, and plugins.md now points at the revocation plugin's API page instead of a package README that the site does not publish.

  • (docs) The worker setup is now stated wherever it is needed, with the version. workerUrl is required and the file it points at must be pdf.worker.min.mjs from pdfjs-dist@5.5.207 -- pdf.js refuses to run a library and worker whose versions differ, and since 0.6.0 the library is bundled, so there is no node_modules/pdfjs-dist to copy the worker out of and npm ls pdfjs-dist reports nothing useful. Three places got this wrong. deployment.md's air-gapped recipe self-hosted the CMaps and fonts and then handed out a unpkg URL for the worker, in the same config block -- an external fetch in a section premised on there being no external fetches; it now downloads the worker as its own step. vanilla.md had no worker guidance at all, and its self-hosting section said to copy dist/, which does not contain the worker and never did. i18n.md and testing.md configured /pdf.worker.min.mjs without saying where that file was supposed to come from. installation.md's canonical section now leads with which file and which version, why the version is fixed rather than chosen, and the runtime error that names both versions when they disagree -- and offers a one-line curl alongside the existing npm pack extraction.

[0.8.0] -- 2026-07-27

Two viewer fixes -- the right-click menu ignored disable, so a plugin the consumer had turned off stayed reachable from it, and on a phone the toolbar's menus opened over the signature sheet while the sheet went on taking the taps -- and, separately, repository tooling. The tooling entries are marked as such: nothing in them is inside a published tarball.

A minor rather than a patch, for two reasons that only touch code written against the plugin API: ViewerPluginContext.getPlugins() is a required member, so a hand-built context object literal no longer compiles, and the context menu now draws only what the installed plugins contribute, which takes entries away from a viewer that had those plugins disabled. Neither is reachable from ordinary use of <Viewer /> or defaultLayoutPlugin, and for those the upgrade is drop-in. On a 0.x version ^ locks the minor, so a project on ^0.7.x stays where it is until it asks for this one -- docs/migration.md has the details.

Added

  • renderContextMenuItems, a plugin hook for right-click entries. The same contribution model as renderMenuItems, which the toolbar overflow menu has used since 0.7.0: a plugin returns the entry it wants in the context menu, and a plugin that is not installed is never asked. Entries are ordered and grouped by the menuOrder / menuGroup a plugin already declares for the overflow menu, so an action holds the same relative place on both surfaces. ContextMenuItemRenderProps and ContextMenuItemsRender are exported for typing the hook, and collectContextMenuItems alongside the existing collectMenuItems. The props are deliberately smaller than the overflow menu's: nothing can overflow into the context menu, so there is no isOverflowed to ask.

  • ViewerPluginContext.getPlugins() returns every plugin resolved into the viewer, composed sub-plugins included. Only a plugin that draws a surface assembled from what the others contribute needs it — the context menu is the one in the box — and everything else should stay ignorant of its neighbours. It is a required member of the interface, so code that builds a ViewerPluginContext object literal by hand (a test double, most likely) now has to supply the method; a plugin that only receives the context in install(ctx) is unaffected.

Changed

  • The context menu's order shifts slightly, now that both menus sort by the same menuOrder. Find in Document moves to the top, Copy Selected Text sits in a group of its own, and Save As… comes before Print. Nothing was removed.

  • (tooling) Published tarballs are now immutable. Re-publishing a package without bumping its version used to replace the live tarball in place, which npm rejects against a lockfile holding the old integrity hash (EINTEGRITY). A given version now resolves to the same bytes forever: re-publishing identical content is a no-op, and re-publishing changed content is refused. If you have ever seen an EINTEGRITY error installing VerifyKit, this was the cause and it cannot recur.

  • (tooling) A sibling dependency can no longer fall out of the manifest by omission. The script already warned when its depsKeys list named a dependency package.json did not declare. The opposite direction -- a sibling in package.json that depsKeys forgets -- was silent, and it is the dangerous one: npm resolves from the registry manifest, not from the tarball's package.json, so the edge simply disappears. That is the same class of failure as the "*" ranges fixed in 0.7.3. Every @trexolab sibling must now be accounted for in either depsKeys or the new bundledDeps list -- the latter for @trexolab/verifykit-vanilla, which inlines the React shell at build time on purpose -- and the run exits non-zero otherwise.

Fixed

  • A disabled plugin can no longer be reached through the right-click menu. defaultLayoutPlugin({ disable: { download: true } }) removed the toolbar button, the overflow-menu entry and the Ctrl+S shortcut — and right-click, Save As… still wrote the full PDF to disk. The context menu kept a hardcoded list of entries and called the actions itself, so it had no way of knowing what was installed; the same bypass applied to every other entry it drew, which is print, rotation, search, fullscreen, properties and shortcuts. It now renders only what the installed plugins contribute through the new renderContextMenuItems hook, so there is no second list to fall out of step — a disabled plugin contributes nothing and the entry is simply absent. Copy Selected Text stays in every case: it is the browser's own clipboard, governed by no feature toggle, and it keeps the menu from ever coming up empty.

    The toolbar-slot setting is a separate knob and is unchanged: toolbar: { download: false } hides the button and nothing else, by design — Ctrl+S and the menu entries keep working, because that setting says where a control appears, not whether the feature exists. Only disable takes a feature away, and only disable now empties the context menu.

  • Print from the context menu prints the document, not the page around it. It called window.print(), which printed the host application — headers, sidebars and all — with the PDF as whatever the canvas happened to show. It now goes through the print plugin, the same path as the toolbar button and Ctrl+P: the file is rendered into an off-screen frame and that is what the print dialog receives.

  • Zoom from the context menu respects zoomPlugin({ minScale, maxScale, step }). It wrote scale into the store directly, in hardcoded steps of 0.1 clamped to 0.25–5, ignoring the plugin's options entirely — so on the defaults (0.25, 10, 0.15) it moved in the wrong increment and stopped at 500%, while the toolbar's own buttons carried on to 1000%. Both entries now call the plugin's zoomIn() / zoomOut().

  • Fullscreen from the context menu fullscreens the viewer. It requested fullscreen with no element, which falls back to document.documentElement — the entire host page. It now uses the fullscreen plugin's API, which targets the viewer container, matching the toolbar button and F5.

  • A toolbar menu can be tapped where it overlaps the signature sheet. On a narrow viewer the signature panel docks to the bottom as a sheet, and any menu opened from the toolbar — the More menu, the search popover, the zoom and text-size dropdowns — drops down across it. Every entry below the top edge of the sheet drew normally, highlighted on hover and did nothing when clicked: at 390×844 with the sheet at the height it opens to, that was the last four entries of the More menu — Fullscreen, Document Properties, Keyboard Shortcuts and About VerifyKit — and ten of the fourteen zoom presets once the sheet was dragged taller. The toolbar carried a z-index of its own, which makes it a stacking context, and that pins every popover inside it to the toolbar's level however high the popover's own z-index climbs; the sheet sat above that level and took the clicks through the menu drawn on top of it. The toolbar is now above the sheet, so what it opens is above it too. The sheet still covers the document, and dialogs, the right-click menu and tooltips still cover the toolbar.

    0.7.0's audit of "every enabled control reachable at every width" missed this because it checked that each control was present in a menu at each width. These four were present, and drawn, and unclickable.

[0.7.3] -- 2026-07-26

A packaging fix. The packages declared their dependency on each other as "*", which npm reads as "any copy already installed will do" -- so upgrading the React shell in a project that already had an older core left the old core in place and said nothing. No runtime code changed in this release.

Fixed

  • The packages no longer depend on each other with "*". @trexolab/verifykit-react and @trexolab/verifykit-plugin-revocation declared "@trexolab/verifykit-core": "*", and @trexolab/verifykit-vanilla declared "@trexolab/verifykit-react": "*". Because every version satisfies "*", npm resolved the edge to whatever copy was already in the tree and skipped the upgrade: a project on 0.5.13 that ran npm install @trexolab/verifykit-react@0.7.2 ended up with the 0.7.2 shell driving the 0.5.13 engine. Nothing errored -- the shell imports 18 symbols from core and all 18 exist in 0.5.13, so it loaded and ran -- and the About dialog reports the React package's own version, so it read 0.7.2 while core-side fixes -- DocMDP detection (0.6.3) and the Node ESM entry (0.6.5) -- were silently absent. Fixes that landed in the React package itself, such as the DocumentPermissions mapping in 0.6.8, were unaffected: that is the package the consumer upgrades. Each sibling edge is now a real range, ^<version>, so npm fetches a matching engine.

    Only an upgrade was affected. A fresh install had no older copy to reuse and always resolved correctly. npm update did not rescue an affected project either, since the consumer's own range held core on 0.5.x. The edges have read "*" since the packages were first published under the @trexolab scope, so every release up to and including 0.7.2 carries it; tarballs already published are unchanged, and a project pinned to one of them needs 0.7.3 or a manual npm install @trexolab/verifykit-core@<version> to pick up the fix.

    ^ rather than an exact pin: on a 0.x version ^ locks the minor, which is the boundary this SDK breaks at, while still letting a project already on a compatible core keep the single copy it has. An exact pin would force a second copy of the WASM engine over a patch difference -- and the engine holds the trust store and the algorithm policy in module state, so a second copy means a second set of both.

  • Each package now depends on an exact sibling version rather than "*". The ranges had been written by hand once and never revisited, so a lockfile could in principle pair one package with a mismatched sibling.

[0.7.2] -- 2026-07-26

A documentation-accuracy release. A grounded audit of every code sample in the docs against the shipped type definitions found samples that could not compile, and public API that the docs described but the package did not export. The fixes are the exports below plus corrections throughout the docs; no runtime behaviour changed.

Added

  • SidebarTabProps, MenuItemRenderProps, MenuItemsRender and CollectedMenuItems are now exported. All four were reachable in the type graph -- a plugin author could receive one -- but had no name to import, so the documented signatures were unwritable. renderMenuItems shipped in 0.7.0 documented as taking MenuItemRenderProps; typing that parameter failed with TS2724.

  • MenuItem, MenuDivider, MenuSection and MenuContainer are now exported. renderMenuItems is public API, so the primitives its own built-in plugins use to render entries have to be public too. Without them a contributed entry could only be built from raw JSX and would not match the menu around it.

  • collectMenuItems is exported alongside collectToolbarSlots and collectSidebarTabs, which were already public.

Fixed

  • The plugin context was documented under a name that resolves to a different type. The package exports the core engine's PluginContext under the plain name and the viewer's as ViewerPluginContext. Every sample wrote import { PluginContext }, which silently resolves to the core one -- it has .config, not .store or .registerShortcut -- so ctx.store did not type-check anywhere. Corrected across plugin-development.md, plugins.md, examples.md, api/react.md and customization.md, with a note in each explaining which name is which.

  • The documented store API did not exist. Samples taught store.setState({ … }) and a one-argument store.subscribe(state => …). The store has no setState; writes go through update(), and subscribe() takes a key first, then a listener receiving that key's new and old value. subscribeAll() is the watch-everything form and hands the listener whole state snapshots -- it does not report which key changed, and was documented as if it did. Four occurrences, including the store reference block itself.

  • ZoomChangeEvent.fitMode was documented with literals from a different union. It is 'none' | 'width' | 'page', not the scroll/spread values it was listed with.

  • A sample imported a package consumers do not have. import('pdfjs-dist').PDFDocumentProxy -- pdf.js has been bundled since 0.6.0, so it is not an installed dependency. The document type now comes from DocumentLoadEvent['document'].

  • The Node.js blog post called a revocation export that does not exist. createRevocationPlugin({ mode: 'direct' }) -- the export is revocationPlugin, and direct mode is selected by omitting endpoint, not by a mode option.

  • useContainerSize was documented without its breakpoint return value, which is the field the rest of the docs tell you to use.

  • CSS custom-property values had drifted from the stylesheet. --fg-muted, --fg-subtle, --border and --border-strong all listed pre-contrast-fix values. The layout table also documented --btn-size and --toolbar-height as the knobs to set, but both are derived -- calc(var(--btn-base) * var(--ui-scale)) -- and shift by pointer type and viewport height; the table now covers the bases and the four media contexts.

  • The migration guide listed v0.6.1→v0.6.2 after v0.6.0→v0.6.1, breaking the newest-first order the rest of the file follows.

Documentation

  • New Overflow Menu Items section in the plugin development guide covering renderMenuItems, menuOrder, menuGroup, the menu primitives, and the isOverflowed truth table -- including why it returns false for a slot the consumer removed.
  • api/react.md gains ViewerPluginContext, MenuItemRenderProps, SidebarTabProps, ToolbarSlotProps, the PdfViewerHandle ref handle, and a view-mode types section noting that 'page' and 'none' mean different things in different unions.
  • customization.md gains tables for the panel, tab and welcome-screen tokens, which had none.

[0.7.1] -- 2026-07-26

Fixed

  • Fit-to-width measured the viewer, not the column the page renders into. 0.7.0 adopted fit-width on xs/sm viewers, which is the wrong axis: what has to hold the page is the space left after the sidebar and signature panel take theirs. Measured on a 1024×768 desktop -- a md viewer, 644px, nowhere near narrow -- the document column was 336px against a 735px page, so 419px of the page (57%) opened out of view; at 1280 it was 163px. The test is now the page against its column, at every width, so the same viewer fits at 1024 and keeps the default 120% at 1920 where the page already fits. Unchanged: a fitMode or scale named by the consumer is never overridden, and a reader who zooms owns the zoom from then on.

  • A fit mode turned on after load never recalculated when its column changed. The plugin viewer's fit ResizeObserver read fitMode when the effect was set up and did not list it as a dependency, so a fit chosen later -- by the reader picking Fit Width, or by auto-fit -- got no observer and kept the scale it was given at that instant. Cold-loading a document at 1024 fitted the page against the column it had before the signature panel opened and left it 252px too wide. The observer now reads the fit mode when the column changes. The legacy <PdfViewer> already had fitMode in its dependencies and was not affected.

[0.7.0] -- 2026-07-26

Fixed

  • Toolbar buttons disappeared on narrow viewers with no way to reach them. Reported against Open File: the plugin was enabled, the button was in the DOM, and it was display: none at every phone width. The toolbar collapsed by hard CSS -- .verifykit-toolbar__collapsible > :nth-child(n+3) { display: none } at 768px and the whole group at 580px -- while the overflow menu was a hardcoded list with no view of the plugin registry, so anything hidden that the list had not been taught about was simply gone. Measured at ≤480px, seven controls had no route at all: Open File, Font Size, Theme Toggle, Zoom Out/Zoom/Zoom In and Hand Tool. The count is now zero at every width, verified at 1280 / 768 / 640 / 480 / 390 / 320 and 844×390. The nth-child rule was also spending a collapse slot on OpenFileButton's hidden <input type="file">, so which buttons survived depended on plugin registration order.

  • The overflow menu ignored plugin disablement, and would still download the file. It called downloadPdf from actions directly, so a consumer who disabled the Download plugin -- the control you would reach for when embedding this for confidential documents -- still got a working Download entry in the menu. The same applied to Print, Fullscreen and Document Properties. Menu entries are now contributed by the plugins that own them, so a plugin that is not installed contributes nothing and the leak closes by construction rather than by remembering to check a flag. Verified both ways: with the plugin enabled the entry appears and produces a file; with it disabled there is no slot, no menu entry and no download, even when the consumer explicitly asks for the toolbar button.

  • The document was clipped on phones while the controls that would fix it were unreachable. At 390px the page rendered 735px wide in a 358px column -- 51% off-screen -- at the default 120% zoom with fitMode unset, and Zoom Out was one of the seven controls with no route. The viewer now defaults to fit-width when it is given a container too narrow to show the page whole. An explicit fitMode from the consumer is still respected and left alone.

  • The signature panel took 64% of a phone viewer and could not be resized. It was a fixed height: 70% with .verifykit-sig-panel__resize { display: none } below 640px, leaving the document 202px. It is now a real bottom sheet: three snap points at 35% / 60% / 90%, opening at 35% so the document is readable, with a visible grip that drags freely and snaps to the nearest detent on release, cycles the snap points on tap, and steps them with the arrow keys.

  • Touch targets stayed at mouse size on the devices that have fingers. The coarse-pointer rule carried and (min-width: 769px), so it grew targets on large touch screens and left phones at the 26px desktop size. The width clause is gone: a coarse pointer is a finger at any width, and buttons are now 40px with a 48px toolbar. Menu rows grow with them, since the whole row height is the target.

  • Nothing in the stylesheet keyed on height. A landscape phone gives the viewer about 285px and the chrome was claiming a third of it. Short viewports now trim the toolbar and message bar rather than the document, and the overflow menu caps its height and scrolls instead of running off-screen. A viewport that is short and coarse gets most of the touch target back.

  • Long Document Properties values ran off the dialog. A row overflowed by 226px at 390px with no wrapping -- the Producer string was unreadable. Values now break anywhere. Measured across Document Properties, Keyboard Shortcuts and About: 0px of overflow at 390px.

  • The legacy <PdfViewer> overflow menu had the same Open File gap. It already did container-driven, config-gated overflow, but omitted Open File from the menu at sm and xs, where its inline button is hidden. Added.

Added

  • renderMenuItems -- plugins can contribute overflow-menu entries. A new optional member of ViewerPlugin, mirroring how renderToolbarSlot already worked, collected by collectMenuItems() alongside the existing collectToolbarSlots(). Contributions receive MenuItemRenderProps: the store, the list of overflowSlots, an isOverflowed(slot) predicate, and onClose. isOverflowed deliberately returns false for a slot that was never registered -- a consumer who removed Download through toolbar.transform has said they do not want that action, so the menu must not offer it either. Plugins with no toolbar slot of their own (document properties, shortcut help) skip the check and always contribute. MenuItemRenderProps, MenuItemsRender and CollectedMenuItems are exported.

  • breakpoint and viewerHeight on the viewer store, and data-breakpoint on .verifykit-viewer. Both describe the viewer's own box, never the window. useContainerSize already computed this and CoreViewer was discarding the result.

  • useResizable works on both axes. It was clientX/col-resize only; it now takes a direction of left | right | top | bottom and an optional onResizeEnd, which is what the bottom sheet snaps with.

Changed

  • The overflow menu is assembled from plugin contributions instead of a hardcoded list. MoreMenuDropdown renders its own genuinely global items -- First/Last page, scroll and spread modes, shortcuts, about -- plus whatever the installed plugins contribute. Its direct calls into actions for Download, Print, Fullscreen and Document Properties are gone. This changes behaviour for anyone who was relying on those entries working independently of plugin installation; see the migration guide.

  • The toolbar measures itself instead of hiding by viewport width. Every slot renders inside a stable [data-slot] wrapper -- one wrapper per slot, however many DOM nodes the plugin returns -- and a layout effect measures the row and drops slots in a defined priority order until it fits, reserving room for the menu button. What survives at a given width is now intentional rather than a consequence of plugin registration order, and everything dropped goes to the menu.

  • Width-dependent layout keys off the viewer's box, not the window. The five @media (max-width:) blocks are replaced by rules on [data-breakpoint], published from a ResizeObserver. This is the axis that matters for an embeddable viewer: a viewer in a 380px column on a 1440px desktop is narrow, and a media query cannot see that. Verified -- a 380px wrapper on a 1440px window takes the full narrow layout, overflow menu and bottom sheet included.

[0.6.8] -- 2026-07-26

Fixed

  • The legacy <PdfViewer> never showed Printing or Copying. Its Document Properties dialog reads those two rows from docPermissions, which was declared as useState(null) with the setter dropped -- nothing could ever set it, so the rows were skipped on every document. The permissions are now read during load(), alongside the metadata that populates the rest of the dialog. The 0.6.7 fixes to the printing label and the flag mapping were only reachable through the properties plugin until now.

  • Both viewer paths mapped the /P flags themselves, and disagreed. The plugin and the loader each had their own copy of the bit tests, which is how one of them ended up checking perms.includes(2052) -- a value no PDF ever sets, fixed in 0.6.7. There is now one readDocumentPermissions() in helpers/pdf-encryption.ts, next to the other /Encrypt reading, and both paths call it.

  • DocumentPermissions was returned two-thirds empty behind a cast. The interface has ten fields; both paths filled two (printing, copying) and used as DocumentPermissions to silence the difference. Anything reading encrypted, permissionFlags, modification, annotations, formFilling, accessibility or assembly from the viewer got undefined — falsy, so a permitted operation read as forbidden. All ten are now filled from the flags pdf.js reports, and the cast is gone.

  • Document Properties could show the previous document's values. The dialog kept whatever it last held, so if the metadata or permission read failed for a newly opened file, the rows still showed the file before it. Both are cleared when the loader sees a different document. An appearance swap re-enters load() with new bytes for the same document and deliberately does not clear them.

Changed

  • Unencrypted documents now show "Printing: Allowed" and "Copying: Allowed" instead of omitting both rows. getPermissions() resolves to null when there is no /Encrypt dictionary, and the old code treated that as "nothing to say". It means the opposite -- a document that restricts nothing -- so it maps to every permission granted. encryptionMethod reads None for these, matching the wording the WASM extractor already used.

[0.6.7] -- 2026-07-26

Fixed

  • <CertificateViewer> crashed when it went from no certificates to some. The component returned early on an empty certificate list -- above the two useMemo calls that build the tree order -- so the hook count changed between renders and React threw Rendered more hooks than during the previous render. Every call site inside the viewer happens to guard on hasCerts before rendering it, which is why the demo never hit this; CertificateViewer is a public export, so any consumer that mounted it empty and then populated it did. The two useMemo calls now run unconditionally (they already returned [] for empty input).

  • decompressFlate()'s headerless fallback never ran. The second format in the fallback loop was 'raw', which is not one of the three strings DecompressionStream accepts (deflate, deflate-raw, gzip) -- so the constructor threw on every attempt and the catch swallowed it. Headerless /FlateDecode streams therefore always returned null instead of being decompressed. The format is now 'deflate-raw'. An as any on the constructor argument had been hiding the mismatch from the compiler.

  • Document properties from the properties plugin showed no dates and a wrong printing permission. The plugin built its own metadata object instead of going through the loader's path: creation and modification dates were handed through as raw D:20240319… strings where a Date was expected, so both rows rendered --, and the permission check tested perms.includes(2052) -- 2052 is not a pdf.js PermissionFlag value, so copying and printing were always reported as not allowed. Both now mirror pdf-viewer-load.ts: dates go through parsePdfDate, and permissions are read from the individual flags pdf.js returns (0x04 print, 0x10 copy, 0x800 high-quality print). These were latent behind an as any that made the mismatched shapes typecheck.

  • The Document Properties dialog reported "Allowed" for documents that forbid printing. printing is three-valued -- allowed, low-res, not-allowed -- and the row rendered it as a yes/no truthiness check, so the non-empty string 'not-allowed' read as allowed. It now maps through the three labels, including "Low resolution only", which had no way to appear before.

  • The find bar left its previous search behind when reopened. Closing it reset six pieces of state one setter at a time from an effect; the panel is now mounted only while open, so query, matches, index and error strings go away with it and the injected <mark> highlights are cleaned up on unmount.

Changed

  • extractSignaturesFromPdf() now returns RawPdfSignature[] instead of any[]. The shape has always been fixed by the Rust SimpleSig struct; it is now written down as an exported interface in @trexolab/verifykit-core. Consumers destructuring the result get completion and checking. This is a type-level change only -- the runtime value is unchanged -- but code that assigned the result to a narrower hand-written type may now need to adjust it.

  • Lint is enforced in CI. npm run lint went from 180 errors to 0, and the CI step lost its continue-on-error: true. Roughly 140 of the 180 were React Compiler advisories from eslint-plugin-react-hooks@7, whose recommended preset enables the whole compiler rule set as errors -- for a project that does not run React Compiler. Those three rules (refs, preserve-manual-memoization, immutability) are downgraded to warnings in eslint.config.js for the seven imperative pdf.js integration files only, and remain errors everywhere else so new code cannot drift. The rest were real: the fixes above, plus 24 no-explicit-any sites given real types and seven set-state-in-effect effects rewritten to set state from the event that causes it.

[0.6.6] -- 2026-07-25

Fixed

  • ./package.json is now exported from all four packages. Reading @trexolab/verifykit-core/package.json threw ERR_PACKAGE_PATH_NOT_EXPORTED, because an exports map replaces the old free-for-all file access and this entry was missing. Bundler plugins, resolvers, and version-reporting tooling read that file as a matter of course, and a package with an exports map has to opt back in. Added to core, react, vanilla and plugin-revocation.

Added

  • A Node consumer smoke test (npm run test:node), wired into CI. Every existing check saw the packages through a bundler — the demo app, the docs site, npm run build. Bundlers resolve specifiers Node's ESM resolver rejects, which is precisely how the ERR_MODULE_NOT_FOUND fixed in 0.6.5 survived from 0.3.x to 0.6.4 with every release passing review. The new script packs each package exactly as npm publish would, installs the tarball into a throwaway project, and imports it from a plain Node process: ESM and CJS entries for the core, a real sha256_hex result rather than a mere successful import, the ./package.json subpath, and @trexolab/verifykit-react importing under Node without touching the DOM (the server-render path 0.6.0 fixed). Confirmed to fail on the reintroduced 0.6.4 defects and pass once reverted.

  • A CI workflow (.github/workflows/ci.yml). The repository had none. It runs the Rust unit tests, builds the WASM, typechecks, builds, and runs the Node smoke test on every push and pull request. Two notes for whoever edits it: npm ci needs --ignore-scripts because the root prepare script builds every workspace and the core build has no WASM to embed on a fresh checkout, and lint is reported but not enforced while 180 pre-existing errors remain.

[0.6.5] -- 2026-07-25

Fixed

  • @trexolab/verifykit-core could not initialise under plain Node.js. The built bundle emitted import("../pkg/verifykit_core_wasm") with no file extension. Bundlers (Vite, webpack, Next.js) resolve an extensionless specifier, which is why every browser integration worked and this went unnoticed; Node's ESM resolver does not, so await initWasm() — and therefore createVerifier() and verifyPdf() — threw ERR_MODULE_NOT_FOUND: Cannot find module '…/pkg/verifykit_core_wasm' for any consumer that did not run the package through a bundler. Confirmed by installing the published 0.6.4 tarball into an empty Node project. The import now carries its .js extension, which both Node and every bundler accept.

    This affected Node.js, Deno and Bun consumers of @trexolab/verifykit-core on every release up to and including 0.6.4. Browser consumers were never affected.

  • WASM initialisation logged a deprecation warning to every consumer's console. using deprecated parameters for the initialization function; pass a single object instead appeared once per initialisation (eight times in a typical demo session). wasm-bindgen's generated init takes a single options object; the loader was passing the source positionally. It now passes { module_or_path: … }. Verified in the browser: eight warnings before, zero after, with verification results unchanged.

[0.6.4] -- 2026-07-25

Fixed

  • The document message bar's text ran underneath the "Signature Panel" button. The message was white-space: nowrap without min-width: 0 or overflow, so as a flex item its minimum size was the full width of the text and it refused to shrink — it simply overflowed under the button, which does not shrink. Latent since the bar was written; it became visible in 0.6.3, when the Adobe-worded certification sentence grew to ~140 characters.

Changed

  • The certification banner now fits itself to the space it has. Clipping the Adobe sentence with an ellipsis would cut it mid-email, so the bar instead measures three complete phrasings and renders the longest one that fits:

    Sentence
    fullCertified by <CN> <email>, <OU>, certificate issued by <issuer CN>.
    compactCertified by <CN>, <OU>.
    minimalCertified by <CN>.

    Measuring rather than switching on viewport breakpoints matters because the length of this sentence comes from the certificate, not the window: a long common name has to degrade sooner than a short one at the very same width. The permission sentence ("No changes to the document are permitted.") is dropped when the message needs its room — it remains in the tooltip and the signature panel — and the full text is always available as the bar's title. Below the narrowest complete phrasing, an ellipsis is the floor.

    Non-certified documents are unaffected: their messages are short, single-variant, and render exactly as before.

[0.6.3] -- 2026-07-25

Fixed

  • Certified (DocMDP) documents were not recognised as certified, so the banner showed the generic "signed" message where Adobe shows "Certified by …". The DocMDP permission extractor required /TransformParams to follow /DocMDP immediately. PDF dictionaries are unordered, and iText — among others — writes /Reference[<</TransformMethod/DocMDP/Type/SigRef/TransformParams<</P 1…>>…], placing /Type/SigRef between the two. Such files were read as uncertified: mdpPermission stayed null, the message bar fell through to "Signed and all signatures are valid.", and the panel's CERTIFIED badges never appeared. Extraction is now order-independent and bounded by the enclosing dictionary, so it also handles a /P that is not the first /TransformParams key. The Catalog's /Perms<</DocMDP N 0 R>> reference continues to be ignored, as it carries no permission value.

    Signature validity is unaffected: apply_doc_mdp_rules only invalidates a P=1 certification when the certifying signature does not cover the whole file, which is checked independently of this extraction.

Changed

  • The certification banner now matches Adobe's wording. It read Certified by <name>.; it now reads Certified by <CN> <email>, <OU>, certificate issued by <issuer CN>. — the same sentence Adobe Reader shows, built from data already present in CertificateInfo (subject, issuer, and the rfc822Name SAN). Every part degrades independently, so a certificate without an email or organisation still produces a clean sentence, and a signature with no certificate falls back to Certified by <name>.

    The organisation segment prefers the subject's OU, falling back to O. This matches the observed Adobe output for a certificate whose OU=TEST and O=TEST CERTIFICATE.

[0.6.2] -- 2026-07-25

Infrastructure and documentation. No SDK code changed — installing 0.6.2 gets you the same viewer as 0.6.1.

Fixed

  • The registry kept only the newest release, so pinning an older version 404'd. pack:registry wiped the tarball directory on every run; the moment 0.6.1 shipped, npm install @trexolab/verifykit-react@0.6.0 failed. Because that directory is gitignored, the old tarballs were gone rather than merely unreferenced. Versions now accumulate: the script merges into the existing manifest and recomputes latest as the highest version present, so re-packing an older release cannot demote a newer one. The /api/registry route serves every version, each with its own shasum, integrity hash and tarball URL — npm verifies these per version, so a shared checksum would break pinned installs. 0.5.13, 0.5.14 and 0.6.0 have been rebuilt from their git tags and restored, and each advertises the dependencies it actually shipped with (pdfjs-dist for 0.5.x, none for 0.6.x).
  • The registry advertised pdfjs-dist as a dependency of @trexolab/verifykit-react after it was bundled. The manifest contradicted package.json, so a consumer resolving through the registry installed ~10 MB of pdf.js the viewer does not use. The dependency builder also defaulted to '*' for any key it could not find, which is why removing it from package.json was not enough; it now skips and warns instead of inventing a range.
  • sync-version now updates the "Packages" version tables in README.md and docs/README.md. These were bumped by hand every release and had drifted — docs/README.md sat on 0.5.12 for four releases.

Documentation

  • Package READMEs caught up with v0.6.0's bundling change: npm ls pdfjs-dist / npm install pdfjs-dist@5.5.207 troubleshooting replaced with the real failure mode (a workerUrl pointing at a mismatched worker), and asset-copy commands repointed from node_modules/pdfjs-dist/ to node_modules/@trexolab/verifykit-react/. The docs/ tree had already been corrected in 0.6.1; this brings packages/*/README.md in line.

[0.6.1] -- 2026-07-25

Fixed

  • Signature Properties showed encrypted bytes as the Field Name. On a password-protected PDF the dialog's "Field Name" row rendered mojibake such as {:žhe*K÷#ΘoIT{… instead of a real name. Field names are among the values encrypted in a password-protected document, and the Rust/WASM core does not decrypt them, so PdfSignature.fieldName can carry undecodable bytes on such a file. v0.5.13 added that row, which surfaced them for the first time. The dialog now shows the decrypted name the viewer already has for that widget, so it reads Signature1 / Signature2 as expected, and omits the row entirely where no readable name is obtainable rather than printing bytes. PdfSignature.fieldName itself is deliberately unchanged — other parts of the pipeline depend on its current value, so if you display it yourself, guard for undecodable bytes on encrypted documents.
  • Synthetic "unverified" signatures no longer show ciphertext as the signer name. On an encrypted PDF the names makeUnverifiedSignature() reads are undecodable for the same reason as above. The panel title now falls back to Signature N when the value is not displayable.

Added

  • SignatureDetailsTab accepts an optional fieldName prop — the decrypted name to display, overriding sig.fieldName. Backward compatible: when omitted, sig.fieldName is used if it is displayable and the row is hidden if it is not.

[0.6.0] -- 2026-07-25

Added

  • Next.js works out of the box — no ssr: false, no next/dynamic, no wrapper file. @trexolab/verifykit-react now ships a 'use client' directive, so it is its own client boundary and can be imported straight from an App Router Server Component. Verified server-rendering and hydrating with zero hydration warnings under both Turbopack and webpack, in dev and in production builds. The demo app's own dynamic(() => import("@/components/DemoViewer"), { ssr: false }) workaround has been deleted as proof. ssr: false still works if you already have it, so upgrading forces no change.

Fixed

  • useVerification().load() no longer throws "Verifier not configured" when called from a mount effect. VerifyKitProvider creates the WASM verifier in an effect, and React runs child effects before parent ones — so a consumer loading a document on mount arrived before the verifier existed, and waitForVerifier() threw instead of waiting, despite its name. The provider now hands out a latch promise created during render (pure allocation, safe on the server) which the effect settles, so an early caller simply waits. createVerifier() is also cached per config identity, so React StrictMode's deliberate double-mount no longer initialises WASM twice.

Changed

  • pdf.js is loaded lazily and bundled into its own chunk. It was previously a static top-level import and an external peer-style dependency. Two consequences of that arrangement blocked server rendering: importing it under Node threw ReferenceError: DOMMatrix is not defined (before any component rendered, so no typeof window guard could help), and webpack could not place pdf.js's .mjs — itself a webpack bundle — into an async chunk without failing with TypeError: Object.defineProperty called on non-object. pdf.js now loads inside the effects that use it, from a chunk this package bundles itself, which resolves both. It is therefore no longer in the initial bundle at all.
  • The package ships unminified. esbuild's name mangling corrupts pdf.js's private class fields when bundling it here, producing output SWC rejects with duplicate private name. Consumer bundlers minify anyway, and stack traces stay readable.
  • pdfjs-dist is no longer an external dependency of @trexolab/verifykit-react. It is bundled into the lazy chunk, so consumers no longer need it installed or aliased — and can no longer swap the build via bundler alias.

[0.5.14] -- 2026-07-25

Fixed

  • Encrypted PDFs: the on-page signature icon now swaps to its verified state in every integration, not just the demo app. The encrypted appearance swap shipped in 0.5.11 needed the password PDF.js had accepted, but the only route from the viewer (which owns the password dialog) to useVerification (which owns the swap) was the optional onPasswordAccepted prop — and nothing in the SDK wired it. Only the hosted demo passed it, so every other integration kept showing Adobe's yellow "?" on the page while the signature panel, status bar and Signature Properties dialog all correctly said valid. The accepted password now travels automatically through VerifyKitProvider, so React (Viewer, CoreViewer and the legacy PdfViewer), @trexolab/verifykit-vanilla and Vue all show the validated icon with no changes in the host app. onPasswordAccepted and applyPassword keep working as an opt-in escape hatch for hosts that drive their own PDF.js instance; a password reported twice is ignored rather than swapping twice.
  • Owner-password-only PDFs are no longer skipped. A PDF that is encrypted but opens with an empty user password never triggers a password prompt, so no password was ever recorded and the swap was skipped exactly as if the document were locked. The viewer now detects encryption directly (via PDF.js getPermissions()) and reports the validated empty password, and the swapper distinguishes "no password known" (undefined, leave the file alone) from "empty password" ('', a real key input). Unencrypted documents report nothing, so they never pay for a second swap pass.
  • Legacy PdfViewer: encrypted documents no longer re-prompt for the password after the swap. The appearance-swapped bytes are still encrypted, but PdfViewer neither remembered the accepted password nor passed one when re-parsing, so they triggered a fresh password dialog and its useSwapAppearance re-parse failed outright on encrypted files. It now remembers the password for the lifetime of the document (keyed on fileName, so a genuinely new document still prompts) and reuses it for both re-parse paths, matching CoreViewer.

Security

  • The appearance swap now validates that it can safely rewrite the document before it writes anything. PDF.js also unlocks a document when the owner password is typed rather than the user password, and in that case the swap cannot produce a file that opens again. It is now detected up front and the original bytes are returned untouched instead (the "?" simply stays). The original signed bytes are never modified in any path.

[0.5.13] -- 2026-07-24

Fixed

  • Invalid / ObjStm-embedded signatures now show the signer's name and certificate (Adobe parity). A signature the engine cannot fully verify — an illegal /Contents, an ObjStm-embedded signature, or a CMS that fails SignedData validation — previously fell back to the signature dictionary's /Name in the panel title, which for many signing tools is the software name (e.g. ATick_dart 1.0.7), not the person. The engine now extracts the signer certificate from the CMS even when the signature cannot be validated — preferring the exact signer by IssuerAndSerialNumber, otherwise the leaf certificate — so the panel title shows the signer CN (e.g. Test), the organisation, and the certificate chain, exactly as Adobe Reader does. This is display-only: the signature's overall status stays Invalid and every per-check result remains unknown / "not evaluated".
  • Signature Properties: the signed-certificate view now shows the Field Name. The "Field Name" row (AcroForm /T) previously appeared only for signatures without a certificate. It now also appears in the Signer section for signed signatures, so the field name is always visible.

[0.5.12] -- 2026-07-16

Added

  • Mobile-responsive viewer and demo. On phones the signature panel no longer collapses the page to a sliver: below 640px it drops out of the flex row and docks to the bottom as a full-width sheet (rounded top, dismissable), so the document keeps full width with the panel open. The demo's "Live Demo" sidebar becomes an overlay drawer below 768px — it auto-collapses when a document loads so the viewer gets full height, slides in from the toggle, and closes on a tap of the dim scrim. Desktop layout is unchanged (fixed sidebar column + right-side signature panel).

[0.5.11] -- 2026-07-16

Fixed

  • Encrypted PDFs: the on-page signature icon now reflects verified status (green check), not Adobe's yellow "?". Since 0.5.9 the appearance swap was skipped entirely for password-protected PDFs, so encrypted documents kept the signer's static yellow "?" on the page even when the signature panel showed valid. The swap now runs for them too, once the viewer has accepted the password. The original encrypted bytes stay byte-for-byte intact, the password still opens the document, and Download returns the pristine original — the swapped copy is display-only. Documents using the AES-128 Standard security handler are supported; any other cipher, a missing password, or any failure falls back to leaving the page untouched — never worse than the "?" shown before. Non-encrypted PDFs are byte-for-byte unaffected.
  • Multi-signature appearance mapping made robust. When signature fields could not all be matched to signatures by name, the positional fallback miscounted documents where one signature is drawn on several pages, or where a field's appearance sits on a child widget rather than the field itself — so a later signature could be given an earlier one's icon. Fixes the second signature's icon in two-signature documents.

[0.5.10] -- 2026-07-16

Fixed

  • Encrypted PDFs: on-page signature fields are now interactive (no longer treated as unsigned). In a password-protected PDF the on-page signature fields rendered as empty unsigned placeholders and clicking them did nothing, even though the signature panel already showed the correct verified status. The core cannot decrypt PDF string objects, so a signature field's /T name is read as encrypted bytes that never match the signer name PDF.js exposes after decryption — and the CoreViewer signature plugin mapped widgets to signatures per-page by name only, leaving encrypted fields unmatched (sigIndex < 0 → unsigned styling and a dead click). The plugin now aggregates raw widgets across all rendered pages and maps them globally through the shared computeWidgets resolver (name match first, then an order-based fallback), so encrypted fields resolve to their signatures and become clickable — opening the Signature Properties dialog. Ordinary PDFs are unaffected: when every signature matches by name, no signatures remain for the fallback to place, so genuine unsigned placeholders still render as unsigned.

Added

  • Certification identification in the signature panel (Adobe parity). A certifying (DocMDP) signature now shows a CERTIFIED badge in its panel row, with a tooltip naming the permission level (e.g. "Certification signature (DocMDP P2) — Only form filling and digital signatures are allowed."). Approval signatures are unchanged, and the timestamp/document-timestamp rows never show it. The permission wording is shared with the document message bar (mdp-utils) so the badge and the bar always agree.

[0.5.9] -- 2026-07-16

Fixed

  • Certified-then-approved signatures no longer wrongly invalidated (DocMDP, Adobe parity). In a document that was certified (author signature with a DocMDP transform) and then approved by a second signature, the first (certification) signature was reported invalid with "The document has been modified or corrupted since this signature was applied." The DocMDP permission extractor was matching the Catalog's /Perms /DocMDP <ref> indirect reference — which carries no /P value nearby — and defaulting to P = 1 (no changes permitted), so the legitimate later approval signature looked like a forbidden modification. The extractor now reads the certification's /DocMDP /TransformParams << … /P N >> form specifically; a P = 2 certification (form-fill and signing allowed) correctly permits the second approval signature, and both signatures show valid — matching Adobe Reader.
  • Password-protected PDFs: correct password no longer rejected. For an encrypted PDF, the signature-appearance swap produced a byte stream the viewer could no longer open, so PDF.js rejected even the correct password ("Incorrect password. Please try again."). The appearance swap and the unverified-signed-field safety-net scan now skip encrypted documents entirely, so PDF.js receives the original bytes and the correct password opens the document. Verified signature status is unaffected (the panel still shows it); only the on-page appearance swap is skipped for encrypted docs.
  • Password dialog: reveal toggle stays centered. The show/hide-password eye toggle could drop below the input's text area after hover or click, because its mouse handlers reset the button's CSS transform to empty and discarded the translateY(-50%) that vertically centers it. The handlers now preserve the centering transform, so the toggle stays put through all hover/press/release states.

[0.5.8] -- 2026-07-15

Added

  • Support information in the signature panel. For an INVALID signature, a collapsible "Support information" section surfaces the engine's own per-check technical reason (e.g. SigDict /Contents illegal data, a revocation reason, a byte-range hash mismatch), with a Copy action for bug reports. Collapsed by default; shown only for errors, so the actual issue is always discoverable without cluttering valid/warning cases.

Fixed

  • Navigation: linked previously-orphaned pages. /faq (now in Footer › Resources) and the /docs/vue (Vue Integration) and /docs/desktop-extensions (Desktop Extensions) guides were reachable by URL but not linked anywhere. They are now in the footer and the docs sidebar (plus the docs prev/next order). Pages that exist for our own use remain intentionally unlinked.

[0.5.7] -- 2026-07-15

Fixed

  • ObjStm-embedded signatures are now detected. A signature whose dictionary lives inside a compressed object stream (/Type /ObjStm) — e.g. a re-encoded/re-saved signed PDF — was previously missed by the raw-text scan, so the field showed the signer's baked-in appearance (a green "Signature Valid" check) and was treated as unsigned. The core now decompresses object streams, detects the signature, and reports it as invalid ("SigDict /Contents illegal data") — matching Adobe Reader.
  • Malformed-signature messaging corrected (Adobe parity). For a signature whose /Contents cannot be parsed, per-check results are now unknown ("not evaluated") with the CMS check carrying the real reason, instead of fabricating specific failures. The panel/modal no longer claims "The document has been modified or corrupted", "The signer's certificate has been revoked", or "A forbidden algorithm was used" for a signature that was never actually verified — it now shows Adobe's wording ("There are errors in the formatting or information contained in this signature", "Signer's identity has not yet been verified").
  • Signature appearance now reflects the real verdict. Fixed a document-loader race in the core viewer where the initial original-bytes load could overwrite the appearance-swap re-parse, leaving the canvas showing a signer-controlled appearance while the store held the swapped bytes. An invalid signature now renders a red ✗ / "Signature Invalid".
  • Ctrl/Cmd+wheel zoom now works. The zoom plugin attached its wheel listener to the scroll container at install time, before it mounted, so it never registered. It now attaches to the always-mounted viewer container and resolves the scroll container per event. Plain wheel scrolls vertically; Shift+wheel scrolls horizontally.
  • Hardened the ObjStm parser against out-of-order object-stream offsets (panic guards + MAX_SIGNATURES cap) in both signature_extraction and field_resolution.

Added

  • Viewer safety net for unverified signed fields. A /Sig field carrying a signature value the engine did not verify is surfaced as a synthetic "unknown — could not verify" entry (grey "?" icon), so a signer-controlled "valid" appearance can never imply validity.
  • Security docs: client-side Trust Boundary. docs/security.md now states that VerifyKit's verdict is computed on the client for the viewing user and is not a server-side attestation — run verification server-side for backend security decisions.

[0.5.6] -- 2026-06-06

Changed

Internal refactor (no behavior change)

  • Large modules split up across all packages, with every public API preserved by re-export -- no consumer code changes required. Every commit in the 0.5.6 sequence was gated on a browser test asserting the signature panel reaches the same state as the 0.5.5 baseline, so the refactor is verified end-to-end rather than only type-checked.

[0.5.5] -- 2026-06-06

Fixed

Verification engine (core)

  • CMS signatures without signedAttrs are now accepted (RFC 5652 §5.4). Some signing engines — notably Indian DSC tokens (Capricorn, eMudhra, etc.) used via Adobe Reader DC — produce adbe.pkcs7.detached signatures whose SignerInfo has no signedAttrs block at all. Per RFC 5652 §5.4 this is legal for id-data content: the signature is computed directly over the message digest of the content, with no separate messageDigest attribute. Adobe Reader DC and openssl smime -verify both accept this form; VerifyKit was rejecting it with "Document has been modified or corrupted since this signature was applied" + "No messageDigest attribute in signedAttrs" + "cryptographic signature could not be fully verified". Two fixes:
    • check_integrity no longer fails when messageDigest is absent. A new integrity_result_no_signed_attrs path returns Valid (or coverage-based Warning) with an explanation that the cryptographic signature step performs the digest match. The byte-range / DSS / "later signatures" reporting is preserved.
    • manual_verify_cms gains an external_signed_bytes parameter, used as the fallback when both signedAttrs and encapContentInfo.eContent are absent. The signature is then RSA-verified against Hash(signed_bytes) — the same path openssl smime -verify -content <bytes> takes. RFC 3161 timestamp verification continues to pass None (eContent is sufficient there).

[0.5.4] -- 2026-04-29

Fixed

Verification engine (core)

  • DSS indirect array references now resolved correctly. The Document Security Store parser previously only handled inline arrays (/OCSPs [77 0 R 78 0 R]); when a PDF stored the array as a separate object (/OCSPs 84 0 R where object 84 is the array), OCSPs/CRLs/Certs were silently read as empty. Added resolve_dss_array which tries inline first and falls back to dereferencing the indirect object. Fixes LTV detection and PAdES level reporting (was B-T, now correctly B-LTA) for PDFs produced by Adobe Acrobat, DocuSign, and other signers that emit DSS with indirect arrays.

  • dss_only_beyond_coverage scan window expanded to the full file. The check that detects whether content beyond a signature's byte-range is only DSS/revocation data (a permitted post-signing update) was limited to the first 100 KB past the coverage end. For large PDFs or late-appended DSS dictionaries that fall beyond that window the check incorrectly treated the content as a modification and returned unknown for integrity. The window is now the full remaining file length.

  • is_last_signature determined by byte-range coverage end, not vector index. When expand_shared_sig_fields clones a CAdES signature value across multiple AcroForm fields, the clones are appended to the end of the signature vector. The previous idx == sig_count - 1 logic picked a clone as the "last" signature, causing a false "document has been modified since it was signed" warning on the actual last signature (the RFC 3161 document timestamp). The flag is now set on whichever signature has the highest offset2 + length2 byte-range end, which is always the document timestamp.


[0.5.3] -- 2026-04-24

Added

Verification engine (core)

  • Configurable algorithm policy — Adobe-parity default for deprecated algorithms with opt-in strict mode.
    • New AlgorithmPolicy type + VerifyKitCoreConfig.algorithmPolicy option. Shape: { sha1: 'valid' | 'warn' }.
    • Default is { sha1: 'valid' } to match Adobe Reader: SHA-1 family signatures pass the algorithm-strength check and surface the algorithm name via the new SignatureCheckResult.algorithmName field, so UIs can render a "Signed with SHA-1" disclosure without marking the overall signature as warning.
    • Set { sha1: 'warn' } to surface SHA-1 signatures as warnings (pre-0.5.3 behavior).
    • Runtime helpers setAlgorithmPolicy(policy) / resetAlgorithmPolicy() exported from @trexolab/verifykit-core and @trexolab/verifykit-react.
    • MD5 / MD2 / MD4 remain hardcoded Invalid and cannot be overridden — they are cryptographically broken, not merely deprecated.
  • SignatureCheckResult.algorithmName — optional human-readable algorithm name (e.g. "SHA-1", "SHA-256 with RSA") populated by the algorithm-strength check regardless of status, enabling integrators to build their own badge UI.
  • CertificateInfo.rawDer (Uint8Array) — raw DER-encoded certificate bytes are now included in the WASM output so UIs can offer per-cert and chain export. The verification pipeline never reads this field; it is serialization-only (#[serde(skip_serializing_if = "Option::is_none")]).
  • buildCertChainPkcs7(certs) — new core helper that assembles a degenerate CMS SignedData (certs-only PKCS#7 / .p7b) from a CertificateInfo[]. Pure Rust ASN.1 encoding, no cryptographic operations, completely decoupled from the verification pipeline.

React / viewer

  • Certificate chain export from the Signature Properties dialog. The Certificates tab (and the TSA tab when TSA certs are embedded) now shows an Export Chain ▾ dropdown with three formats:
    • PEM chain (.pem) — all certs concatenated as -----BEGIN CERTIFICATE----- blocks, ready for Unix / server / Java trust stores.
    • PKCS#7 bundle (.p7b) — Windows / Adobe Reader native import format.
    • All certificates (.zip) — one NN-<CN>.pem + NN-<CN>.cer pair per cert, written by a dependency-free stored-ZIP writer (CRC-32 + local / central / EOCD records). Exports sort from signer to root.
    • Default filename: <signerCN>-chain.{pem,p7b,zip} with filesystem-safe sanitization.
    • Existing per-cert PEM / DER buttons in CertificateViewer.DetailPanel now actually render (they always existed but required rawDer), covering "export signer" and "export any individual certificate" without new UI.
  • exportCertChainAsPem, exportCertChainAsPkcs7, exportCertChainAsZip re-exported from @trexolab/verifykit-react alongside the existing exportCertAsPem / exportCertAsDer.
  • React signature list panel shows an Adobe-style Signed with SHA-1 (legacy algorithm). disclosure in the expanded signature summary when a legacy algorithm is detected.
  • Configurable zoom range. zoomPlugin() now accepts ZoomPluginOptions { minScale?, maxScale?, step? }; defaultLayoutPlugin({ zoom }) and vanilla VerifyKit.create({ zoom }) pipe the same option through. The zoom dropdown filters presets to the configured range so no unreachable level is ever offered. New preset levels: 25%, 600%, 800%, 1000%.

Documentation site

  • /faq page with 12 developer-focused questions and answers.

Documentation

  • docs/security.md — new "Algorithm Policy" section (tier table, Adobe-parity rationale, opt-in code sample).
  • docs/api/core.mdAlgorithmPolicy type, setAlgorithmPolicy / resetAlgorithmPolicy function references, algorithmName field on SignatureCheckResult.
  • docs/api/react.mdZoomPluginOptions documented on zoomPlugin() and DefaultLayoutPluginOptions.
  • docs/customization.md — new "Zoom range" section with React à-la-carte, defaultLayoutPlugin, and vanilla examples.

Changed

Behavior

  • Default overall status for pure-SHA-1 signatures — signatures whose only issue was the SHA-1 algorithm previously produced overallStatus: 'warning'; with the new Adobe-parity default they produce overallStatus: 'valid'. Integrations relying on the old behavior can pass { algorithmPolicy: { sha1: 'warn' } } to createVerifier() or call setAlgorithmPolicy({ sha1: 'warn' }).
  • Default zoom range — min 0.40.25, max 510 (40%–500% → 25%–1000%, matching pdf.js). Restore the old range with zoom: { minScale: 0.4, maxScale: 5 }.

Visual / theme

  • Light-theme contrast tuned across the docs site and the SDK viewer (CSS tokens only, no component changes). Docs-site card/border tokens widened so cards are distinguishable from the background; SDK viewer --fg-muted, --fg-subtle, --border, tab-bar / status-bar / find-bar / floating-menu text tokens darkened to hit WCAG AA 4.5:1 on white. Dark theme untouched.

Repository layout

  • Internal reorganisation of the monorepo. No published package name, entry point or API changed.

Fixed

Verification engine

  • Legacy adbe.pkcs7.sha1 signatures now verify correctly. Signatures using the PDF 1.3 PKCS#7 format — where encapContentInfo.eContent IS the SHA-1 hash of the byte-range bytes and signedAttrs are absent — were previously reported as INVALID with "document has been modified or corrupted." The Rust engine now recognises this format: the integrity check compares SHA-1(signed_bytes) against eContent, and CMS verification falls back to verifying the signature directly over eContent per RFC 5652 §5.4 when signedAttrs are missing. Restores Adobe Reader parity for multi-signature PDFs that mix a modern CAdES certification signature with a legacy SHA-1 approval signature.

JS / React wrapper

  • rehydrateDates() was destructuring Uint8Array values returned from WASM into plain {0: byte, …} objects because the recursive walker did not special-case typed arrays. Binary fields (e.g. the new CertificateInfo.rawDer) now pass through verbatim. Date rehydration itself is unchanged.
  • TypeScript 5.7+ BlobPart narrowingUint8Array<ArrayBufferLike> (which includes SharedArrayBuffer) is no longer assignable to BlobPart. The two new chain-export helpers (exportCertChainAsPkcs7, exportCertChainAsZip) now cast to BlobPart explicitly; runtime buffers are always plain ArrayBuffer, so behavior is unchanged.
  • Certificate chain row in CertificateViewer mixed shorthand (borderLeft, border: 'none') with longhand (borderLeftStyle/Width/Color) properties on the same <button>, triggering a React dev-mode warning. Replaced with per-side longhand properties; visual output is unchanged.

Documentation site

  • Documentation routes returned 404 in production after the repository reorganisation above. Fixed.

[0.5.2] -- 2026-04-22

Fixed

  • PAdES level detection now correctly reports B-LTA for signatures covered by a later ETSI.RFC3161 document-time-stamp when the DSS carries complete chain revocation data. Previously, a signature whose embedded TSTInfo failed to parse was demoted to B-B and the archive-timestamp promotion step only handled B-LT → B-LTA, so such files were misclassified relative to Adobe Reader DC.
  • Timestamp evidence for PAdES classification now recognises the presence of the signature-time-stamp unsigned attribute (matching Adobe Reader), instead of requiring a fully verified TSTInfo.
  • Archive-timestamp promotion extended to cover B-B / B-T → B-LTA when the covered signature has complete chain revocation data, not just B-LT → B-LTA.

[0.5.0] -- 2026-03-26

Improved

  • Full codebase audit: resolved all TypeScript type errors, unused variables, and dead code across core, react, and plugin-revocation packages
  • Rust/WASM engine: zero clippy warnings — replaced manual iterator patterns with idiomatic contains(), strip_prefix(), ? operator, and const thread-local initializers
  • Removed dead functions (injectIconIntoStream, getBannerSub), unused imports, and stale constants
  • Fixed type safety in plugin-revocation handler and proxy request paths
  • ESLint configuration overhauled: proper ignore patterns for generated files, _-prefix convention for unused params, removed inapplicable react-refresh rules
  • All workspace dependency references aligned to wildcard (*) for consistent local resolution
  • Fresh production builds verified clean across all packages

[0.4.6] -- 2026-03-26

Added

  • PAdES conformance tab in signature properties with progress bar and level timeline
  • PAdES level badges (B-T, B-LT, B-LTA) and TIMESTAMP badge in signature panel
  • Document Timestamp label and TSA name display for ETSI.RFC3161 signatures
  • SDK logo in viewer About modal, documentation header, and favicon
  • Branded name with logo colors (Verify in cyan, Kit in default)
  • About modal redesigned with logo, capability pills, link cards, and TrexoLab product link
  • Five new documentation pages: Security, Testing, Internationalization, Browser Support, Error Reference
  • Documentation version badge in website header (read from root package.json at build time)

Fixed

  • PAdES B-LTA detection: upgrade B-LT signatures when followed by RFC3161 document timestamp
  • PAdES level detection restricted to ETSI signatures only (PKCS#7 signatures no longer incorrectly tagged as PAdES)
  • padesLevel field now optional (null for non-PAdES signatures like adbe.pkcs7.detached)
  • Documentation audit: 15 files corrected (wrong props, broken examples, stale versions, missing types)

Changed

  • Details tab redesigned: verdict-first layout, verification checks promoted, signer card with avatar
  • Removed GitHub references from documentation website
  • All hardcoded version numbers removed from documentation (single source of truth via root package.json)

[0.4.5] -- 2026-03-26

Fixed

  • Display status aligned with Adobe Reader DC default behavior — valid signatures with unchecked revocation now show as "Valid" instead of "Unknown" (matches Adobe's non-strict revocation mode)
  • Missing React key props in plugin overlay, right panel, and page overlay collection functions

Added

  • Changelog page added to documentation site
  • Mermaid diagram rendering support in documentation
  • Architecture diagrams (SDK overview + verification pipeline) using Mermaid
  • "Adobe Reader Aligned" capability added to About modal
  • All four packages now release on a single synchronised version number
  • Version and release guide (docs/creating-a-package.md)

Changed

  • About modal redesigned — technology-neutral descriptions, cleaner layout with bordered cards, copyright footer
  • Polyfills no longer need separate import — auto-included via main entry point

[0.4.4] -- 2026-03-25

Added

  • Certificate chain resolution with AKI/SKI matching and PKCS#7 embedded certificate support
  • Signature status icon artwork refreshed (build-time change; no API or output difference for consumers)

Fixed

  • Signature display aligned with Adobe Reader for expired certificates, certification level, and N4 text
  • Keyboard shortcuts now toggle instead of one-shot activate
  • Multi-field signatures sharing a single PKCS#7 blob are correctly detected
  • Unknown signature status aligned with Adobe Reader DC behavior
  • Signature validation for malformed PDFs aligned with Adobe Reader
  • Status icons no longer rendered with missing strokes on some signature fields

Changed

  • Documentation synced with recent SDK changes
  • Homepage redesigned with stone palette, SEO metadata, and grid sections
  • Demo site restyled with dark-first design tokens, Geist fonts, muted palette, and flat styles

[0.4.3] -- 2026-03-17

Fixed

  • ETSI.RFC3161 document timestamp integrity check -- use TSTInfo messageImprint instead of signedAttrs messageDigest for document timestamps (PAdES B-LTA)
  • Document timestamp EKU validation -- accept id-kp-timeStamping (1.3.6.1.5.5.7.3.8) for TSA certificates instead of requiring document-signing EKUs
  • PDF field name extraction from binary stream data -- strict delimiter validation prevents /T matches inside compressed streams from corrupting the field name
  • Signature widget overlay matching for multi-page fields -- handle sibling-replica suffix (_sr{N}) and shared /V reference patterns
  • Status icon placement for PDFs where several signature fields share a single signature value -- each field now gets the icon for the signature it belongs to
  • Revocation check leniency for failed document timestamps -- require valid CMS signature before granting timestamp grace period

Added

  • Scroll-to-signature on panel click -- viewport centers on the signature field with 4-blink highlight animation (matching Adobe Reader behavior)
  • Widget registry in signature plugin for cross-page widget lookup
  • Shared field-name-utils.ts utility for normalized field name matching across the codebase

Changed

  • Overall verification messages use "Document timestamp" language for ETSI.RFC3161 signatures instead of generic "Signature" wording
  • Document timestamp signing time extracted from TSTInfo genTime (the CMS itself is the timestamp)
  • Extracted duplicated field name suffix stripping into shared normalizeFieldName() / isSameFieldFamily() helpers (was duplicated in 3 files with inconsistent order)
  • Removed redundant overall_message.to_string() allocation and trivial extract_tst_gen_time_from_econtent wrapper
  • Marked file-dependent integration tests with #[ignore] for CI compatibility

[0.4.2] -- 2026-03-16

Changed

  • Enable wasm-opt -Oz post-compilation pass -- WASM binary reduced by 15.7% (1297 KB to 1093 KB)
  • Smaller WASM binary, with no loss of verification speed
  • Async base64 decode via fetch(data:...) in browser -- avoids 1.3 MB intermediate atob() string, reducing peak init memory
  • Trust store refactor: removed unused all field, ownership transfer instead of cloning, single-pass classification with inline root_dns build -- reduces heap allocations by ~1 MB

[0.4.1] -- 2026-03-16

Fixed

  • Support BER indefinite-length CMS encoding (0x30 0x80) -- PDF signatures using this encoding were rejected by the ASN.1 parser, causing all verification checks to cascade-fail
  • DSS extraction now resolves latest object revision in incremental PDF updates -- fixes LTV/DSS revocation data not being found when the catalog was updated after signing

Changed

  • Bump version to 0.4.1

[0.4.0] -- 2026-03-16

Changed

  • Version bump to 0.4.0

[0.3.3] -- 2026-03-16

Changed

  • Flicker-free appearance swap for PDF page rendering
  • Single-render VerificationFloater for improved performance

Added

  • Automated registry and CDN publishing

[0.3.2] -- 2026-03-15

Added

  • Embedded VerificationFloater component in the viewer
  • Smart context menu with signature-aware actions
  • Live demo page
  • CORS headers on revocation API route for cross-origin requests
  • Revocation guide added to documentation navigation

Changed

  • workerUrl is now mandatory -- removed auto-detection and bundled worker
  • Updated all documentation for mandatory workerUrl configuration
  • Updated Viewer props API

Fixed

  • pdfjs-dist getOrInsertComputed compatibility error
  • Polyfill issues resolved

[0.3.1] -- 2026-03-13

Added

  • Base64-embedded WASM binary for universal bundler compatibility -- no external .wasm file needed, works in Vite, webpack, Next.js, Node.js, Deno, and Bun with zero configuration
  • Comprehensive documentation update covering all packages
  • pack-registry script for building registry tarballs

Changed

  • Aligned all packages to v0.3.1
  • Fixed type declaration paths across all packages
  • Removed registry tarballs from git tracking