Skip to content

Merge main into stable#3023

Merged
harbournick merged 66 commits into
stablefrom
nick/merge-main-stable
Apr 30, 2026
Merged

Merge main into stable#3023
harbournick merged 66 commits into
stablefrom
nick/merge-main-stable

Conversation

@harbournick
Copy link
Copy Markdown
Collaborator

No description provided.

caio-pizzol and others added 30 commits April 24, 2026 15:09
The require-ci ruleset requires one check context named "validate"
but 13 workflows all emit a job named validate. That makes the
required-check match ambiguous: any one of the 13 checks can satisfy
the rule, so PRs can merge while most workflows are still failing
or not even running.

Add a unique workflow-prefixed name: to every validate job so each
one becomes its own required context. Harden the five aggregate
validators to fail on any non-success result (cancelled and skipped
included, not just failure). Add merge_group to visual-test.yml so
its validate context can be required safely.

Companion ruleset update lives in the PR description.
…SD-2670) (#2928)

* feat(document-api): ranges.scrollIntoView for text + entity targets (SD-2670)

Adds `editor.doc.ranges.scrollIntoView({ target, block?, behavior? })` so
consumers can navigate the editor viewport to any document range without
reaching into the deprecated ProseMirror view. Handles paginated,
virtualized layouts — mounts the target page on demand before scrolling.

- `target` accepts `TextAddress`, `TextTarget` (e.g. passed directly from
  `selection.current()`), or `EntityAddress` (scroll to a comment or
  tracked change by id).
- `block` / `behavior` mirror the DOM `ScrollIntoViewOptions` shape and
  default to `center` / `smooth`.
- Returns `Promise<{ success }>`. Async because virtualized pages may
  need to mount before the scroll completes; `success: false` when the
  target can't be resolved or the page-mount times out.
- EntityAddress resolution piggybacks on existing internal resolvers
  (`resolveTrackedChange`, `listCommentAnchors`) — no new resolver added.

Under the hood, the super-editor adapter resolves the target to a PM
position and delegates to `PresentationEditor.scrollToPositionAsync`,
which already handles virtualization correctly.

Tests: 13 cases for the validator + delegation in document-api, 9 cases
for the super-editor adapter helper (TextAddress, TextTarget first-segment,
comment entity by commentId, comment entity by importedId fallback,
tracked-change entity, missing-presentation fallback, option passthrough,
adapter failure). New `testRangesScrollIntoView` section in
consumer-typecheck exercises all three target shapes + the
`Promise<ScrollIntoViewOutput>` return.

Part of the SD-2667 drop-in assessment umbrella. Once this lands, the
custom-sidebar-card-click → scroll-to-anchor flow in the drop-in example
no longer needs a workspace-only hack.

* fix(document-api): address PR review on ranges.scrollIntoView

Three findings from PR 2928 review:

1. Unawaited Promise in invoke(): the new op was the first registry entry
   with a Promise output, but dynamic invoke() callers (e.g. CLI
   orchestrators) treated results synchronously and received a pending
   Promise instead of a `{ success }` payload.

2. Non-functional CLI exposure: registering in OPERATION_DEFINITIONS
   auto-surfaced `superdoc doc ranges scroll-into-view` through the CLI,
   but the CLI opens documents headlessly with no PresentationEditor,
   so the command could only ever return `{ success: false }`.

3. Story-blind tracked-change resolution: the EntityAddress path looked
   up tracked changes only in the host editor's body, ignoring the
   `story` metadata that trackChanges.list()/get() returns for
   header/footer/footnote/endnote anchors. Non-body targets silently
   returned `{ success: false }`.

Fix: remove `ranges.scrollIntoView` from the RPC/CLI surface entirely
(it is a browser-only UI side-effect, not a data-layer RPC) and
delegate EntityAddress targets to `PresentationEditor.navigateTo`,
which already has the story-aware activation path.

- Dropped `ranges.scrollIntoView` from `OPERATION_DEFINITIONS`,
  `OperationRegistry`, `schemas.ts`, and the `invoke` dispatch table.
  Added it to `META_MEMBER_PATHS` in `check-contract-parity.ts` with a
  comment explaining the browser-only constraint — same precedent as
  `selection.onChange`. Direct calls through
  `editor.doc.ranges.scrollIntoView()` continue to work.
- Rewrote `scrollRangeIntoView` to split cleanly by target kind:
  - EntityAddress → `presentation.navigateTo(target)`. The presentation
    editor handles page mounting, story activation, and alignment. The
    caller-provided `block` / `behavior` options are not applied here
    because `navigateTo` picks per-entity-type defaults that the
    document API shouldn't second-guess.
  - TextAddress / TextTarget → unchanged: resolve first segment to a
    PM position, call `scrollToPositionAsync` with caller options.
- Removed `resolveTrackedChange` + `listCommentAnchors` imports from
  the adapter — the delegation to `navigateTo` makes them unnecessary.
- Deleted the now-orphaned reference doc for the removed operation.
- Added a test case covering tracked-change EntityAddress with a story
  field, verifying the full target (including story) reaches
  `navigateTo` unchanged.

Tests: 10 adapter cases (5 text-path + 4 entity-path + 1 missing
presentation) still pass; document-api 1384 pass; super-editor 11648
pass; consumer-typecheck clean; contract parity 386/386; outputs 426
files clean.

* fix(document-api): honor success/false contract on text-path failures (SD-2670)

PR 2928 follow-up.

- F2: wrap the TextAddress / TextTarget path in try/catch so
  `resolveTextTarget` throws (ambiguous block id) and
  `scrollToPositionAsync` rejections resolve to `{ success: false }`
  instead of propagating to the caller. The entity path already did
  this; the text path should match.
- Added 2 tests covering the thrown-resolver case and the
  rejected-scroll case.
- Updated JSDoc to call out the virtualized-non-body-entity
  limitation flagged in F1. That limitation lives in
  `PresentationEditor.#navigateToTrackedChange` — its non-body paths
  (`#activateTrackedChangeStorySurface`, `#scrollToRenderedTrackedChange`)
  both require rendered DOM candidates, with no equivalent of the
  body path's `setCursorById` + `scrollToPositionAsync` pre-mount
  flow. Tracked as SD-2750 since the fix needs body-side reference
  resolution inside the presentation editor, not the adapter.

Tests: 12 adapter cases pass (was 10, +2 for F2).
…donor font propagation (#2931)

* feat(list-level-formatting): implement symbol font normalization and donor font propagation

- Added functions to handle symbol font normalization during transitions from bullet to ordered lists, ensuring that symbol fonts do not interfere with the rendering of ordered markers.
- Introduced logic to propagate a legitimate text font from higher levels to nested ordered levels after symbol fonts are stripped, maintaining consistent font styles.
- Enhanced tests to cover various scenarios of font handling during level transitions, including cases with symbol fonts and ensuring legitimate fonts are preserved.

This update improves the visual consistency of numbered lists in the editor by preventing unwanted symbol font rendering.

* refactor(list-level-formatting): enhance donor font propagation logic
* ci: align release and CI triggers with package impact map

Test broad, release narrow. CI gates compatibility (a core change should
run dependent CI to catch breakage). Release gates artifact changes (a
package should only publish when its own published artifact actually
changes).

- Shrink release-react, release-template-builder, release-esign and
  their .releaserc.cjs to own-path-only. These wrappers externalize
  superdoc in their Vite builds, so republishing on core changes
  produces near-identical tarballs; consumers pick up core via
  peerDependencies on their own install.

- Expand release-mcp trigger paths and .releaserc.cjs to cover SDK,
  CLI, document-api, and core. MCP depends on SDK via workspace:* and
  imports engine/session code directly. Current trigger only watched
  apps/mcp/**, causing MCP to lag SDK releases.

- Strip packages/ai/** from every workflow and .releaserc.cjs include
  list. @superdoc-dev/ai is being deprecated; npm-side deprecation is
  a separate operational step.

- Add .github/package-impact-map.md as the source of truth for which
  paths should trigger CI and release per surface. Workflow changes
  derive from it.

* ci(react): keep release broad - superdoc is a regular dep, not peer

React declares superdoc in dependencies (">=1.0.0") rather than
peerDependencies, unlike template-builder and esign. That means
existing consumers with pinned lockfiles won't pick up a new core
version until react republishes. Shrinking release-react to own paths
would strand them on old core fixes.

Revert release-react to broad core paths and restore the minor-cap
release rules. The impact-map rationale now distinguishes react from
the peer-dep wrappers and calls out the future peer-dep migration as
the prerequisite for going narrow.

No breaking change to consumers; migration to peerDependencies is
tracked as a separate decision.

* chore(react): dual-list superdoc as dep and peer for singleton signal

Add superdoc to peerDependencies while keeping it in dependencies.
Preserves auto-install for every consumer regardless of package
manager (no breaking change) and signals the singleton contract that
template-builder and esign already express via peer-dep.

This is a semantic cleanup, not a release-policy change. release-react
stays broad because existing consumers still pick up core versions
via the dependencies pin. Removing superdoc from dependencies would
unlock release-narrow but is a breaking change tracked separately.

Verified with pnpm install --frozen-lockfile — no lockfile changes.

* ci: ignore packages/ai/** in ci-superdoc paths-ignore

Review found ci-superdoc uses paths-ignore and did not list
packages/ai/**, so an AI-only PR still triggered the full SuperDoc CI
despite ai being deprecated. Add it to paths-ignore to match the
impact-map claim that ai is removed from all release and CI triggers.
* chore(ai): mark @superdoc-dev/ai deprecated, remove from docs

Mark packages/ai/package.json as private and strip publish/release
scripts so the package can never be accidentally republished.

Add a deprecation banner to packages/ai/README.md pointing users at
@superdoc-dev/mcp and @superdoc-dev/sdk with the Document API.

Delete the already-hidden apps/docs/ai/ai-actions/ pages and remove
@superdoc-dev/ai entries from apps/docs/scripts/validate-code-imports.ts
and apps/docs/__tests__/lib/extract.ts so the docs stop advertising
(and stop lint-allowing) a deprecated package.

Run npm deprecate '@superdoc-dev/ai@*' '<message>' alongside merging
this PR to emit install-time warnings for existing consumers. Full
source removal (rm -rf packages/ai, pnpm-workspace.yaml entry, eval
references, scripts/type-check-all and scripts/test-cov entries) is
tracked as a separate cleanup PR.

* docs: remove ai-builder placeholder page

ai-builder/overview.mdx only existed to link to the now-deleted
ai-actions docs and was already hidden from navigation. Remove the
page and drop the corresponding skip pattern from the docs test
extract helper so no broken internal links remain.
…pec imports (#2940)

* ci(behavior): chromium-only on PRs, full matrix on merge_group

Firefox is the wall-clock bottleneck at ~13 min per shard; gating it
behind merge_group and workflow_dispatch gives PRs fast chromium
feedback while still running the full 3-browser suite before merge.

* test(behavior): collapse math-equations spec to one test per fixture

The file loaded the same 11 DOCX fixtures 74 times - once per test.
In a recent Firefox run that single file accounted for ~809s of
passed-test time. Each describe block now loads its fixture once
and wraps the original tests as test.step() assertions, taking the
import count from 74 to 11 while preserving every expect().

* ci(behavior): run full matrix on push to main/stable as post-merge safety net

Pairs with the chromium-only PR matrix: Firefox/WebKit still run on
every change, just after merge instead of before. Measured approach
before considering a merge queue gate.

* Revert "ci(behavior): run full matrix on push to main/stable as post-merge safety net"

This reverts commit 785a24a.
)

* fix: comments being merged

* fix: ooxml compliance

* test: added more tests around paraId and paraIdParent

* fix(comments-export): key commentsExtended.xml guard off file-set, not origin (SD-2443)

detectDocumentOrigin stamps every file missing commentsExtended.xml as
origin='google-docs' on import, including legacy Word range-based files,
so the previous exportStrategy !== 'google-docs' guard never fired for
the exact class of files SD-2443 targets. Testing 123.docx only
round-tripped because the pre-existing hasThreadedComments safeguard
caught one imported reply link; strip parent links and the regression
returns.

Flips the two commentThreadingProfile integration tests and the
commentsExporter "still honors Google Docs export strategy" unit test
that codified the bug (they asserted commentsExtended.xml should be
dropped for comments.xml-only imports). Adds the range-based +
origin=google-docs + no parent links regression case, plus a
commentsExtended profile test so the override does not over-fire.

---------

Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: Caio Pizzol <caio@superdoc.dev>
Co-authored-by: Artem Nistuley <artem@superdoc.dev>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
* chore(renovate): ignore demos, examples, and visual-testing

devtools/visual-testing/packages/harness depends on a locally-built
packages/superdoc/superdoc.tgz that does not exist in Renovate's clean
checkout, which makes pnpm install fail and breaks artifact updates on
every dep PR. Skipping that subtree (and the already-non-shipped
demos/examples) at discovery time avoids the issue and lets Renovate
focus on dependencies that actually ship.

Replaces the demos/examples packageRule (matchFileNames + enabled:false)
with a single top-level ignorePaths block.

* chore(renovate): preserve test/vendor ignore patterns from preset

ignorePaths is non-mergeable, so setting it at the top level replaced
the patterns inherited from :ignoreModulesAndTests via config:recommended.
That silently brought test package.json files (tests/visual,
tests/behavior, packages/superdoc/tests/cdn-smoke, etc.) back into
Renovate's scope. Re-list the preset's patterns explicitly so coverage
stays the same as before.
GitHub's required-status-check rule is name-based; when a workflow is
skipped by paths, the required check stays pending and blocks the PR
forever. That has been blocking dep-only and other path-irrelevant PRs
(e.g. #2949) on the Behavior Tests / validate gate.

The simplest durable fix is to drop the path filter and let the
workflow run on every PR. Trades a bounded amount of CI minutes for
removing an entire class of stuck-PR failures and the recurring
maintenance of keeping path lists in sync with the repo layout.
….119 (#2949)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
…s (SD-2668) (#2941)

* feat(document-api): selection primitives + multi-block comment targets (SD-2668)

Adds `editor.doc.selection.current()` and `editor.doc.selection.onChange()` so
consumers can build custom toolbars, comments sidebars, and selection-driven
UIs without reaching into ProseMirror internals. Widens `comments.create`
target to accept `TextTarget` so multi-block selections anchor across blocks
instead of silently collapsing to the first segment.

- Consumers previously had to resolve PM positions through
  `editor.state.doc`, walk the PM tree for the containing block's `sdBlockId`,
  and convert to flattened-text offsets — ~30 lines of editor internals.
  `selection.current()` returns a portable `SelectionInfo { empty, target,
  activeMarks, text? }` with a multi-segment `TextTarget` ready for
  `comments.create`.
- `comments.list().target` already used multi-segment `TextTarget`; the
  write side (`comments.create`) only accepted single-block `TextAddress`,
  so drag-selecting across paragraphs lost data with no warning.
- Contract, schemas, dispatch, and adapter wired. Super-editor adapter
  projects PM selections into the flattened-text model via the existing
  `computeTextContentLength` helper.

Part of the SD-2667 drop-in assessment umbrella.

* fix(document-api): address PR review on selection + comments scope

1. Drop the aspirational `in: StoryLocator` parameter from
   `selection.current`. The adapter always read the live editor selection
   and merely copied `input.in` into the returned `TextTarget.story`, so a
   body selection could be mislabeled as a header/footer selection. The
   operation now documents that it always reflects whichever story holds
   focus; story-scoped selection reads are out of scope.
2. Narrow `comments.create` multi-segment handling to contiguous ranges.
   Validation previously accepted any non-empty segment array; the handler
   spanned `first.start → last.end` as a single PM range, so disjoint or
   out-of-order segments would silently anchor the comment over text the
   caller never selected. `addCommentHandler` now resolves every segment,
   rejects out-of-order pairs, and rejects any pair with non-empty text
   between them (`INVALID_TARGET`).
3. Fix schema inconsistency: `SelectionInfo.target` is `TextTarget | null`
   at the type level, but the published schema required it as an `object`.
   Schema now uses `oneOf: [textTargetSchema, { type: 'null' }]` so empty
   selections validate against the exported contract.
4. Make `SelectionAdapter` optional on `DocumentApiAdapters`. This is a
   public exported interface; adding a required member is a source
   breaking change for external adapter constructors. The factory now
   throws `SELECTION_ADAPTER_UNAVAILABLE` when selection operations are
   called without a registered adapter.

Tests: add 3 new cases (multi-segment forward, missing-adapter for
`current` + `onChange`). 1374 pass, 0 fail.

* fix(document-api): correct selection offset mapping + cancel pending flush

Two bot findings on PR 2924:

- P1: `collectTextSegments` was deriving block-relative offsets with
  `selStart - blockStart`, treating raw PM positions as flattened text
  offsets. That's only equivalent when the block contains pure text; it
  diverges whenever the block has inline wrappers (e.g. `run` marks) or
  leaf atoms whose PM boundary tokens do not count in the flattened
  model. A selection inside a run would therefore return a `TextTarget`
  off by the number of wrapper boundary tokens, and `comments.create`
  using that target would anchor to the wrong text.

  Added `pmPositionToTextOffset(blockNode, blockPos, pmPos)` alongside
  the existing `resolveTextRangeInBlock` in `text-offset-resolver.ts` —
  it walks the block with the same flattened model (text = length,
  leaf = 1, block separator = 1, inline wrapper tokens = 0) and
  returns the correct offset. `collectTextSegments` now uses it for
  both endpoints.

- P2: `subscribeToSelection` scheduled `flush` via `queueMicrotask` but
  the returned unsubscribe only detached listeners — a microtask
  already queued before cleanup would still fire, invoking the listener
  after unsubscribe returned (stale state updates on component unmount).
  Added a `cancelled` closure flag set by unsubscribe and checked in
  `flush` and `schedule`.

Tests: 5 new cases for `pmPositionToTextOffset` covering plain text,
inline wrapper transparency, leaf atoms with `nodeSize > 1`, before-
block-start, and past-block-end. 11515 super-editor pass, 0 fail.

* test(document-api): positive-path + write-side selection coverage

Addresses PR review findings on test coverage:

- Adds `selection-info-resolver.test.ts` (11 cases) covering
  `resolveCurrentSelectionInfo` projection (empty state, single-block
  selection, multi-block segment-per-touched-block, missing blockId,
  includeText on/off, active-marks empty path) and
  `subscribeToSelection` (listener fires once per tick, unsubscribe
  stops firing, queued-microtask-cancellation on unmount).
- Adds 3 write-side cases to `comments-wrappers.test.ts` for the
  multi-segment path: out-of-order rejection with INVALID_TARGET /
  "document order" message, non-contiguous-gap rejection with
  "contiguous" message, and the contiguous-success path verifying
  the spanned PM range [first.from, last.to] is applied.
- Updates `assemble-adapters.test.ts` to assert both
  `selection.current` and `selection.onChange` are wired on the
  adapter bag.
- Adds a new section in `tests/consumer-typecheck/src/customer-scenario.ts`
  exercising the exported `editor.doc.selection.current()` surface,
  `SelectionInfo` destructuring, multi-segment `TextTarget` pass-through
  to `comments.create`, and the `onChange` subscription shape. Requires
  threading the new types through `packages/super-editor/src/index.ts`
  and `packages/superdoc/src/index.js` JSDoc typedefs so they are
  reachable from the `superdoc` package entrypoint.
- Exempts `selection.onChange` from the contract-parity member-path
  check via `META_MEMBER_PATHS` — it is a subscription primitive, not
  a request/response operation, so it does not belong in
  `OPERATION_DEFINITIONS` / schemas / dispatch. Fixes the `validate`
  CI job that otherwise rejects the new runtime member.

Deferred to SD-2671 (follow-up):
- doc-api-stories/comments/multi-segment-target.ts (CLI-harness story)
- doc-api-stories/selection story
- Playwright behavior test that drives selection.current → comments.create

Verified: document-api 1374 pass, super-editor 11529 pass,
tests/consumer-typecheck compiles clean against the packed tarball.

* fix(document-api): type-guard comment target routing + intersect marks per-node

Two bot findings on PR 2941:

- Comment-target routing used `'segments' in target` to pick between
  the TextAddress and TextTarget branches. That misclassifies a
  TextAddress that happens to carry an extra `segments` field (e.g.
  from object spread or a caller with wider union types) and then
  crashes on `segments[0]` inside `targetToSegments`. Replaced with an
  `isTextTargetShape` guard that requires `kind === 'text'` plus a
  non-empty `segments` array — matching the runtime contract the
  document-api validator already enforces. Malformed payloads now fall
  through to the single-block branch and surface as clean
  `INVALID_TARGET` responses.

- `markTypesPresentEverywhere` expanded each text node's mark set once
  per selected character and intersected those arrays afterwards. For a
  10 KB selection that allocated 10,000 Set references per event, and
  `selection.onChange` fires frequently during editing. Rewrote as a
  running intersection over text nodes: initialise from the first
  overlapping node's mark set, intersect with each subsequent node,
  stop descending once the intersection empties. O(text-nodes) with
  bounded allocation; same return value.

Tests: +4 regression cases (type-guard misclassification, per-node
intersection across multiple runs, empty-intersection for a mixed
marked/unmarked selection, 10k-char selection completes under 50ms).
Adapter suite 3190 pass, 0 fail.

* fix(document-api): close 5 multi-segment / selection edge cases on PR 2941

Five real correctness regressions surfaced in review-round-2:

- Hybrid TextAddress+TextTarget payloads (both shapes carried in one
  object) passed both `isTextAddress` and `isTextTarget` validators, then
  routed through the segments branch and silently dropped blockId/range.
  Hardened `isTextTargetShape` to reject TextAddress-style fields, so
  hybrids fall through to the explicit-block path.

- Two collapsed segments in different blocks slipped both the order +
  contiguity guards AND the spanning-range collapse check (because
  firstResolved.from < lastResolved.to across the block boundary), then
  silently anchored a comment over content the caller never selected.
  Added a per-segment collapse check before the resolution loop.

- `textBetween(prev.to, curr.from, '')` returns '' when the gap is
  composed entirely of inline atoms (images, math, etc), so an
  atom-only gap passed the contiguity check. Pass a `leafText`
  callback so atoms still register as gap content. Block separators
  remain represented by an empty `blockSeparator`, so legitimate
  cross-block adjacency still produces an empty gap and accepts.

- `collectTextSegments` skipped textblocks with no addressable id and
  emitted segments for the rest, returning a TextTarget that didn't
  cover the user's selection. Now bails out and returns null for the
  whole selection so callers can refuse the action rather than act on
  partial data.

- `subscribeToSelection` listened to both `selectionUpdate` and
  `transaction` to catch programmatic selection changes that don't fire
  the former. The `transaction` event also fires on every keystroke, so
  the listener ran per character even when SelectionInfo was unchanged.
  Added a content-key dedupe (empty + segments + activeMarks) so
  identical states fire the listener once. The listener still fires
  immediately when SelectionInfo changes for any reason.

Also: relaxed the 10k-char wall-clock perf bound from 50ms to 500ms
(the functional assertion is the real correctness check; the timing
is a smoke check that won't flake on noisy CI workers).

Tests: +6 regression cases (hybrid routing, collapsed multi-block,
atom-only gap, non-addressable block aborts the walk, transaction
dedupe, dedupe doesn't become sticky after a real change).
Adapter suite 3198 pass, 0 fail.
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* fix: footer tcs in replacement generating one per character
* feat(examples): add AI streaming demo using Document API

Streams LLM tokens into a SuperDoc editor via editor.doc.insert(). A small
Node proxy keeps the OpenAI key server-side; the browser appends buffered
deltas every 150ms or on newline boundaries to avoid one mutation per token.
The in-flight stream is aborted on unmount and on Stop.

* refactor(examples): move ai-streaming to ai/ and add docs pointer

Belongs alongside the other AI integrations, not under feature demos.
Adds a streaming-pattern section to apps/docs/ai/agents/integrations.mdx
and a row in the AI overview so it's discoverable.

* chore(examples): drop stale ai/streaming-document-api playwright entry

* chore(examples): pin ai/streaming to workspace superdoc, bump deps

Use workspace:* for superdoc so monorepo CI tests against the local
package, not whatever's published to npm. Bump openai 4 to 6, dotenv,
react, and vitejs/plugin-react to current latest. Fix the README port
(5173 to 5180) and tighten voice on the docs streaming section.

* fix(examples): guard streaming generate, sync vite proxy with PORT

Re-entry guard on generate() prevents a fast double-click from racing
two streams and orphaning the first abort controller. Vite proxy now
reads PORT via dotenv so server and proxy stay aligned when overridden.
* feat(editor): allow child editors to skip telemetry

- Updated the condition in the Editor class to include `isChildEditor` for skipping processing in non-primary document editors.
- Added a test to ensure telemetry is disabled for story editors, regardless of their header/footer status.

* test(super-editor): cover sub-editor telemetry override and isChildEditor branch

Move the telemetry default in createStoryEditor below the caller-overrides
spread so a caller-provided telemetry option cannot re-enable document-open
telemetry on sub-editors. Add a regression test that exercises this case
with telemetry: { enabled: true } in editorOptions.

Add a small smoke test for createLinkedChildEditor to lock down the
isChildEditor flag, which is what Editor.ts skips on for telemetry init.

---------

Co-authored-by: Caio Pizzol <caio@superdoc.dev>
* feat(lists): merge and split operations

* fix(lists): restart numbering

* fix(lists): return OOXML-stable paraId from lists.insert receipt

* docs(prompt): sub-point + nested-list recipes for agents

* test(lists): merge/split validator and wrapper tests

* fix(query-match-adapter): enhance error handling for discontiguous text ranges

* feat(numbering): add sanity checks for ordered list formatting and symbol font violations

- Introduced `numbering-consistency.js` to validate that ordered list levels do not use symbol fonts, preventing rendering issues in Word.
- Implemented `findSymbolFontsOnOrderedLevels` function to identify violations in OOXML abstractNum elements.
- Added comprehensive tests in `numbering-consistency.test.js` to ensure correct functionality and edge case handling.

* feat(lists): enhance list action validation and add new tests

- Updated `package.json` to refine the `clean` script and introduce new prebuild commands for benchmark dependencies.
- Added a new function `usesListAction` in `checks.cjs` to validate that the expected list action is called during evaluations.
- Introduced a new document `basic-list.docx` for testing list operations.
- Expanded benchmark and execution tests to include assertions for list actions, ensuring correct functionality for merging, splitting, and restarting lists.
- Implemented regression guards to check for symbol font violations in ordered lists.

* refactor(intent-dispatch): streamline case statements for intent actions

* refactor(intent-dispatch): simplify case statements for intent actions and improve readability

* refactor: require structural emptiness for paragraphs

- Updated test descriptions for clarity, removing references to specific bugs.
- Added new test cases for `lists.merge` and `lists.split` actions to ensure proper functionality and validation.
- Refactored `listsMergeWrapper` and `listsSplitWrapper` to improve handling of empty paragraphs and revision management during list operations.

* fix: update document API references to use 'document' instead of 'active editor'

* test(evals): assert list structural changes for merge/split/restart

Adds checkBulletsAndNumbersMerged, checkBulletListSplitAtWith, and
checkRestartAtAllSorts in evals/shared/checks.cjs that read
word/document.xml from the saved docx and verify the numId/ilvl change
the agent was supposed to make. Without these the merge/split/restart
evals could pass even if the list itself never changed.

Also reword the listsSplitWrapper doc comment to drop the misleading
"applied atomically" claim - the implementation runs as two sequential
steps and the inline note already documents the partial-apply failure
mode. And rename the validator test that read backwards (default is
restart-on, not opt-out).

---------

Co-authored-by: Caio Pizzol <caio@superdoc.dev>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Co-authored-by: Artem Nistuley <artem@superdoc.dev>
…SD-2766) (#2973)

* feat(document-api): anchor tracked changes to text spans in extract (SD-2766)

Add `block.textSpans` so consumers can map each tracked change to the
exact run of text it covers, instead of guessing from a free-form
excerpt that's ambiguous when the same word repeats. Also add
`blockIds` and `wordRevisionIds` on each `trackedChanges[]` entry so a
review queue or RAG citation flow can navigate back without scanning
every block. Suppress the aggregate excerpt for paired replacements
(both insert and delete in one entity) where the concatenated value
was misleading; spans carry the per-half text.

The new fields are all optional and the existing `text` field is
unchanged, so non-tracked-change consumers see no diff.

* fix(document-api): detect paired tracked changes from observed mark types

Address PR review findings:

- Generalize paired-replacement detection to suppress the aggregate
  excerpt for any multi-type entity, keying off mark types observed
  during the span walk. The previous check looked only at imported
  `wordRevisionIds`, missing in-app paired edits where no `sourceId`
  is set.
- Rename `rawIdMap` → `canonicalIdByAlias` to match the existing
  convention in `tracked-change-refs.ts` (the value is the canonical
  entity id, not a raw mark id).
- Document `blockIds` order as document order in the public typedef.
- Inline the default value in the `type` JSDoc so readers don't chase
  the cross-reference.
- Gate the visual-inspection log behind `DEBUG_EXTRACT_SAMPLE` so CI
  doesn't print two pretty-printed JSON blobs per run.
- Add unit tests for the in-app paired case (no `sourceId`),
  span coalescing of identical adjacent marks, and non-tracked marks
  (bold) coexisting with tracked marks without affecting span
  boundaries.

* test(extract): add real Word-authored DOCX with paired replacements (SD-2766)

Adds a 22 KB Word-authored fixture (74 deletes + 104 inserts, all
paired replacements with one author and one timestamp) reported by
the customer who originally asked for tracked-change anchoring. The
test asserts that title-level "Report" -> "Captain's Log" and body-
level "get started" -> "set sail" replacements come through as
distinct delete/insert spans, that every tracked change reports
blockIds, and that span text concatenates back to block.text. A
DEBUG_EXTRACT_SAMPLE-gated log prints the rendered <ins>/<del>
output for the first five tracked blocks for visual verification.
* fix: floating textbox not rendering

* refactor: match decorative and graphic elements by local name

Migrates two remaining hardcoded namespace prefix lookups in the
DrawingML import path so docs that re-prefix the DrawingML namespace
(e.g. ns6: instead of a:) get consistent treatment alongside the rest
of the PR.

- encode-image-node-helpers.js: `adec:decorative`/`a16:decorative`
  in wp:docPr extLst now matches by local name `decorative`.
- merge-drawing-children.js: `a:graphic`/`a:graphicData` in the
  zero-id repair path now use findChildByLocalName.

* test: cover re-prefixed decorative and graphic lookups

Adds regression tests for the namespace-prefix-agnostic lookups
introduced in the previous commit:

- encode-image-node-helpers: covers `adec:decorative`, `a16:decorative`,
  re-aliased `ns7:decorative`, val=0, and missing-decorative cases.
- merge-drawing-children: covers the zero-id repair path when the
  graphic subtree uses `ns6:graphic` / `ns6:graphicData`.

Both new "re-prefixed" tests fail against the pre-fix implementation
and pass after, locking in the local-name matching as the contract.

* test: cover vector-shape-helpers re-prefixed namespace path

Adds a 'namespace prefix tolerance' describe block to lock in the
local-name matching introduced in this PR for vector-shape-helpers.js
(34 callsites converted from `el.name === 'a:foo'` to findChild /
hasLocalName / filterChildren / getLocalName).

Covers the public surface that depends on those helpers:
  - extractStrokeWidth (a:ln)
  - extractStrokeColor (a:ln/a:noFill/a:solidFill/a:srgbClr/a:schemeClr)
  - extractFillColor   (a:solidFill/a:gradFill stops/lin angle)
  - extractLineEnds    (a:ln/a:headEnd/a:tailEnd)
  - extractCustomGeometry (a:custGeom/a:pathLst/a:path with
    a:moveTo/a:lnTo/a:close, plus a:pt children)

6 of the 7 new tests fail against the pre-fix implementation and
pass after, locking in the local-name matching as the contract.

---------

Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Co-authored-by: Caio Pizzol <caio@superdoc.dev>
* fix: page number on footer not properly aligned

* fix(footer): clamp band origin and skip default footer:0

- normalize-header-footer-fragments: outer Math.max(0, ...) clamp on band origin matches the renderer's behavior, so malformed footerDistance > pageHeight produces 0 rather than a negative origin.
- PresentationEditor: only spread margins.footer when the source defines w:footer. Defaulting to 0 here would defeat the bottom-margin fallback in computeFooterBandOrigin (typeof 0 === 'number' passes the check, returning pageHeight - 0).

---------

Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: Caio Pizzol <caio@superdoc.dev>
* fix: table border conversion

* fix: ts build issue

* fix: tests and final issues

* fix: convert table borders before creating a new table

* fix: convert table styles

* refactor: simplified code

* fix: tests

* refactor: consolidate duplicate function

* refactor: added missing code

* refactor: unified functions to convert borders to px

* refactor: return unit alongside border values

* fix: tests

* docs(pm-adapter): document options arg and fix border JSDoc example

* test: cover SD-2343 border conversion paths and add behavior fixture

---------

Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: Caio Pizzol <caio@superdoc.dev>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
caio-pizzol and others added 7 commits April 29, 2026 13:30
* feat(superdoc/ui): custom toolbar command registration (SD-2802)

ui.commands was a closed registry — 38 hardcoded ids, no extension
hook. Every TipTap / CKEditor / TinyMCE consumer with custom toolbar
buttons (AI rewrite, insert mention, internal workflow actions, slash
commands) had to fork the registry or work around it. The drop-in
replacement story isn't real until consumers can wire their own
buttons through the same surface as built-ins.

Add ui.commands.register(...) returning a typed registration object:

  const ai = ui.commands.register<{ prompt: string }>({
    id: 'company.aiRewrite',
    execute: async ({ payload, superdoc }) => { ... return true; },
    getState: ({ state }) => ({ active: false, disabled: !ready }),
  });

  ai.handle.execute({ prompt: 'fix tone' });   // typed
  ai.invalidate();                              // re-run getState
  ai.unregister();                              // idempotent

Custom commands appear in ui.toolbar.snapshot.commands alongside
built-ins. Every entry now carries source: 'built-in' | 'custom' so
consumers can render one uniform toolbar without branching on the id.

Built-in collisions are refused by default with a console warning;
override: true on the registration replaces the built-in deliberately.
Custom-vs-custom replacement warns and replaces. getState errors fall
back to a static disabled-false state and log once per unique error
message. Async execute is supported and normalized to boolean.

invalidate() exists because custom command state often depends on
external app state (permissions, AI quota, upload progress) that
SuperDoc has no way to observe via editor events. Consumers wire it
to whatever signal their app uses; the controller microtask-coalesces
the resulting snapshot rebuild.

The captured registration handle is the realistic typed path —
indexing ui.commands['company.aiRewrite'] degrades to unknown without
module augmentation. Don't promise type safety we can't deliver.

Backed by 14 new unit tests in custom-commands.test.ts. Existing 81
ui tests continue to pass. tsc -b clean.

* fix(superdoc/ui): four correctness fixes from PR #3004 review (SD-2802)

All four were verified by failing tests/typecheck before fixing:

1. Default TPayload to `void` instead of `unknown`. Without this,
   `register({ id, execute: () => true })` returned a handle whose
   zero-arg `handle.execute()` was a type error — consumers had to
   write `register<void>({...})` for every payload-less button.

2. Type `snapshot.commands` as `{ [id: string]: ... | undefined }`.
   The prior `Record<string, ...>` claimed every string lookup
   returned a state, but at runtime unregistered ids return undefined.
   Consumers writing `snapshot.commands[id].disabled` would crash.

3. Preserve `null` returned from `getState`. The old
   `derived?.value ?? STATIC_CUSTOM_STATE.value` collapsed null to
   undefined, so a custom command using null to mean "no current
   value" (matching built-ins like link / text-color) couldn't.

4. Stop observers firing after unregister. The Subscribable lives on
   the controller's selector substrate and outlives the registration;
   without an explicit early-return the next snapshot rebuild emitted
   the static fallback `{ disabled: false }` to active observers,
   leaving stale buttons enabled. The observe wrapper now detaches
   its inner subscription on the first post-unregister emit.

Adds four regression tests; total 18 in custom-commands.test.ts (up
from 14). All 99 ui tests pass, tsc -b clean.

* fix(superdoc/ui): identity-guard register lifecycle + active observer disposal (SD-2802)

PR #3004 second review pass.

Bot P1: A's stale `unregister()` would delete B's replacement.

  // before: identity-blind
  unregister() { entries.delete(id); ... }

  // after: identity-checked
  unregister() {
    if (entries.get(id) !== ownEntry) return; // stale call from prior owner
    entries.delete(id);
    ...
  }

Same guard on `invalidate()`. Verified by failing test before fix.

Bot P2: existing observers attached to a registration are now actively
disposed during `unregister()` and during register-time replacement.
The lazy `!entries.has(id)` short-circuit in the observer wrapper is
kept as a safety net — but no longer the only mechanism. A new
`observerDisposers: Map<string, Set<() => void>>` tracks each active
observer's teardown so the registry can call them all on demand.

Three new regression tests:
- A.unregister after B replaced does not remove B
- A.invalidate after B replaced does not re-emit on B's observer
- Replacement actively disposes prior-registration observers

Total 21 tests in custom-commands.test.ts (up from 18). All 102 ui
tests pass, tsc -b clean.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…le) (#2857)

* feat: implement 2 extra types of bullet lists (square, circle)

* refactor: code reuse

* refactor: simplified code removing font override

* chore: small code tweaks

* test: add coverage for bullet style picker (SD-2526)

unit:
- numbering-transforms.test.ts (new): generateNewListDefinition with bulletStyle, lvlText override, rFonts strip, ordered-list ignore
- list-numbering-helpers.test.js: markerTextToBulletStyle truth table + null cases
- toggleList.test.js: style-aware predicate, swap-mints-numId path, no-arg fallback
- create-headless-toolbar.test.ts: bullet-list direct command forwards style argument
- toolbar-registry.test.ts: bullet-list state exposes raw markerText for each style

behavior (playwright):
- bullet-style-picker.spec.ts: AC1, AC2, AC3, AC5 picker UX + active-style reflection
- bullet-style-undo-redo.spec.ts: AC9 undo of initial create
- bullet-style-word-fixtures.spec.ts: round-trip from real Word disc/circle/square docs (fixtures generated via Word COM)

* test(lists): verify bullet style export round-trips to DOCX

Adds a behavior test that picks bullet styles via the toolbar dropdown,
then exports and inspects numbering.xml to confirm the per-list w:lvlText
markers match the chosen disc/square styles.

* fix: footer tcs in replacement generating one per character (#2965)

* fix: footer tcs in replacement generating one per character

* feat: added split button for button styles

* fix: generate new nsid when list style is changed

* fix: changing list style changes current identation

* fix: list item de-indentation

* fix: lockfile

* chore: removed console log

---------

Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: Caio Pizzol <caio@superdoc.dev>
Co-authored-by: Luccas Correa <luccas@harbourshare.com>
Co-authored-by: Nick Bernal <117235294+harbournick@users.noreply.github.com>
…TextTarget (SD-2812) (#3010)

* feat(superdoc/ui): selection slice exposes SelectionTarget alongside TextTarget (SD-2812)

A consumer reading the current cursor through `ui.selection` got a
TextTarget — the right shape for `editor.doc.comments.create` and other
range mutations. The same cursor in SelectionTarget shape (kind:
'selection' with explicit start/end SelectionPoints) is what
`editor.doc.insert`, `editor.doc.text.replace`, and similar
point/range operations expect. Two shapes for the same cursor, no
public conversion, so every consumer wrote the lift manually:

  const seg = ui.selection.getSnapshot().target?.segments[0];
  const target = {
    kind: 'selection',
    start: { kind: 'text', blockId: seg.blockId, offset: seg.range.start },
    end: { kind: 'text', blockId: seg.blockId, offset: seg.range.end },
  };
  editor.doc.insert({ value, type: 'text', target });

This was real friction: the build-your-own-ui example app had to do
exactly this conversion in its custom Insert Clause command. Filed as
SD-2812.

Fix: add `selectionTarget` to `SelectionSlice`, computed from `target`
in the same memo path so identity stays stable across no-op
recomputes. Single-segment selection produces start/end on the same
blockId; multi-block uses the first segment's start and the last
segment's end (the doc-api adapter resolves inner blocks via the same
walk it already does internally).

  ui.selection.getSnapshot().target          // TextTarget — for comments / format.apply
  ui.selection.getSnapshot().selectionTarget // SelectionTarget — for insert / replace

Also re-exports `SelectionTarget`, `SelectionPoint`, `TextTarget`,
`TextSegment`, `TextAddress`, and `EntityAddress` from `superdoc/ui`
and the public `superdoc/ui` sub-entry. Consumers no longer need to
know `@superdoc/document-api` exists. Partial close on SD-2815 (the
broader type re-export pass).

Tests: 2 new (multi-block lift, null pass-through) + 2 updated for
the wider slice shape. 104 ui tests pass, tsc -b clean.

* fix(superdoc/ui): preserve story field when lifting selection target (PR #3010 review)

The TextTarget→SelectionTarget converter dropped the optional `story`
field. Mutation operations route from `target.story`; without it,
inserts and replaces against `ui.selection.selectionTarget` for a
header / footer / footnote / endnote selection would silently route
into the body and either fail to resolve the block or edit the wrong
story.

Fix: copy `story` onto every SelectionPoint and onto the
SelectionTarget root. Also fold `story` into the selection memo key
so a cursor move from one story to another (same blockId/offset by
coincidence) busts the cache and re-derives the slice.

Adds a regression test for the header-selection case.

* feat(superdoc/ui): official React provider and hooks (SD-2813) (#3011)

* feat(superdoc/ui): official React provider and hooks (SD-2813)

The build-your-own-ui example app had to write four pieces of glue
from scratch: a context provider that creates one controller per app,
a hook to read it, a hook that subscribes a component to a slice of
controller state, and the unmount lifecycle that destroys the
controller. Two of those are easy to get wrong — the unmount in
particular has a stale-closure bug (`useEffect(() => () =>
ui?.destroy(), [])` captures the initial null, leaks subscriptions on
unmount).

Ship the bindings officially so consumers don't reinvent them.

  import {
    SuperDocUIProvider,
    useSuperDocUI,
    useSuperDocHost,
    useSetSuperDoc,
    useSuperDocSlice,
    useSuperDocSelection,
    useSuperDocComments,
    useSuperDocReview,
    useSuperDocToolbar,
    useSuperDocCommand,
  } from 'superdoc/ui/react';

Plumbing
  - New `./ui/react` exports entry on @superdoc/super-editor.
  - New Vite build input emitting `dist/ui-react.es.js`.
  - Public sub-entry on the superdoc package: `superdoc/ui/react`.
  - Vite alias ordered before `/ui` so the longer path matches first.
  - `react` and `react/jsx-runtime` externalized in the rollup config
    so the built artifact stays peer-dep-friendly.
  - tsconfig grows `"jsx": "react-jsx"` so `.tsx` files compile.
  - @testing-library/react + react-dom + @types/react-dom added as
    devDeps.

Verified
  - 10 new tests (6 provider, 4 domain hooks); 114 ui tests pass total
  - tsc -b clean
  - superdoc build emits dist/ui-react.es.js with react +
    react/jsx-runtime as external imports
  - bundle audit still clean

Open follow-ups: useSuperDocCustomCommand once `ui.commands.get(id)`
lands (SD-2814). Vue equivalent deferred until the React surface is
exercised against real consumer apps.

* fix(superdoc/ui): address PR #3011 review (StrictMode + id resubscribe + types) (SD-2813)

Four review fixes from the React-bindings PR:

1. SuperDocUIProvider.setSuperDoc no longer constructs the controller
   inside a setUI((prev) => ...) updater. Under React StrictMode, React
   double-invokes state-updater functions for purity-checking, which
   would call createSuperDocUI() twice and leak one controller's
   editor / SuperDoc subscriptions per call. Construction now lives
   in the callback body and the prior controller is torn down via the
   uiRef. A regression test reproduces the leak (24 editor.on calls
   under StrictMode without the fix vs 12 with).

2. useSuperDocCommand(id) bypasses useSuperDocSlice and subscribes
   with [ui, id] effect deps. The selector closes over id, but
   useSuperDocSlice's effect only re-runs when ui changes, so a
   toolbar that reuses one component slot with different command ids
   would observe the prior command forever. A regression test
   reproduces the stale read.

3. typesVersions adds the ui/react entry so TypeScript projects on
   the legacy moduleResolution: "node" can resolve declarations.
   exports already had it, but typesVersions is what matters for
   classic resolution (the project follows this pattern for every
   other public subpath).

4. super-editor's vite build externalizes react/jsx-runtime defensively.
   The published consumer path (superdoc/ui/react) already externalized
   correctly via aliases, but this keeps the intermediate
   @superdoc/super-editor dist compatible with React 17/18 hosts in
   case any pnpm-link / examples consumer reaches it directly.

* feat(superdoc/ui): ui.commands.get(id) for dynamic toolbar lookup (SD-2814) (#3013)

* feat(superdoc/ui): ui.commands.get(id) for dynamic toolbar lookup (SD-2814)

Adds a typed string-indexed lookup on the commands surface so consumers
iterating over command IDs from a config array can resolve handles
without unsafe casts.

The Proxy-driven `ui.commands` mixes per-command handles, the
`register()` method, and custom IDs, which makes string-indexed
lookup type-error today: consumers fall back to `as unknown as`
casts at every dispatch site.

`ui.commands.get(id)` returns a unified `DynamicCommandHandle` for
built-in or custom IDs and `undefined` for unknown IDs. Custom
takes priority so `register({ override: true })` is honored. The
emitted state carries the `source` discriminator so a single
render path can drive both built-ins and customs without branching.

* fix(superdoc/ui): cached dynamic handle dispatches through later override (SD-2814)

Addresses PR #3013 review (P1): a `DynamicCommandHandle` returned by
`ui.commands.get('bold')` before a later
`register({ id: 'bold', override: true })` kept routing execute
through `toolbarController.execute('bold', ...)` even though the same
handle's observe stream now emits the merged custom state. Config
driven toolbars that memoize handles once would render the override
visually while clicks ran the original built-in.

The built-in dynamic handle now re-resolves at dispatch time:
`customCommandsRegistry.has(id)` is checked before falling back to
the toolbar controller, so override semantics hold for long-lived
handles. Two regression tests cover the override path and the
revert-after-unregister path.

* feat(superdoc/ui): re-export public document types (SD-2815) (#3014)

* feat(superdoc/ui): re-export public document types (SD-2815)

The browser UI controller surfaces document-side shapes everywhere:
state.comments.items returns CommentInfo records, action methods
return Receipt, ui.viewport.scrollIntoView accepts ScrollIntoViewInput,
state.review.items references TrackChangeInfo, etc. Consumers typing
their components had to reach into @superdoc/document-api directly,
which isn't on the recommended import path.

Re-exports the controller-surfaced doc-api types from superdoc/ui
and packages/superdoc/src/ui.d.ts so consumers can write the
custom-toolbar / sidebar example types entirely from one entrypoint.
The types resolve to the same shapes reached through the root
superdoc import - parity asserted in customer-scenario.ts via
distribution-equivalence checks.

Adds:
- CommentInfo / CommentsListQuery / CommentsListResult
- TrackChangeInfo / TrackChangesListResult
- Receipt
- ScrollIntoViewInput / ScrollIntoViewOutput
- SelectionInfo
- CommentAddress / TrackedChangeAddress

* fix(superdoc/ui): ship real doc-api types for packed consumers (PR #3014 review)

The PR #3014 (SD-2815) re-exports added on superdoc/ui resolved
through @superdoc/document-api, which is private to the workspace
and not published. The ensure-types.cjs post-build step generated an
ambient `declare module '@superdoc/document-api' { ... = any }` shim
in dist/_internal-shims.d.ts so consumer compiles wouldn't error,
but every doc-api type re-exported through superdoc/ui (CommentInfo,
Receipt, SelectionInfo, TextTarget, etc.) collapsed to `any` for
packed consumers. Components typed against these shapes had no
checking, defeating the purpose of the new public surface.

Three coordinated changes:

1. vite.config.js dts plugin now includes ../document-api/src/**/*
   so the document-api types emit into dist/document-api/. tsconfig
   include matches.
2. ensure-types.cjs rewrites every bare @superdoc/document-api
   specifier in emitted .d.ts files to a relative path into
   dist/document-api/, and skips the package when generating the
   _internal-shims.d.ts ambient declarations (parity with how
   @superdoc/super-editor was already handled).
3. tests/consumer-typecheck/customer-scenario.ts adds an
   `IsNotAny<T>` distribution check on every newly re-exported
   doc-api type. If a future change drops the document-api dist or
   loses the import-rewrite, the consumer-typecheck fails (assigning
   `boolean` to `true`).

Verified end-to-end by packing superdoc, installing into
consumer-typecheck, running tsc --noEmit. The IsNotAny guards pass;
_internal-shims.d.ts no longer carries the doc-api types.

* feat(superdoc/ui): ui.selection.capture for sidebar / floating-menu composers (SD-2821) (#3016)

* feat(superdoc/ui): ui.selection.capture for sidebar / floating-menu composers (SD-2821)

A sidebar comment composer or floating menu takes focus into its
own input element when it opens; the editor's selection visually
clears and `state.selection.target` becomes null. A consumer that
calls `editor.doc.comments.create({ target })` on submit then has
no anchor and the create rejects.

Adds `ui.selection.capture(): SelectionCapture | null` so consumers
freeze the addressable selection at the moment the composer opens
(or the menu mounts) and pass `captured.target` /
`captured.selectionTarget` straight into `editor.doc.*` actions
when the composer submits. The captured handle is `Object.freeze`d
so a stored reference can't be accidentally mutated across renders.

Visual restore (re-focus the editor + re-highlight the captured
range) is intentionally NOT on this surface yet. The public
Document API has no `selection.set` primitive today, and routing
through `editor.commands.*` would skip the contract this controller
is explicitly built on. A `restore(capture)` method lands once the
doc-api primitive does.

Returns null on non-text selections, no-editor state, or pre-ready
snapshots so the consumer's null-guard is the explicit "capture
isn't applicable here" signal instead of silent failure later in
the flow.

3 new tests: addressable-selection capture, null-on-no-anchor,
captured value survives a later live-selection clear (the
documented sidebar-composer scenario). 131 ui tests pass; bundle
audit clean.

* fix(superdoc/ui): deep-freeze captured selection (PR #3016 review)

The shallow `Object.freeze({ ...slice })` in `ui.selection.capture()`
left nested fields (target, target.segments, activeMarks array)
mutable AND sharing references with the controller's memoized
selection slice. A consumer doing
`captured.target.segments[0].range.start = 99` or
`captured.activeMarks.push('foo')` would corrupt the shared
snapshot every other subscriber sees and feed bad targets into
later editor.doc.* calls, despite the API promising a frozen
captured handle.

Capture now deep-clones the slice before deep-freezing the clone.
The clone breaks the reference share with the memo so a frozen
captured handle is genuinely independent. JSON-style structural
clone is sufficient: the selection slice is plain data with no
functions, Dates, Maps, or cycles. Recursive freeze short-circuits
on already-frozen values so EMPTY_ACTIVE_IDS doesn't loop.

Regression test asserts every nested field is frozen and that
strict-mode mutation attempts on captured.target.segments[0].range
and captured.activeMarks throw, leaving the live snapshot from
ui.selection.getSnapshot() unaffected.

* fix(superdoc/ui): unify command dispatch + handle identity + memo key + polish (PR #3010 review)

Six review findings rolled into one commit. Each verified and tested.

1. Memo key for selection slice was using made-up StoryLocator
   field names (`story.type` / `story.id`) instead of the real
   discriminated-union shape (`storyType` / `refId` / `noteId` /
   `section` / `headerFooterKind` / `variant`). Two selections in
   different stories collapsed to the same key, defeating the memo
   bust the comment claimed. Fixed to walk every discriminating
   field; once the doc-api resolver starts stamping `target.story`
   for non-body surfaces (separate ticket), cross-story navigation
   correctly invalidates the slice.

2. Override-routing inconsistency. `ui.commands.get(id)?.execute()`
   re-resolved through the custom registry on every call, but
   `ui.commands.bold.execute()` and `ui.toolbar.execute('bold')`
   went straight to the headless-toolbar built-in. After
   `register({ id: 'bold', override: true })`, `state.toolbar.commands.bold`
   showed `source: 'custom'` while clicks via the per-id /
   aggregate surfaces ran the original built-in. Centralized as
   `dispatchCommand(id, payload)`; all three paths use it. Two
   regression tests cover the per-id and aggregate surfaces.

3. Stale custom-command execute / observe through replacement.
   `regA.handle.execute()` after a custom-vs-custom replace ran
   B's executor because `buildHandle` closed only over `id` and
   `registry.execute(id)` was identity-blind. Bound the handle to
   its own `InternalCustomEntry` and added an identity check on
   every execute and observe emit: stale handles return `false`
   from execute and detach from observe.

4. Stale JSDoc on `ui.comments.reopen`: said the doc-api
   "currently throws INVALID_INPUT". SD-2789 shipped the lifecycle
   inverse; updated the prose to match.

5. Stale "skeleton" framing in the top-level types.ts JSDoc:
   the surface is no longer a skeleton. Reframed to describe the
   substrate vs. domain handles split.

6. `SelectionCapture` was typed as `SelectionSlice` even though
   the runtime calls `deepFreeze`. Changed to `DeepReadonly<SelectionSlice>`
   so the static type matches reality; consumer mutations on
   nested fields are TypeScript errors at compile time.

Plus: added a JSDoc paragraph on `SelectionSlice.selectionTarget`
documenting the known gap that the doc-api resolver doesn't yet
stamp `target.story` for non-body surfaces (header / footer /
footnote / endnote). Story preservation in the lift is honored
once the resolver starts stamping; tracked as a separate doc-api
ticket.

Plus: added `src/ui-react.js` to the superdoc package's coverage
exclude list. The file is a pure re-export barrel like the other
already-excluded `superdoc/headless-toolbar*` and `superdoc/ui`
barrels; missing it from the list caused the codecov patch report
to flag 8 missing lines for SD-2813.

Verified: 136 ui tests pass (4 new regressions); bundle audit clean.
#3020)

TS2352 in CI: TextTarget doesn't sufficiently overlap with { story?: Record<string, unknown> }, so cast via unknown first.
* chore: pr builder
@harbournick harbournick self-assigned this Apr 30, 2026
@harbournick harbournick requested a review from a team as a code owner April 30, 2026 13:41
@github-actions
Copy link
Copy Markdown
Contributor

📖 Docs preview: https://superdoc-nick-merge-main-stable.mintlify.app

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 981e97fd2b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@codecov-commenter
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@harbournick harbournick merged commit 0fca49c into stable Apr 30, 2026
67 checks passed
@harbournick harbournick deleted the nick/merge-main-stable branch April 30, 2026 15:01
@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented Apr 30, 2026

🎉 This PR is included in superdoc v1.30.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 1, 2026

🎉 This PR is included in superdoc-cli v0.8.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 1, 2026

🎉 This PR is included in superdoc-sdk v1.8.0

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 1, 2026

🎉 This PR is included in @superdoc-dev/mcp v0.3.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 4, 2026

🎉 This PR is included in template-builder v1.9.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 5, 2026

🎉 This PR is included in vscode-ext v2.3.0

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 7, 2026

🎉 This PR is included in @superdoc-dev/react v1.3.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 16, 2026

🎉 This PR is included in esign v2.7.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 16, 2026

🎉 This PR is included in esign v2.5.1-next.1

The release is available on GitHub release

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants