Conversation
The kernel has one plugin shape and one context. A plugin is
`definePlugin({ id, token, scope, requires, optional, state, create })`, and
`create(ctx)` returns `{ api, connect }`. The action/reducer path is deleted:
`initialState`, `reduce`, `capability`, `init`, `effects`, `dispatch`,
`getState`, `onAction`, `EffectContext` and the `CORE_*` actions. The
controller context is merged into `PluginContext`.
- State: `ctx.state` (`get`, `update(transition, ...args)`, `onChange`),
`ctx.notify()` for resources, `ctx.watch()` for state a plugin does not own.
- Engine data: `ctx.mirror()` and `ctx.pageMirror()` load after connect and
fold confirmed document events from every origin, with cursor replay, page
reloads and resync on desync or a new version. `memo`/`memoByKey` give
identity-stable reads; `createHostToken` builds a plugin's host lens.
- Engine invariant: an own mutation's event is published before its promise
settles. The conformance suite checks it; local and cloud engines pass.
- Plugins: every plugin runs on the new context. Annotation and form stay in
sync through mirrors instead of re-reading after writes; a create is matched
to its confirmation by the client-assigned /NM.
- Fixes: parked signings the engine no longer knows; disarms made by the
annotation plugin; raster invalidation for flattens, signatures and desyncs;
link and selection re-reads after redactions and flattens; redaction mark
verbs reject instead of throwing; command definitions can resolve any
capability in development builds (`resolvesAnyCapability`).
- Names: `pageObjectNumber` for `pon`; `Point`, `ContentGeometry`,
`ModelAnnotation`, `Message`; `kind` for things that are, `type` for things
that happen; no single letters or retired abbreviations. Public renames:
`usePages().previous`, `ChromeGeometry`, `Paint.lineCap`, draft `current`.
`useMeasurement()` drops `canCalibratePage`; read `canCalibrate()` with a
selector. `/internal` entries are removed.
- Comments state present behavior, with no plan, phase, history or missing-doc
references and no capitals for emphasis.
- Conventions live in docs/conventions/ (architecture, state and sync, events,
plugins, naming, comments, testing, packages and the moved references); the
agent skill routes into them.
- Enforcement: every package typechecks its tests; `check:comments` and
`check:architecture` run in CI; ESLint naming rules are errors; the plugin
boundary check knows `controller`/`connect`; a pull-request template.
- API snapshots regenerated; one changeset per package with a user-visible
change.
The annotation core no longer stores records. `update(model, message)`
returns `{ session, change, effects }`: the next session, the records the
message changed, and the engine work. The plugin keeps three kinds of data,
each with one owner: the records mirror holds what the engine confirmed,
`pending` holds the user's changes until their writes settle, and `session`
holds selection, gestures and settings. The view composes them into the
core's `Model`.
- Core: `Model = Session & AnnotationView`. The record messages (`loaded`,
`hydrated`, `upsert`, `remove`, `bumpAp`, `created`, `createFailed`) are
gone; `rekey` follows a new record to its engine key and `forget` drops
references to records that left the view. New records are `new:<n>`.
Typing emits a `text` effect; `patch` no longer guesses `apChanged`;
`delete` names the record by id, and `delete`/`flags` are emitted for
unconfirmed records too. `src/update.ts` is split into `src/update/*`,
one module per gesture family.
- Confirmed: the records mirror holds `{ dto, apVersion }` and the order,
and owns appearance versions (the engine's `appearance.changed`, form and
signature repaints, reloads). A weak (position-addressed) record keeps one
record when the engine names it; a page is re-read when positions moved.
- Pending: one change per engine write, holding only the fields that write
carries. A refused change is dropped at once, so the view shows the
engine's record again (rollback is deletion). An accepted change is
dropped once the mirror holds it and every older change of the record has
settled, and stays while the mirror is stale. Changes follow a record
whose key changes; writes to an unconfirmed record wait for its create.
- View: memoized per record, with per-page slices so a change on one page
does not rebuild another page's items.
- Fixes: a refused delete no longer hides the annotation; refused flags
revert; a refused restyle shows a collaborator's change made meanwhile;
typed text survives another write of the same record settling; edits and
deletes of a new annotation before confirmation are kept and written
after the create; weak annotations leave no duplicate after an edit and
no ghost after a delete.
- Public: `onWriteFailed`; `Annotation.pending`; `Annotation.raw` is always
the confirmed record; programmatic updates keep the engine's raster and
re-fetch it after a re-bake; every annotation the plugin creates has /NM.
- Kernel: a failed page re-read sets the mirror status to `error` until a
full load succeeds. Engine core: `positionKey(page, index)`.
- React: the free-text editor writes through `draftRichText`, one write
after a pause or when the edit ends instead of one per keystroke.
- Readability: crud split into verbs, effect runners, outcomes and
page-space patching; READMEs for core-annotation and plugin-annotation;
conventions updated (overlay rules, mirror failures, overlay tests).
- Tests: capability scenarios for refusals, out-of-order answers, edits of
unconfirmed records, weak records and a stale mirror; 40 seeded random
interleavings; the update contract; a kernel mirror failure test;
real-engine tests for weak records and changes faster than the engine
answers; a React DOM test for typing.
- API snapshots regenerated; changesets extended.
Replace the old new-records logic with a single record-identity service that centralizes how records change keys (confirmed new records and engine-named weak records). Update services API (newRecords -> identity) and wire identity into intents, store, write runners, links and text-editing so writes wait for a record's engine ref and pending data (including textSelection and vector preference) follow on key changes. Adjust followRecord to accept ref and move text ranges, improve write error reporting so one engine write reports refusal once, and settle held creates when the mirror holds them. Update docs and tests to reflect behavior changes (key renames, rendering source, batching of keystrokes, link sync) and remove the deleted new-records module.
Every annotation kind is declared once, and its read, create and update
types and schemas are derived from that declaration. Both engines read and
write the same shapes, so an annotation read from the engine can be sent
back as an update, or as a create. A session has one identity object on
both engines, and the engine stamps attribution from it.
- Declarations: `defineKind` with `field.data`, `field.attribution`,
`field.engine` and `field.preserved`, plus `.nullable()`,
`.nullableOnRead()`, `.optional()`, `.createOnly()` and `.writes()`.
There are twenty kinds in `kinds/<kind>/declaration.ts`, including a new
`popup` kind. The hand-written dto, draft, patch and schema files are
gone. Read schemas drop unknown keys; create and update schemas are
strict.
- Complete reads: every declared field is present, `null` when the PDF
has no entry. `flags` is replaced by ten flag fields, and
`inReplyTo`/`replyType` by `reply: { to, type } | null`. Captions are
split: `captionEnabled`, `captionPosition` and `captionOffset` on lines;
`captionEnabled` and `captionCenter` on polygons and polylines. Reads
also carry the `popup` back-link, `userId`, `createdBy`, `updatedBy` and
`importedBy`. `actions` is read and kept but not writable. A missing
attachment file reads as `file: null`.
- Writes: a field left out keeps its value, `null` removes it, and a value
replaces it. Patches merge at the top level and replace below it. A
patch takes its subtype from its target. A different subtype, a changed
`nm`, or a field the kind doesn't declare is refused: the worker checks
names against the zod-free `ANNOTATION_FIELD_NAMES`, and the server
checks values against the target kind's schema. Engine-owned and
attribution fields sent back are ignored. `reply.type` defaults to
`'reply'`. A group can be reassigned but not removed.
- Popups are listed as their own items, linked both ways to their parent
(`/Parent`, `/Popup`), and can be created. `/Open` is not modeled yet.
- Foreign (GEO) measures read as a marker. An update that sends the marker
back keeps the measure, and an explicit scale replaces it; the writer
removes it first, because the native setter can't replace one in place.
A create carrying the marker is refused for now.
- Identity: one `Identity` (`userId`, `displayName`, `email`, `title`,
`organization`, `organizationalUnit`, `groupId`, `groups`). The local
engine takes it as an open option; the document JWT carries it in one
`identity` claim; `/access` returns it; `doc.security.identity` exposes
it. Breaking: this replaces the flat `user_id`, `group_id`,
`display_name` and `groups` claims, and the flat identity fields of the
doc-token mint request. `DocumentIdentity` and `IdentityClaims` are
removed.
- Attribution: create stamps `/T`, `/CreationDate` and `/M` (the same
moment), plus `UserID`, `CreatedBy`, `UpdatedBy` and `GroupID`, and
ignores attribution sent in the data. Update stamps `/M` and
`UpdatedBy`. A new annotation goes in the draft's group, else the
session's; any other group needs set-group authority on both engines.
The server now runs its collab check after parsing the draft.
- New capability `doc.annotate.import`. `pdf.permissions` never grants
it, `doc.annotate.modify` doesn't imply it, and signature protection
removes it. Nothing checks it yet.
- Fixes:
- The native runtime read a missing `/EMBD_Metadata` key as `''`, so
cloud reads showed `groupId: ''`.
- The local engine refused to open with
`annotations:set-group:group=X` unless the session was in group X.
- The cloud dashboard example minted share tokens with no identity.
- acrojs `identity.corporation` came from the group id. It now comes
from `organization`, and `identity.email` is filled in.
- Consumers moved: the annotation plugin (its model keeps `flags` and one
caption object, and the repository translates at the DTO boundary),
link, actions, the viewer's measurement panel, the form and stamp
tests, the cloud engine, the server, the contract, the cloud dashboard
example and the engine docs.
- Tests: two conformance suites run on local and cloud.
`runAnnotationDeclarationConformance` checks that every read of real
and created annotations matches its declaration with zero drift, that
shared fields follow the write rules on every creatable kind, that a
read sent back as a patch works, that unknown fields and names are
refused, and that popups link both ways.
`runAnnotationAttributionConformance` covers create and update
stamping, forged attribution, a second session's update, an
Acrobat-authored annotation, groups with and without set-group
authority, and the import capability. Also new: a field-name table
test, `doc.annotate.import` scope tests, and JWT `identity` claim tests.
- `openapi.json` and the API snapshots are regenerated.
Bytes are no longer part of an annotation's data. `create(data, resources?)`
and `update(ref, patch, resources?)` take them in a second argument, keyed by
what they are for, so the data a read returns is exactly what a create or an
update takes, on the wire as in the API.
- Resources: one list of roles, `ANNOTATION_RESOURCE_ROLE_NAMES`
(`appearance`, `file`), gives `AnnotationResourceRole`,
`AnnotationResources` (`Partial<Record<role, Uint8Array | Blob>>`),
`WireAnnotationResources` (`Partial<Record<role, ArrayBuffer>>`) and the
declarations' `KindResources`. A create without a role its kind requires,
a role the kind doesn't take, an unknown role, or an appearance that isn't
PNG, JPEG or PDF is refused with `InvalidArg`. The zod-free
`ANNOTATION_RESOURCE_ROLES` table is kept equal to the declarations by the
field-name test.
- One data shape: `WireAnnotationDraft`, `WireAnnotationPatch`, the stamp
and file-attachment `Wire*` types, `ResourceRef` and
`normalizeAnnotationDraft`/`normalizeAnnotationPatch` are gone.
- The worker job carries the resources by role.
- An HTTP write is the data as JSON, or multipart with the data as `body`
and the bytes as `resource:appearance` and `resource:file`.
- The update route now accepts any bytes as a `file`; it only accepted
images before.
- Stamp: `source` becomes the `appearance` resource. `fit` becomes a
declared field, recorded in `/EMBD_Metadata/AppearanceFit` and read back
(`null` for a stamp that records none).
- A create records the fit it used, unless the data says `fit: null`.
- A resize re-fits with the recorded fit.
- A patch's `fit` re-fits the drawing and is recorded.
- A new appearance replaces the drawing.
- File attachment: `file` holds the name, MIME type and description; the
bytes are the `file` resource. In an update, `file` replaces all three and
writes only what differs, so sending a read back changes nothing. The
resource replaces the bytes and keeps the MIME type when the patch gives
none. File metadata no longer counts as an appearance change.
- New fork setter `EPDFAttachment_SetName`: sets `/UF` and `/F` and keeps
every other filespec entry. The runtime's function table gains it, and its
generator writes the header the committed file has.
- Document attachments: `WireAttachmentFile` and
`normalizeAttachmentFileSource` move to `dto/`, since only
`attachments.create` uses them now.
- `toCreateDraft` is no longer exported; the plugin keeps it internally to
turn its own records into engine data.
- Plugins:
- Stamp placement and the stamp library put `fit` in the data and the
bytes in `appearance`.
- Icon placement splits a picked file into `file` data and the `file`
resource.
- Fix: the plugin's public `update(ref, { flags })` still sent a `flags`
object, which the engine has refused since the flags became separate
fields. It now writes each flag as its own field.
- Tests:
- The declaration suite covers, on both engines: resource checks per
kind, replacing a stamp's drawing, recording and re-applying its fit,
and renaming an attached file and replacing its bytes.
- `resources.test.ts` replaces `normalize.test.ts`.
- Stamp, multipart and plugin tests move to the new shape.
- Docs: the stamp section is rewritten, a file-attachment section is added,
and the annotations page explains the second argument. `openapi.json` and
the API snapshots are regenerated.
- readResource(ref, role) replaces downloadFile on both engines. The
`appearance` role returns a stamp's drawing as a one-page PDF, before
the fit, rotation and opacity its data describes; `file` returns the
attached bytes. Worker job annotations.readAppearance, route
GET …/items/:annotKey/resources/appearance, contract operation
doc.annotations.readAppearance; needs doc.download.
- Stamp `opacity` (/CA) is data. It is painted once, as the layer
Acrobat writes and recognises by name (`/R0 gs /MWFOForm Do` over our
wrapper), so an opacity set here or in Acrobat replaces the other
rather than multiplying it. Opacity changes go through
EPDFAnnot_SetStampOpacity, which reads the appearance with the old
value. Our wrapper is found again under Acrobat's forms that only draw
or move it; a layer that doesn't paint /CA stays part of the drawing.
- A stamp that records no fit is re-fit as `fill`; a create with
`fit: null` fits that way too.
- Viewer: stamps get an Opacity prop, and the plugin reads /CA into the
style.
- Runtime: fork bumped to 0657b971e (wrapped-appearance module, nested
form opacity applied once, EPDFAttachment_SetName); generated function
list.
- Tests: runAnnotationResourceConformance on both engines (12 tests),
with fixtures stamp-opacity-acrobat.pdf, stamp-rewrapped-acrobat.pdf
(and its generator) and stamp-roundtrip-acrobat-{60,100}.pdf, the
Acrobat round trip of a stamp made here; a plugin repository test for
stamp opacity.
- Docs: annotation types (opacity both ways with Acrobat, `fit: null`,
copying a stamp, readResource), synced to both sites; openapi.json and
API snapshots regenerated.
- Runtime: fork bumped to <sha>: canonical drawing export, adoption on import, boolean entry accessors, and the exporter no longer writes the target's /Info. Generated function list updated. - A PNG or JPEG stamp is drawn from a one-page drawing of the image at its own size, a pixel to a point, scaled down to fit 14,400 pt. Before, it was the image placed in its first box with its first fit baked in. A `cover` stamp later set to `contain` now shows the whole image, and the same image is the same drawing in any box. - Popups declare, read and write `open` (/Open, false when absent). - Tests: runAnnotationResourceConformance on both engines checks that export after import is the same bytes (vector, PNG and both Acrobat stamps), that an image is the same drawing in any box, that cover then contain shows the whole image, and that an export has no /Info or private keys. The declaration suite covers popup `open`. A local test checks that wasm and native export the same bytes. - Docs: what a stamp's drawing is (images at their own size, the same bytes from either engine, storable by hash), synced to both sites. openapi.json and the API snapshots regenerated.
setStampContent no longer empties the old appearance through the object API before replacing it; both paths set a whole new appearance. A full rewrite after a new drawing has none of the old one. Bumps runtime-src for copy-on-write of shared forms.
- Runtime: fork bumped to <sha> (shared stamp drawings,
EPDF_DigestBuffer). Generated function list updated.
- DocumentSession keeps a DrawingIndex for the open document. It maps
each drawing's content id (the SHA-256 of its canonical bytes) to the
drawing, built from the file's stamps on first use, and notes which
drawing each uploaded set of bytes made. It lives in memory only and
is dropped with the document.
- The stamp writer finds or adds the drawing (stampDrawing.ts: check the
note, canonicalize, look up the index, import only new content), then
places it with EPDFAnnot_SetStampDrawing.
- 100 placements of one image store it once.
- A PNG and the drawing exported from it are one drawing.
- Drawings already in a reopened file are placed again, not copied.
- A stamp another tool made exports its drawing at its own size. Its
`fit: null` (a fill) places it again.
- Tests:
- Resource conformance, on both engines: 10 PNG and 10 PDF placements
give one drawing each; a PNG and its export are one drawing; new
artwork for one stamp leaves a stamp sharing the old artwork alone.
- A local test places artwork that a reopened document already has.
- Docs: stamps share drawings, and another tool's stamp is exported at
its own size. Synced to both sites. API snapshots updated.
A bundle holds annotations and the bytes beside them, to move them
between documents in one call. Each item is a row: its data, exactly
what a read returns, and the ids of the resources it names. Each
resource is stored once, under the SHA-256 of its bytes.
- engine-core src/transfer/, zod-free, exported from the main entry:
- AnnotationBundle, AnnotationBundlePage, AnnotationBundleItem and
ResourceId. resourceIdOf hashes with WebCrypto.
assertAnnotationBundle checks a bundle whole: format, limits, shape,
references, then every resource against its id.
- AnnotationBundleLimits and DEFAULT_ANNOTATION_BUNDLE_LIMITS: one set
for making and reading a bundle, measured on the bundle itself (the
manifest's JSON plus each resource's bytes).
- AnnotationTransfer.stringify and parse: the bundle as one JSON file,
resources in base64. parse checks the limits before it decodes
anything: first the text's length, before JSON.parse, then the
counts and each resource's size, read from its base64, and only
then decodes and hashes. It refuses:
- an unknown format, version or field, naming the field;
- an item on a page the bundle doesn't list;
- a missing or unused resource;
- bad base64;
- a resource that doesn't match its id.
stringify runs the same checks on shape and size.
- EngineErrorCode.PayloadTooLarge, with details { limit, max, value }.
The server maps it to 413 and the kernel to invalid-input.
openapi.json regenerated.
- Base64 moved from wire/schemas.ts to resource/base64.ts. It is now
zod-free and one pass over a preallocated buffer, and
decodedLengthOf sizes base64 without decoding it. The /wire entry
still exports toBase64 and fromBase64.
- Fixes a type error in the resource conformance helper (a
Uint8Array<ArrayBufferLike> passed to new Blob).
Tests: the codec (a round trip; resources stored once; each refusal;
each limit refused before anything is decoded or hashed),
assertAnnotationBundle, and base64 encode, decode and sized-length.
Engine core 612, kernel 136, cloud engine 290.
doc.annotations.export(selection?) takes annotations out of a document
with the bytes beside them, as one AnnotationBundle: every annotation,
chosen refs, or whole pages.
- What an export takes (closeExportSelection, engine-core):
- the selection, plus what it points at: a reply brings its parents
up to the root, an annotation its popup, a popup its annotation;
- by default (include: 'threads'), every reply below a selected
annotation; include: 'references' leaves them out;
- replies and popups are on their parent's page (ISO 32000-2
§12.5.6.2), so only the selection's pages are read.
pageRefsIn lists the pages each item names; each bundle page carries
its position and crop box, for mapping pages on import.
- The worker job (AnnotationExporter) loads no page: annotations come
from the raw read and each resource from a raw annotation handle. A
drawing is exported once per drawing object however many stamps
place it; another tool's stamp through EPDFAnnot_ExportAppearance;
attached files are capped at the resource limit. Ids are SHA-256
from the fork's digest, and every limit is checked while the bundle
is built (PayloadTooLarge names it).
- Local engine: needs doc.annotate.read and doc.download; resource
buffers go on the transfer list.
- Cloud: a versioned, CDN-cacheable GET, not a POST:
- GET …/layers/:layer/annotations/export@<token>, and a doc-level
twin for layers that inherit the annotations and layout planes;
- the token pins annotationsVersion and layoutVersion (a page
reorder moves positions without bumping annotationsVersion) and
carries the selection canonically, one selection one URL, durable
refs only;
- resources annotations-export and layer-annotations-export require
both capabilities (kind 'all');
- the response is multipart: the manifest, then one resource:<id>
part per resource. The server checks the pins before and after
the job; the client refreshes on a stale pin and checks every
part against its id.
- Shared helpers, one owner each: saveDocumentToBuffer (out of
AnnotationFlattener), digest (out of stampDrawing), buildMultipart
(routes/_multipart.ts), readBoxes (exported from PagesReader), and
the conformance stamp fixtures (stampFixtures.ts).
- An attachment past its decoded-size limit is PayloadTooLarge.
Tests: runAnnotationExportConformance on both engines:
- data equals the raw read; one resource per drawing;
- resources equal what readResource returns;
- the thread and reference closures;
- the export after an edit and after a page move;
- a whole-file SHA-256 of two Acrobat fixtures that both engines
reproduce.
Local: the doc.download gate and each limit through WorkerHost.
Cloud: Forbidden without doc.download. Also the closure and the
export token.
Engine core 630, local engine 546, cloud engine 297, server 869,
kernel 136. Docs: "Exporting annotations", synced to both sites; API
snapshots updated. openapi.json unchanged: these are viewer routes.
Implement annotation bundle import end-to-end across engine and server. - Engine core: add transfer types/schemas for import, pageRefs helpers, planAnnotationImport, import options/results and event tx grouping. - Local & Cloud: DocumentAnnotationsService.import(), SessionEventPublisher tx support, SSE handling for multi-fact audit rows. - Worker: AnnotationsImport worker request, AnnotationImporter + AnnotationBatchApplier to apply creates inside a DocumentCheckpoint for safe rollback. - Server: multipart import endpoint with Idempotency-Key handling, limits enforced while streaming, repo lookup for idempotent commits, audit row kind 'annot.import'. - Added tests and conformance suites, helper utilities (uniqueViolation, drawing hashes), and documentation for import behavior.
Measurement formatting now preserves the precision Acrobat expects, including trailing zeros for /RD and /FD, and keeps labels consistent across multi-unit cascades. This also adds annotation transfer conformance coverage, keeps form-field widgets with their field, drops unsupported action payloads and attachment metadata, and fixes file attachment date and MIME handling.
Add support for linking creates to existing annotations and strengthen weak refs during batch annotation imports/creates. Key changes: introduce BatchLinkTarget and resolveAnnotIndexRaw; extend DocumentCheckpoint with object() to checkpoint arbitrary objects; refactor AnnotationBatchApplier to claim names, resolve/link reply/popup targets (including existing annotations), track strengthened stable IDs, update meta generation and weak-annotation bookkeeping, and add helpers (withOpenExisting). AnnotationMutator.create now delegates to AnnotationBatchApplier. Add E8 transfer benchmark tests and shared helpers, a worker test helper and counting fault harness updates. Update runtime bindings and local runtime artifact refs.
One shared engine/ corpus for embedpdf.com and cloudpdf.com: quick start, setup (per site), authentication (cloud), documents, text (extraction, search, selection & geometry), annotations (one page per family, import & export, appearances), forms & signatures, and good-to-know pages (permissions, events, errors, identity). The pages describe the target API in docs/plans/2026-09-25-engine-api-v3.md; don't deploy them before its rounds land. - sync.mjs mounts all of engine/ and swaps @embedpdf/engine imports per flavor; the site-owned getting-started pages moved into content - docs/content/redirects.mjs: every old core-concepts and getting-started URL (and its .md) redirects, imported by both next.configs - website: <CloudPdfCallout> (mdx registry + markdown projection), used inside <Engine local> where the cloud engine does more - inbound links updated in headless, server and api-reference pages, the cloud product switcher, guide section and landing, the website overview - cloudpdf.com's stale headless pages and samples resynced
…fields - PermissionDenied, InvalidScope, MissingIdentity and AbortError are EngineErrors (Forbidden with details.required, InvalidArg, Aborted); the server's guards throw PermissionDenied, so cloud 403s name the missing capability - a ref that finds nothing is NotFound; InvalidReference is a stale index - @embedpdf/engine-core/public: the export list both engine packages share - objectUrl() and render.policy() are AbortablePromises; abortWith(signal) ties a call to an AbortController - remove createCloudEngine, CloudEngineOptions.sessionId, PageHandle.pageIndex, capabilities.pageEditSessions; no optional members both engines implement (forms.submit stays feature-detected) - caps builders cover every capability; stale comments and dead code - fix @embedpdf/engine's test typecheck; API snapshots regenerated
- localEngine() works in Node: with no Worker global it runs PDFium in this thread (native, or wasm via the new `runtime` option) and renders PNG/BMP with the new PortableImageEncoder (CompressionStream deflate). WebP there is NotImplemented, naming `imageEncoder`. It also forwards signedDocumentPolicy and sessionKind. - The PDF password lives only in OpenOptions.password; `id` is optional on bytes, layerBytes and layerFile. - Every input kind opens a locked file locked: the worker parks the kind's own load and unlock() runs it with the password. - A wrong open() password opens locked with passwordPrompt.incorrect true (both engines); the kernel's locked event carries it. - An unlock refreshes the local guard's PDF permission bits, so the owner password lifts the file's restrictions under pdf.permissions. - A local scope without doc.open is refused before the file is read. - layerFile on wasm is NotImplemented; inserting an encrypted PDF is DocPasswordRequired (shared services/runtime/loadError.ts). - Cloud engine.destroy() closes the documents it opened. - Tests: local-engine-node, opening (encrypted.pdf fixture from PDFium), cloud engine-destroy; scopes in two suites gain doc.open. - Docs: opening, setup (Node), rendering, permissions, errors; API snapshots regenerated.
- doc.annotations.list({ pages? }) replaces listRaw/listRawAll. One
AnnotationList { annotations, pages, auditHead? } on every layer: both
engines, the worker's one annotations.list job, and both REST list
endpoints. The page list is a raw read. Snapshot types removed.
- get/list names: metadata.get, text.get, actions.get,
pieceInfo.get/list/delete, render.getPolicy, measure.listViewports,
signatures.getContents/getDigest/downloadRevision/cancel.
- Write results: { annotation } for create/update, { annotations } for
move and import, { meta } for delete on every layer; the
annotation.deleted event names what went (deletedAnnotationOf).
- REST contract declares the real annotation results, and names
Annotation, AnnotationList, AnnotationMutationMeta and PageState once
(AnnotationWireComponents); openapi.json, SDKs and API reference
regenerated.
- downloadResource, beginEdit, session close().
- renderAppearances() is the encoded render on both engines,
renderAppearancesRaw() the pixels; both take a viewport. Width
viewports are off an enforced appearance lattice.
- Annotation flatten items are 'applied' | 'unchanged'.
- Kebab-case intents (line-dimension, …) and measure subtypes
(rectilinear, geospatial); the file keeps PDF names.
- Plugins, tests, conformance, server, runtime demo, docs and API
snapshots follow.
- One range shape: TextRange { start, count } and PageTextRange.
- page.text: get(), slice(range) and layout(). layout() is a TextLayout
with charCount, runs, charAt(point), wordAt/lineAt(point | index),
segments(range) and charQuad(index); the free layout functions are
internal and page.geometry is gone.
- Raw geometry: runs have start, characters loose/tight and
space/empty (only when true) instead of looseBox/tightBox/
looseQuad/tightQuad and flag bits; a turned run has baselineAngle.
- Search: a flat request (query + snippets, from, cursor, limit),
matches as page ranges with snippet { before, match, after }, results
with pagesSearched/pageCount; snippets default off; skip is internal
(SearchScanRequest). charRangeForTextOffsets takes and returns a
range; sliceText(snapshot, range).
- Cloud search token keys follow the request; result format ranges1.
- Search plugin hits are ranges (selection.select(hit)), snippets
replaces mode, from replaces startPage; the selection plugin reads
text.layout(); chrome, React layer, example app, docs, tests and API
snapshots follow.
- doc.forms verbs: export, import, create, update, delete, addWidget,
removeWidget; toFieldRef(name) in both engine packages
- Every form write's meta is a FormMutationMeta (affectedPages,
cacheDelta, changedFields, changedWidgets), filled by one worker
helper; delete returns { meta } and its event names `deleted`;
import returns { form, applied, skipped, meta }; applyEffects meta is
never null (the worker's internal `wrote` gates persist and events)
- needsAppearancesCleared
- Signatures: prepare takes `signer` facts; cancel returns 'cancelled'
and emits signature.cancelled; worker job and wire path are cancel
- core-signature: sign({ key }), refusals are EngineError
SignatureRefused with details.reason (SigningError removed)
- Signature plugin and chrome: `key` for the key holder, `signer` for
the facts, cancelPending
- REST: doc.forms.list, doc.signatures.cancel, typed form results;
importData/exportData keep their names (SDK keyword guard);
openapi, SDKs and API reference regenerated
- Conformance: value-write meta, effects meta union law, no-op batch
names nothing; docs synced, API snapshots updated
- doc.events.on(type, listener), typed by the event (DocumentEventOf<T>),
beside subscribe(); both engines build it on their subscribe through
the shared subscribeToType helper (a type listener opens the cloud's
live stream like any subscriber); @embedpdf/core/testing has it too
- Event names follow the docs: annotations.*, attachments.*,
forms.{valueSet,created,updated,deleted,widgetAdded,widgetRemoved,
imported,repaired,effectsApplied}, signatures.{prepared,completed,
cancelled}, pages.scaleSet; engines, cloud audit mapping, conformance,
plugins and convention docs follow
- pages.moved / pages.inserted carry toIndex
- Conformance: on(type) hears only its type; its unsubscriber stops it
- Server audit kinds unchanged (they are not event names)
- Every write is { <noun>, meta }, meta never null: page, metadata and
attachment cache pins fold into MutationMeta.cacheDelta
(layoutVersion/metadataVersion/attachmentsVersion); the per-domain
cache types go; the cloud absorbs through one apply(meta)
- Flatten and redaction return an empty meta when nothing applied (the
worker's wrote flag gates artifact, event and server persist)
- setScale returns PageScaleResult; pieceInfo.update/delete return
results; the signatures.prepared event carries its result
- Attachments: AttachmentRef/Attachment with ref, toAttachmentRef,
list() -> { attachments }, { attachment, meta } / { meta } with
meta.changed naming them, ArrayBuffer data
- redaction.apply({ pages } | { annotations }); listViewports() ->
{ viewports }; toIndex everywhere; flatten(refs, { usage }) and
DownloadOptions
- security.state / security.scope; allowsAnnotation(action, target)
- REST contract declares the real page, redaction and viewport results;
openapi, SDKs and API reference regenerated; conformance, plugins,
chrome and tests follow
- Deleting a merged field/widget deletes the field and its widgets in one
change; removeWidget and the annotation delete refuse it and point to
doc.forms.delete
- Fields a signature locked: applyEffects rejects them and FDF/XFDF
import skips them (the runtime import takes a skip list)
- forms.create is one change: the draft is checked first, then applied
in a document checkpoint, so a rejected draft creates nothing
- forms.update needs no family; members of another family are InvalidArg
- complete() after cancel() is NotFound on the cloud; the cloud analyze()
judges the saved version unless until: 'working-copy'
- A signed field's appearance change is ProtectedDocument; the worker
refuses a rewrite save of a signed document on both engines; the
server answers ProtectedDocument with 409
- The server audits signature prepare, cancel and complete, and other
cloud sessions receive the signatures.* events
- core-signature: sign({ kind: 'timestamp' }) makes RFC 3161 document
timestamps through a timestamp authority; unknown subFilters are
InvalidArg; createTestTimestampAuthority for tests
- Docs, conformance, bindings and API snapshots follow
- One value check on both engines: the kind schemas run in the worker (assertAnnotationDraft/checkAnnotationPatch), InvalidArg names the field; server schema errors name it too - Field trait readBack(): a read sent back keeps a read-only value (link target, an orphan popup's parent), a changed one is refused - Opacity reads as written (float /CA, shortest decimal); quadPoints replace; a redaction's rect comes from its quads and its font reads a registered key; a standard state brings its model; a popup needs a parent; a stamp takes a one-page PDF - Deleting an annotation deletes its thread and popups in one change, checked member by member on both engines (all or nothing, details.refs); the deleted event names them all; a popup deleted alone clears /Popup - Reads never leave a field out (attachments, viewports, namedPages, a line's caption flag); popups leave comment threads - REST read shapes, SDK, docs, conformance, bindings and API snapshots follow
- Measure helpers throw EngineError(InvalidArg); measureFromRatio takes
{ userUnit } in its options; precision defaults to 100; the measure
types are public
- Imports: a copied file's dates travel in both modes; a stamping import
needs only create authority; the cloud checks a bundle against the
limits /v1/access advertises, and HTTP 413 is PayloadTooLarge
- Cloud export takes any selection: a POST when a URL can't carry it
(position refs, long ref lists)
- Metadata: '' is a value; a standard or malformed custom key is
InvalidArg naming it, before anything is written
- Fonts: a standard font's name can't be a key
- Files: the engine never guesses a type (no octet-stream fallback, a
download's mimeType is null, X-EmbedPDF-File-Type header); a File
brings its name and type to a file-attachment annotation
- Docs, conformance, bindings and API snapshots follow
The thumbnail rail is now configured to ignore document interactions so it behaves as a secondary navigation lens instead of exposing the document's tools. This keeps the fixed-magnification thumbnail strip focused on click-to-navigate behavior while preserving scroll-based navigation without zoom gestures.
A create tool answers one question: if the gesture ended now, what would
exist? The commit, the drawing in progress and the tool's ghost now make
the same calls. gesturePlacement places the dragged box or segment, or
the tool's click default, and placedShape lays the kind's shape there
through the new ShapeFamily.placed. unmadeItem paints an annotation not
made yet the way the made one will paint. The callout's text box and the
form palette go through the same calls. clickCreateGeom and the free-text
click fallback are gone.
A line click is centred on the pointer. A line's ClickCreate takes
`anchor` ('center', 'start' or 'end', which puts an arrow's tip on the
click) and `rotation`, which replaces angleDeg. Under `upright` a click is
laid out as the person sees the page: a line points as seen, and a
centred box keeps its width × height and slides inside the page by the
area it covers there. A square or circle takes the quarter turn as
swapped sides, so it stores no rotation; only a stamp keeps the turn. The
built-in square, circle and line tools are upright.
A tool's `ghost` is true, false or { opacity } and works for every tool
whose click places something. The plugin keeps only the pointer and
derives the ghost from it and the tool's live defaults. The handler that
takes the click shows the ghost on hover, and only where no hover claim
sits above it (checked with the hub's new hasCursorClaim) and the user
may create. The ghost stays while a press is still a click and clears
when the gesture ends. Ghosts paint see-through with --epdf-ghost-opacity
winning. The ghost handler and its priority are removed; getToolGhost is
now getImageGhost, and setPlacementPreview is now previewPlacement.
Form field tools show their ghost. A radio button is its own kind,
widget-radio, drawn round as the engine draws it, in its ghost and while
it is dragged. A cancelled placement places nothing. The viewer turns
ghosts on for square, circle, line and arrow.
A callout is its own kind, free-text-callout: kindOf returns it for a free text with the callout intent. It keeps the turn it was made with, the one that stands it upright on a turned page. So it shows no rotate knob, rotate 90° and reset rotation leave it as it is, and a selection that includes a callout can't turn or resize as a group. Reset rotation now skips every kind that doesn't turn, so a caret keeps the turn of its text. The callout tool edits its own kind's fields. A callout's selection frame is everything it paints: its box, its line and the ending at its tip, the same box as its rect. A selected callout is grabbed on its line or anywhere in that frame. A multi-selection box takes in its line, the menu sits below the whole callout, and a move keeps the whole callout on the page; page-bound's callout special case is gone. Any selected annotation is grabbed in its frame or anywhere a click would hit it, so selecting never shrinks where it can be grabbed. Two things stay on the text box. An attached link covers only the box. A double-click opens the text only on the box or a resize handle on its border (isOnTextBox in the core, textBoxAt in the plugin); on the line or the empty rest of the frame it is a normal press. The caret and the text box share isInTurnedBox.
Each shape family says what it paints as a few simple pieces: ink along a path, a filled polygon, or an ellipse (PaintedPiece, in painted.ts). Two questions are asked of the pieces: paintedNear, for a click, and paintedTouches, for a rectangle. A rectangle of no size touches exactly what a click with no margin hits there, so a click and the marquee can't disagree. ShapeFamily.painted replaces ShapeFamily.hit. endingPieces and distancePainted/captionPainted describe endings and measurements, and paintedOf(record, view) puts a record's pieces together: its shape as a screen-anchored body shows at the view, and a measurement's caption and lines. hitTest and geomHit read them, and geomPainted keeps the pieces per shape object, since a hover asks on every pointer move. The plain geometry the questions need lives in rect.ts: point, segment and polygon against a rect. Clicks hit what they hit before, with two exceptions. A cloudy border is hit on its own curves, flattened from the same Bézier arcs the path draws, so its bumps' tips hit and the gaps between them don't. The margin around a text box, a caret or a measurement's caption reaches the same distance every way, so its corners are rounded. Before the old rules were deleted, a check over a grid of points for every family, measurements and a screen-anchored note found no other difference. The family hit functions, endingNodesHit, distanceHit and cloudyBorderExtent are gone.
The marquee asks the painted pieces whether the box touches them (paintedTouches over paintedOf), instead of testing the selection frame. It catches an annotation's ink, or the inside of a filled shape, as a click hits it; a screen-anchored body counts where it shows at the view. A box inside an unfilled shape, or in the empty corners of a diagonal line's bounds, no longer selects it; one that touches only a callout's line, an arrowhead, a measurement's caption or a cloud's bumps does. A marquee of no size catches exactly what a click with no margin hits. selectionInBox is what a box selects: every annotation it touches and the rest of each one's group. The marquee gesture and the capability's selectInRect both use it, so selectInRect now selects exactly what dragging the same box does, leaving out annotations an engaged tool makes inert. It used to select whatever overlapped an annotation's bounds, without its group. annotsInBox takes a rect, and quadIntersectsRect, which only the marquee used, is gone. The selecting docs say what a box has to touch, synced to both sites.
A measurement's offset, leader and caption were built out from a pointer kept on the page, but nothing held the result there: on a slanted line the dimension line, its arrows and its caption could leave the page. A gesture that reshapes an annotation now stops where its frame would reach further past the page's edge than when the gesture began (fractionOnPage, and deltaOnPage for a two-way drag that slides along an edge). It holds for drawing a distance's offset, dragging its leader, dragging any measurement's caption, and every handle drag: a line's end stops before its arrowhead leaves the page, and a turned box stops when a corner reaches the edge. An annotation already past the edge is never pushed further out.
documents.save() and saveLayer() settle the document before they read
the file (settle.ts): the kernel runs every flush plugins registered
with ctx.onSettle() and waits for it, so the bytes have everything the
user sees. A download used to read the file at once, so an autosave or
a save shortcut right after typing could miss the last words, and it
could overtake writes still on their way. The caller's signal, or the
document closing, cancels the wait with operation-cancelled. A flush
that fails is reported, and the file is written from what the engine
has.
The annotation plugin writes the text typed in the last 250 ms
(flushAllText) and waits for its running writes (store.whenWritten).
The draw handler finishes an ink drawing waiting for its next stroke,
never a stroke still being drawn. The text being typed in a form field
lives in the form plugin (draftText, commitDraftText, discardDraftText
on the host lens) instead of the React components, and is committed
first, as Acrobat commits the field being edited before a save.
ctx.serialQueue() returns a queue with idle(). Forms, page edits,
redaction apply and measurement scales register theirs with
ctx.onSettle(() => queue.idle()). A queue that carries verbs never
does: the actions plugin's queued runDocumentVerb('save') calls
documents.save() from inside its queue, and waiting for that queue
would wait for itself. The rules are in
docs/conventions/state-and-sync.md, and the test context has settle().
i18n: dotted keys ('review.reject') become branches wherever strings
enter the state (nestKeys), so they match; they used to be stored as
they were. addTranslations() takes strings for a language in loaders
that hasn't loaded yet: they wait in waitingTranslations and go in on
top of the pack when it registers.
A plugin's settings are declared in its definition
(definePlugin({ settings: { defaults, registered } })). The kernel
builds one store per registration when it plans the plugin list, so
the settings exist before any document opens and one change reaches
every document (kernel.settingsOf(token), ctx.settings()). Every
store has getSettings, updateSettings, resetSettings and
onSettingsChanged ({ settings, changed }). Nested objects merge,
arrays and values replace, and reset goes back to what the app
registered. definePlugin reads its types from token, state and
settings, so a plugin passes no type arguments; create and inScope
are checked against them, never a source of them. A plugin without
settings has NoSettings, a type of its own rather than never, so one
plugin list holds plugins with and without settings. ctx.settings()
throws there.
A plugin declares its State table once, framework-free:
defineState(token, { read, empty }). React turns any declaration
into its hook (stateHook), which re-renders only when a field
changes and returns `empty` when no document is open. settingsHook
does the same for settings. Outside a document, use<Plugin>()
returns a stand-in: rendering works, the settings calls reach the
store, and every other call throws not-ready.
A workspace plugin can say what it looks like inside a document's
scope (PluginDef.inScope). The documents capability and commands use
it, so documents.save() and commands.execute() inside a
<DocumentScope> act on that document, and useDocuments() follows the
scope.
A refused call names what was missing: PluginError.permission, set
from the engine's refusal or by ctx.assertAllowed(permission,
operation). Every can<Verb> reads ctx.allows(permission). Form,
render, selection, metadata, measurement, redaction and annotations
no longer build refusals by hand. Events carry the engine's own
origin (EventOrigin: kind, sessionId, sub, ts, serverId, tx);
ChangeOrigin and originOf are gone, and check-architecture flags an
origin built by hand. ctx.pageOf(page) takes a PageRef or an index
and throws not-found for a verb; ctx.getPage(page) returns null for
a read. ctx.cancellable(signal, task) aborts an engine call when the
caller cancels, and a queued operation whose signal fired before it
started is skipped.
@embedpdf/web lists every --epdf-* variable (EPDF_VARIABLES) and
builds the lookup the theming page promises (paint): the part's own
variable, then the part it follows, then its plugin's accent and
--epdf-accent, then the setting.
Search is the first plugin on the contract. Its settings are reveal
and highlight { color, activeColor, blendMode }, and <SearchLayer>
paints from them and the CSS variables instead of color props. Its
state is a declaration (activeHitIndex, activeHit), with
useSearchHits(page?) and useSearchSettings(). onProgressChanged
comes from state, so it also fires when a new search starts over,
and the events no longer carry operationId. Every verb takes a
signal, page-taking reads return empty for a page that is gone,
listPagesWithHits() is in document order, and a drag that starts on
a match selects text instead of calling onHitClick.
The docs check themselves. snippets.mjs compiles every page's
snippets per framework, and samples.mjs compiles the live examples
straight from docs/content, failing on an error; both share
compile.mjs and run in a directory of their own. publish-gate.mjs
writes which pages are live: a page is live for a framework when its
snippets compile and its reference tables name nothing pending.
Otherwise the sites show its title and a notice, and keep it out of
llms.txt, search and search engines. With DOCS_PREVIEW=1 (and in
next dev) every page shows, and a held-back one carries a banner
under its title. The reference check also compares State tables
with defineState, Settings tables with the settings defaults, and
the From CSS columns with EPDF_VARIABLES.
Live examples are plain elements with class names and one ordinary
CSS file each, shown as a tab next to the code; on the page, a
build step scopes each example's stylesheet to it. Search has four
examples (a search box, options, a results list, highlight colors).
The Stage examples and every other example that used the demo kit
are converted, and the kit is gone.
… page
The headless docs work: each plugin went through the shared
contract with its React binding, until the code does what its docs page
says. Names now match the pages, and nothing keeps an old name (pre-3.0,
no compat aliases). Details per package are in the changesets.
The contract, plugin by plugin:
- Settings live in definePlugin({ settings }): getSettings, updateSettings,
resetSettings and onSettingsChanged on every plugin that has them (render,
selection, actions, commands, i18n, measurement, redaction, signature, ...).
- State is declared once (stageState, shellState, metadataState, ...) and
each framework turns it into its hooks.
- Permissions are can* reads, events carry their origin, pages are PageRefs.
Notable changes:
- Documents: open() and retry() resolve { document }; a download runs the
document's save actions itself; a split pane opens beside the focused one.
- Viewing: the current page is a PageRef; links open websites in the user's
gesture; navigation called before the stage has a size waits for the first
placement instead of being lost (so a PDF's open action to a page works).
- Text: goToHit() takes a hit; selection colors follow the viewer accent.
- Annotations: the capability has nouns (selection, draft, text, tools,
stamps); the record type is Annotation (was AnnotationDTO); create() copies
a read of another annotation; a kind's style schema is its properties.
- Stamps, measurements, redaction, signatures: a free placement uses
`center` (was `at`); their settings are live.
- Forms: list/get/validate and the other names the forms pages use.
- Your app's UI: commands and i18n are settings; useCommandShortcuts takes a
target; registerTool() for your own tools; epdfThemeVariables() and
fitAnchoredRect() in @embedpdf/web.
Docs:
- 150 React live examples: plain elements and one ordinary CSS file each,
shown as a tab next to the code. Every example shows its feature working
on load.
- A samples check compiles them; the sync mounts every sample area.
- The reference check has 0 pending names except print. 38 of 39 pages are
live for React; Saving waits on printing.
Verified: about 3,800 tests, 0 type errors, API snapshots, docs:check on both
sites, the architecture and comments checks, and a browser pass over all 39
pages.
Custom annotation looks and UI about an annotation each get one clear
place: a look is part of the page and draws into a frame the layer
places; UI about an annotation (a badge, a status, a menu) floats over
the pages with <Anchored>. Details per package are in the changesets.
Anchored UI:
- `pinned` keeps it where it's put: no flip, no move to stay in view,
and it scrolls away with its box. Placements line up with a side's
start or end ('top-end', ...), and a negative gap overlaps the box.
- Anchored UI on a page that isn't on screen renders nothing and sits
out camera frames; out of view, it isn't drawn. Stage and PageView
provide the pages on screen next to the per-frame projection.
- A note's AnnotationAnchor carries boundsIn(view), so
useAnnotationAnchor() re-renders only when the annotation moves.
One frame for every annotation:
- Every render item carries `frame` (box, turn, scale) and `raster` (the
engine's picture inside it), resolved in the annotation core and
handed out through the capability. Adapters convert with @embedpdf/web
(frameInPixels, rasterInFrame) and decide no geometry
(docs/conventions/architecture.md).
- The layer draws its own look and yours in the same frame: custom looks
land on the native drawing on turned pages, notes stay upright like
their icons, and blending happens on the frame.
- A look is drawn at the annotation's 100% size and scaled with the
page, text and borders included; `scale: false` opts out.
- Renderers get frame { width, height, rotation, scale } and `selected`;
`box` and `page` are gone; useRichTextEditor(annotation).
- A plain text box's scene is drawn upright; a callout's is unchanged.
Docs:
- Custom rendering: "Its look, or UI on it"; the frame and its scale;
bubbles filling a bigger note, with Turn the view; a pinned stamp
status (annotation/stamp-status replaces annotation/interactive).
- Menus over the page: pinned, start/end placements, a negative gap, and
a Pinned switch in the placement example.
- Coordinates: the pin snippet draws in the page function with toPixels.
- Both sites are synced, including the cloud site's copies of the
previous commit's example fixes.
Align redaction label preview and engine apply behavior by measuring text in Helvetica, fitting auto-sized labels to the region, and preserving the font in /DA for size-0 labels. Repeated labels now tile at 12pt, default no-font redactions use Helvetica, and docs/tests were updated to cover the fixed layout and burn-in behavior.
A noZoom annotation's projection reused the resize gesture's scale, which keeps a box at least 4pt. Past about 6x zoom a note stopped shrinking on the page and grew with it, while its frame still drew a custom look at 1x: a large bubble with small text and a thin border. A gesture's commit at that zoom also stored the annotation larger than it was. scaleAbout and geomScaleAbout now scale exactly; group resize passes its 4pt limit as minSize. The hit margin was in page points, so a click reached about 200px around an annotation at 25x zoom and about 2px at 25%. hitMargin is now a screen-pixel setting, converted by the page's view scale on pointer-down, hover and the cursor, like snap.alignmentThreshold. Also regenerates the engine-core conformance API snapshot for the redaction label checks added earlier.
…all four frameworks - @embedpdf/vue (SFCs + composables), @embedpdf/svelte (Svelte 5 runes) and @embedpdf/angular (provideEmbedPdf + withX(), one signal service per plugin, ng-packagr entry points, zoneless), each covering every headless page. - Every React example ported: 150 per framework, 600 live demos; every page's snippets compile per framework; both sites' check:samples compile all four. - Shared code consolidated into @embedpdf/web, core, core-ui, core-geometry and the plugins; publish guards for Angular declarations and the Vue/Svelte tarballs. - Docs: code panels moved out of the compiled MDX into per-framework JSON read at render (the dev server ran out of memory with four frameworks per page); the leak test now catches React code on non-React pages; Angular demos build in production mode. - Tile plane lifetimes in every adapter: the view handle follows the renderer and the view, the page's claim follows the page, the demand follows the camera. Vue and Svelte released the page on every zoom or scroll (so tiles stopped updating), Svelte also re-acquired the handle and re-bound unchanged tiles, and Angular kept the old page when a page view switched pages. Four lifecycle tests per adapter. - Fixes found along the way: plugin-stage ignores a same-size viewport report (it cancelled an on-open reveal/goToPage), placement latch for navigation before placement, annotation selection.set([]) clears, React/Vue/Svelte PageView follows DocumentScope and is the page's pointer surface, React link and form presses isolated natively; Angular render layer sets the picture URL directly and tiles report "unpainted" to their view. - API snapshots cover Angular and list Vue/Svelte component props.
The stage now owns its frame loops: pending animation frames are cancelled when the instance is disposed, and no new ones are scheduled afterward. This prevents late callbacks from writing into a closed stage while page teardown happens mid-countdown. Viewport reports with no area (hidden containers or elements removed from the page before teardown) now keep the last real viewport and view instead of fitting to a 0 × 0 box. When shown again, the stage resumes from that state without firing duplicate viewport, zoom, or camera events.
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.