Skip to content

Merge main into stable#3378

Closed
superdoc-bot[bot] wants to merge 178 commits into
stablefrom
merge/main-into-stable-2026-05-19
Closed

Merge main into stable#3378
superdoc-bot[bot] wants to merge 178 commits into
stablefrom
merge/main-into-stable-2026-05-19

Conversation

@superdoc-bot
Copy link
Copy Markdown
Contributor

@superdoc-bot superdoc-bot Bot commented May 19, 2026

Summary

  • creates merge/main-into-stable-2026-05-19 from stable
  • merges main into the candidate branch
  • opens the promotion PR to stable

Auto-created by promote-stable workflow.

caio-pizzol and others added 30 commits May 7, 2026 17:35
* ci: auto-resolve version conflicts in promote-stable workflow

* ci: also match root-level release artifact files in auto-resolve
…#3200)

* fix(mcp): fall back to z.unknown() for oneOf with non-object variants

z.looseObject({}) emits type:"object" which is right for object-only
unions but rejects arrays/booleans/etc. at runtime when the union
includes non-object variants. Gate the looseObject path on "every
variant is type:object" and fall back to z.unknown() otherwise. The
only catalog field this affects today is superdoc_edit.content
(oneOf object|array), where the array form was getting rejected
before reaching DocumentApi.

Adds a unit test that walks the catalog and checks the emitted
type for both branches.

* docs(mcp): tighten oneOf branch comment with AIDEV-NOTE anchor
Strengthens the existing reminder so agents look for comment-policy.md
explicitly and run /comment-audit to validate changes, instead of
relying on the bare 'follow comment-policy.md' wording.
* feat(contracts): add typed direction context types (SD-2775)

Introduces orthogonal direction context types so future RTL work cannot
accidentally collapse axes that ECMA-376 keeps separate:

- BaseDirection, WritingMode (enums)
- SectionDirectionContext (page direction, gutter — chrome only)
- TableDirectionContext (visual cell ordering only)
- CellDirectionContext (cell writing mode)
- ParagraphDirectionContext (paragraph inline base direction + writing mode)
- RunBidiContext, RunScriptContext (run-level signals; consumed in 1b/1c)

Adds `directionContext` field to ParagraphAttrs alongside the existing
`direction` scalar. Both are populated by pm-adapter from the same source;
consumers can migrate gradually.

Per ECMA-376 §17.6.1 / §17.3.1.6 / §17.4.1 / §17.3.1.41, each axis stays
separate: section bidi is chrome only, paragraph bidi is paragraph-local,
table visual direction is cell ordering, writing mode is the one
inheriting axis.

No behavior change. Resolver chain and migration follow in subsequent
commits.

* feat(pm-adapter): add direction resolver module (SD-2776)

New module pm-adapter/src/direction/ with:

- resolveSectionDirection / resolveTableDirection / resolveCellDirection /
  resolveParagraphDirection — context propagation chain mirroring OOXML
  containment hierarchy
- logicalSides helpers (resolveLogicalAlignment, resolveLogicalIndent,
  physicalSide, isRtl, toBaseDirection) — direction-aware logical→physical
  mapping
- 12 non-collapse tests enforcing the four ECMA spec rules:
  1. Section w:bidi MUST NOT make paragraphs RTL (§17.6.1)
  2. Table w:bidiVisual MUST NOT make cell paragraphs RTL (§17.4.1)
  3. Run-level w:rtl MUST NOT bubble up to paragraph
  4. Paragraph w:bidi DOES produce paragraph RTL (§17.3.1.6, including
     style cascade through docDefaults per §17.7.2)
- Writing mode IS the one inheriting axis (§17.3.1.41) — paragraph→cell→
  section→default

Co-located README documenting the spec rules, a worked-example for
downstream consumers, and explicit non-goals (script classifier and bidi
controls deferred to Wave 1b / 1c).

No production call sites consume the resolver yet; migration follows.

* refactor(pm-adapter): migrate computeParagraphAttrs to direction resolver

Replaces the cascade in resolveEffectiveParagraphDirection +
inferDirectionFromRuns with the typed resolver chain from
pm-adapter/src/direction/. The cascade had three issues identified by
the audit at .tmp/rtl-audit-findings.md:

1. Section→paragraph fallback (§17.6.1 violation) — section bidi
   propagated to paragraph inline direction. Latin paragraphs in RTL
   sections rendered right-aligned; Word renders them left-aligned.
2. Majority-of-runs heuristic (UAX #9 P2/P3 disagreement) — base
   direction came from counting runs whose w:rtl flag was set, not
   the first strong character of the text content.
3. docDefaultsDirection parameter (redundant) — the style-engine
   cascade in style-engine/src/ooxml/index.ts:165 already resolves
   docDefaults.paragraphProperties.rightToLeft into the paragraph's
   resolved properties before this resolver runs.

Now: paragraph direction comes from paragraph w:bidi (or its style
cascade); when absent, inlineDirection is undefined and the browser
applies UBA via the missing dir attribute. Output corrected for
documents that today render incorrectly; unchanged for documents that
were already correct.

Tests updated:
- paragraph.test.ts: removed cascade/heuristic tests that codified
  the spec violations
- paragraph.test.ts: section-fallback test flipped to assert no
  inheritance
- index.test.ts: two integration tests flipped to expect undefined
  paragraph direction when only section bidi is set

Validation:
- 1,765 pm-adapter unit tests pass
- 211 contracts unit tests pass
- 12,374 super-editor unit tests pass (incl. footer w:rtl roundtrip)
- 51 RTL Playwright behavior tests pass across Chromium/Firefox/WebKit

Closes SD-2776, SD-2778. The legacy attrs.direction scalar remains
populated for backwards compatibility; consumers should migrate to
attrs.directionContext over time.

* fix(direction): accept rightToLeft on TablePropertiesLike

The resolved TableProperties type from the style-engine uses
`rightToLeft` for the bidiVisual flag (matching the existing importer
convention). The resolver previously checked only `bidiVisual`, so
passing real resolved table properties would leave visualDirection
undefined for RTL tables.

Now accepts both `rightToLeft` (style-engine name) and `bidiVisual`
(OOXML name) for safety. Test added to cover the alias.

* fix(direction): map all ST_TextDirection values incl. V-suffix variants

Per ECMA-376 §17.18.93, ST_TextDirection has 12 enumeration values across
the strict and Word-transitional vocabularies. The V-suffix variants are
glyph rotation, which CSS expresses through text-orientation, so they share
the writing-mode of their non-V sibling.

Before this commit the three resolvers (paragraph/section/cell) handled 6
of the 12 values; lrTbV, tbRlV, tbV, lrV, rlV all fell through to undefined
and the resolver silently used the inherited/default writing-mode instead.
The repo's ST_TEXT_DIRECTION contract (registry.ts:18) publishes lrTbV and
tbRlV as accepted values, so this was a contract violation - documents that
imported one of these would lose their writing-mode override.

Adds an exhaustive test that exercises all 12 values on paragraph, section,
and cell.

* fix(layout-bridge): separate text direction from RTL hit testing

* fix(layout-bridge): harden isRtlBlock and anchor compat-fallback rule

Three review-driven nits on the SD-2780 hit-test fix:

1. The directionContext gate used `'inlineDirection' in directionContext`
   which fires for keys with `undefined` values. The resolver can produce
   `inlineDirection: undefined` when no paragraph w:bidi is set anywhere
   in the cascade, and the function would then return false instead of
   falling through to the legacy direction/dir field. Check the value, not
   the key.
2. Anchor the legacy direction/dir fallback as compat-fallback per
   comment-policy.md so future agents know what triggers it (no typed
   directionContext) and when it can be retired (SD-2778 collapses the
   duplicate field).
3. Document why `attrs.textDirection` is no longer in the chain. Per ECMA
   §17.18.93, ST_TextDirection values are writing-mode (lrTb/tbRl/btLr/
   lrTbV/tbRlV/tbLrV); none equal 'rtl'. The old check was always dead.

New test covers the precedence edge case.

---------

Co-authored-by: Caio Pizzol <caiopizzol@gmail.com>
…or (#3201)

Extract resumePackagePublish switch into per-descriptor resumePublish
functions and replace pkg.name === 'sdk' branches with capability checks
(pkg.pythonPackages, pkg.preparePythonSnapshot). No behavior change:
the recovery engine becomes generic so adding superdoc/react/vscode-ext
in follow-up PRs only adds adapters, not new switch arms.

The internal field state.sdkPythonPublished is renamed to
state.pythonPublished and recovery's returned snapshot field
sdkPythonSnapshot to pythonSnapshot. recordSdkPythonSnapshot keeps its
name so it continues emitting the sdk_python_snapshot_* GITHUB_OUTPUT
keys consumed by release-stable.yml.

Existing helper tests updated to match the refactored structure (the
intent - each package has its own explicit resume path - is preserved).
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
#3120)

* chore: save

* feat: update TOC entry in context menu

* fix: update TOC creating extra spaces and changing fonts

* test: created tests around TOC programmatic update

* refactor: removed unused code

* refactor: code style tweaks

* refactor: removed duplicate functions

* test(toc): add regression coverage for SD-2664 review findings

- tabLeader: 'none' must round-trip via serialize/parse (currently lost
  because no \\p is emitted when separator is missing, and the parser has
  no way to disambiguate "default = dots" from "explicit none").
- toc.configure({ tabLeader: 'none' }) on a default-leader TOC must not
  silently no-op (areTocConfigsEqual reports identical serialized output).
- toc.update mode: 'pageNumbers' must find tocPageNumber marks when the
  marked text is nested inside a run wrapper (the rebuild output shape).

All three tests fail on the current branch and lock in the regressions
flagged in code review.

* fix: toc context menu update

* refactor: simplified logic

* refactor: removed unnecessary test suite

* refactor: simplified tests

* chore: small comment tweaks

* test: added behavior test for multiple TOCs updates

* fix: inline partial selection to produce inline text

* fix: toc from empty to non-empty

* fix: early return on TOC update

---------

Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
Co-authored-by: Caio Pizzol <caio@superdoc.dev>
… paragraphs (SD-2973) (#3210)

* fix(converter): preserve hyperlink mark on inserted text split across paragraphs (SD-2973)

SD-2858's preserveRaw path keeps tracked-change-wrapped fields structurally
intact when the field crosses paragraph or wrapper boundaries, avoiding the
import crash. For destructive wrappers (w:del / w:moveFrom) this trade-off
is invisible since the content disappears on accept, but for constructive
wrappers (w:ins / w:moveTo) the user keeps the inserted text and Word
shows it both as inserted AND as a clickable hyperlink — the previous
behaviour rendered it with insertion styling alone.

Add an `isConstructiveTrackChangeElement` predicate, propagate a
`preserveRawConstructive` flag through the field collector, and run a
post-pass that finds the visible runs (between separate and end fldChars)
and wraps them in `w:hyperlink` in-place. The surrounding paragraph and
tracked-change wrapper structure is left intact so the SD-2858 round-trip
guarantee still holds.

* refactor(converter): share hyperlink attribute resolution between field paths (SD-2973)

Per Luccas's review on PR #3210: the URL/anchor parsing and rels-element
construction in applyConstructiveFieldInterpretation duplicated the same
logic in preProcessHyperlinkInstruction.

Extract resolveHyperlinkAttributes(instruction, docx) as the single source
of truth for parsing a HYPERLINK field instruction into the attribute set
that belongs on a w:hyperlink element. Both preProcessHyperlinkInstruction
(the standard field path) and applyConstructiveFieldInterpretation (the
SD-2973 raw-preserved constructive-tracked-change path) call it.

Net change: ~-5 lines, single source of truth, no behaviour change.
* fix: footer tcs in replacement generating one per character (#2965)

* fix: footer tcs in replacement generating one per character

* fix: sdt with "contentLocked" not removable

* chore: removed console log

* fix: allow single click to target whole field

---------

Co-authored-by: Nick Bernal <117235294+harbournick@users.noreply.github.com>
Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com>
…#3212)

Adds two color swatches to the toolbar that re-theme tracked changes and
comment highlights by writing the public --sd-* CSS variables on :root,
and centers the editor in its column with a max-width wrapper.

- Native <input type="color"> hidden behind a tb-btn label keeps the
  no-UI-kit posture of the demo.
- Icons inside each swatch use mix-blend-mode: difference so they stay
  legible across any picked color.
- The editor sits inside an .editor-canvas wrapper (max-width 880px,
  margin auto) so the document is centered between the toolbar and the
  activity sidebar.
* feat(workflows): notify homepage repo on stable superdoc release

Mirrors the promote-stable-docs.yml pattern: workflow_run on Release
superdoc, gated on success + stable, with a tag-diff against the
triggering head_sha to filter out semantic-release no-ops. Sends
repository_dispatch to superdoc-dev/homepage so a receiver workflow
there can open one bump PR per release.

Kept out of release-superdoc.yml on purpose: that job sits in the
release-stable concurrency group, and a homepage/token failure should
not mark a successful npm publish as failed.

* refactor(workflows): simplify notify workflow to a single step
* fix: clear transient hyperlink styleId on unlink

* test: add unlink regression coverage for transient hyperlink style cleanup

* fix(link): derive underline preservation at unlink time and add imported-link regression

* fix(link): tag paste-added underline as autoAdded so unsetLink removes it

When a user pastes a bare URL, handlePlainTextUrlPaste auto-converts it
to a link and adds an underline mark. The PR's autoAdded mechanism in
unsetLink only removes underline marks tagged autoAdded:true, so the
paste-added underline (untagged) was left behind on Remove Link.

Same one-line fix as setLink at link.js:247. Adds regression coverage
to relationships.test.js for: paste-URL unlink, setLink with longer
replacement text, mixed-underline selections, and re-setLink.

* test(link): drop paraphrase comments to match local convention

Existing tests in relationships.test.js use no inline comments;
removing the four added in the previous commit so the new tests
match local style and the comment policy.

---------

Co-authored-by: Caio Pizzol <caio@harbourshare.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
…ands (SD-3083) (#3217)

* fix(super-editor): guard cached paragraph props lookup in indent commands (SD-3083)

Increase/Decrease Indent crashed when fired before the rendering pass had
populated the resolved-paragraph-properties cache (fresh load, freshly
inserted paragraphs). Falls back to compute-on-miss so the style cascade
is honored, instead of a bare guard which would stop the crash but apply
the wrong delta on style-derived indents.

Set/Unset commands keep the original code path - they don't read the
current indent and don't need the resolve work.

* test(super-editor): expand textIndent regression coverage (SD-3083)

Unit tests:
- decreaseTextIndent honors style-derived indent on cache miss (symmetric
  to the existing increase regression test)
- Cache hit short-circuits the compute-on-miss fallback - inverse of the
  set/unset opt-out test, guards the production || short-circuit

Behavior test (tests/behavior/tests/toolbar/paragraph-indent.spec.ts):
- Increase Indent on a fresh paragraph adds indent without crashing
- Decrease Indent removes the indent applied by Increase
- Repeated Increase compounds the left indent

* docs(super-editor): fix unsetTextIndentation @example typo

Pre-existing typo - example used `unsetTextIndent()` but the function
is `unsetTextIndentation`. Found during a comment audit on this branch.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* feat(super-editor,painter): render images inside Word textboxes (SD-2804)

ECMA-376 §20.4.2.38 (CT_TxbxContent) lets a textbox hold rich body-level
content — paragraphs whose runs can carry inline w:drawing images. The
text-only extractor used to silently skip those drawings, so the textbox
rendered empty even though export round-tripped the image untouched.

The fix surfaces the inline drawing as a textContent part with kind='image'
so the existing shape painter can render it alongside text spans:

- TextPart contract gains optional kind/src/width/height/alt fields.
- extractTextFromTextBox.handleRun branches on w:drawing, reuses the v3
  wp drawing handler (handleImageNode) to resolve rId, then upgrades the
  path-style src to a data URI from converter.media so the painter can
  drop it straight into <img>.
- DomPainter's createFallbackTextElement renders image parts as inline
  <img> elements next to existing text spans.

Linked: SD-2745 (header-anchored floating textboxes — positions the box
where this content now renders).

* fix(super-editor,pm-adapter,painter): address PR #3207 review (SD-2804)

Per Luccas's review on PR #3207:

- (C1) Skip hidden textbox images. handleImageNode flags wp:docPr
  hidden="1" via attrs.hidden, but the new image-part branch only checked
  attrs.src and emitted visible <img>s for them. Top-level hidden drawings
  are filtered later in the pipeline; image parts bypass that filtering.
  Gate the textParts.push on imagePm.attrs.hidden !== true so hidden
  textbox drawings stay hidden, matching the body-level behaviour.

- (C2) Drop the duplicated resolveImagePartSrc helper in the importer
  (it rejected Uint8Array, breaking Y.js binary media). Store the raw
  path + extension + rId on the image part. pm-adapter's hydrateImageBlocks
  gains a vectorShape branch that hydrates textContent.parts alongside
  ImageRuns, so all media path candidates and the Uint8Array → TextDecoder
  decoding live in a single place.

- (C3) Anchored drawings inside textboxes are out of scope — wrap /
  position / transform metadata isn't carried into the text-parts model.
  Restrict the textbox-image branch to wp:inline and document the limit
  in the code comment so a future fixture can extend it intentionally.

- (C4) Align inserted images to the text baseline like body inline images
  do (vertical-align: bottom). ECMA-376 §20.4.2.8 specifies that an inline
  drawing behaves "like a character glyph of similar size", and the body
  inline image renderer defaults to vertical-align: bottom (renderer.ts
  ~L5770, L5847) — the textbox image part used vertical-align: middle,
  visibly misaligning text next to the image inside a textbox compared
  to outside it.
…stop) (#3204)

Adds superdoc to the stable orchestrator so the v* tag drives docs-stable
promotion in the same workflow that releases tools, removing the cross-
workflow concurrency-eviction problem for stable superdoc releases.

The orchestrator now groups packages by chain. Within a chain, fail-stop
applies as before (CLI failure skips SDK/MCP). Across chains, failures
are independent: a tools failure does not skip superdoc and vice versa.

Workflow rewiring:
- release-superdoc.yml stops auto-firing on stable; main pushes still
  publish prereleases, and workflow_dispatch is preserved for recovery.
- promote-stable-docs.yml triggers off release-stable.yml. The conclusion
  gate now accepts both success and failure - a tools-chain failure that
  follows a successful superdoc release should still promote docs. The
  inner git-tag detection (compare tags merged at the run's head_sha vs
  origin/stable) remains the source of truth, so tools-only runs still
  leave docs-stable alone.
- release-stable.yml header comments + step name updated to reflect the
  broader scope; the workflow's name field is unchanged so the existing
  workflow_run trigger and concurrency group continue to match. A rename
  is best as a follow-up cleanup PR.

Stacked on #3201 (descriptor refactor).
Adds react to the core chain after superdoc. react consumes `superdoc`
in dependencies, so releasing them in order through the orchestrator
means consumers never see a react release that pins to an older
superdoc than what just shipped.

- Adds `resumeReactPublish` adapter (uses generic `npm-publish-package.cjs`
  helper, idempotent against npm via dist-tag updates).
- Adds react descriptor to the `core` chain. A superdoc failure now skips
  react (fail-closed within chain); a tools failure still does not.
- `release-react.yml` stops auto-firing on stable; main pushes still
  publish prereleases.

Stacked on #3204 (superdoc orchestrator).
Completes the core chain: superdoc -> react -> vscode-ext. vscode-ext
publishes to the VS Code Marketplace (not npm) and ships a .vsix asset
on the GitHub release; the script's existing helpers already cover both,
so this PR adds the descriptor, the resume adapter, and the workflow
plumbing.

- `resumeVscodeExtPublish` runs `pnpm run package` then `vsce publish
  --skip-duplicate`; idempotent against the marketplace. In a tagged
  snapshot it also runs `build:superdoc` first so esbuild can resolve
  the webview's `superdoc` and `superdoc/style.css` imports through
  packages/superdoc/dist (snapshot only ran `pnpm install`).
- vscode-ext descriptor uses `vsCodeExtensionId` (no `npmPackages`), so
  `inspectPackageReleaseState` probes the marketplace, not npm.
- release-stable.yml's orchestrator step gains `VSCE_PAT` env so vsce can
  authenticate.
- release-vscode-ext.yml stops auto-firing on stable; main pushes still
  build .vsix attachments to the GitHub release.
- Three remaining `pkg.name === 'vscode-ext'` branches in the script
  (`getExpectedReleaseAssets`, `isGitHubReleaseComplete`, `ensureGitHubRelease`)
  switched to `pkg.vsCodeExtensionId` capability checks for consistency
  with PR #3201's refactor pattern.
Stable superdoc releases now ship via release-stable.yml (named
'📦 Release stable tooling (CLI/SDK/MCP)'), so listening to
'📦 Release superdoc' on workflow_run silently stopped firing for
stable releases. Mirrors the trigger change already in place on
promote-stable-docs.yml.

- Trigger now matches the orchestrator workflow name.
- Accept conclusion: failure too, so a tools-chain failure following a
  successful superdoc release still notifies (chain-independent
  orchestrator semantics).
- Verify both 'superdoc' and '@harbour-enterprises/superdoc' on npm
  before dispatching; semantic-release's prepare phase pushes the v*
  tag before publish, so a publish failure that does not recover would
  otherwise notify on an unpublished version.
buildTocEntryParagraphs wraps the page-number text inside a run, so the
tocPageNumber mark lives one level below the paragraph's direct
children. sanitizeTocContentForSchema only filtered marks on direct
children, so any schema that omits tocPageNumber (the case the
sanitizer exists to support) would still receive the unknown nested
mark and nodeFromJSON would fail when rebuilding the TOC.

Extracts the per-node mark-stripping into a recursive helper that
walks content arrays, so the sanitizer works regardless of how deep
buildTocEntryParagraphs nests the run wrapper.

Verified empirically before fixing:
- Reproduced the bug against a real prosemirror-model Schema lacking
  the tocPageNumber mark: unfixed sanitizer leaves the nested mark and
  nodeFromJSON throws 'There is no mark type tocPageNumber in this
  schema'; fixed sanitizer strips it and nodeFromJSON succeeds.
- Codified as 4 unit tests in toc-wrappers.test.ts (sanitizer exported
  for testing). With the fix reverted, 2 tests fail with the exact
  surviving-mark assertion. With the fix applied, all 13 tests pass.

Flagged by chatgpt-codex-connector in #3228.
* fix: missing headers in collab mode; wrong odd/even headers

* fix: rebuild cache and clean up

* fix: improve odd/even header selection
…el (SD-3109) (#3243)

The Document API write side accepts text offsets in a flattened model: text contributes its length, leaf atoms contribute 1, inline wrappers (run, etc.) contribute 0. Position readback was using raw PM arithmetic (`pos - resolved.start(depth)`), which counts the wrapper boundary tokens the flattened model skips. Result: `editor.doc.bookmarks.list()` returned offsets that drifted by the number of inline wrappers in the targeted block.

- Route both readback helpers through `pmPositionToTextOffset` so read and write share one offset model.
- Same fix applied in `permission-ranges-adapter.ts`, which carried a parallel copy of the buggy helper.
- Regression test for the round-trip invariant (write at offset N, list returns offset N).
- Includes regression coverage for SD-3108 (content-control wrap by text offset). That bug did not reproduce on main; the tests stay as guardrails.
…(SD-2781) (#3203)

* feat(pm-adapter): preserve run-level bidi/script metadata on TextRun (SD-2781)

Adds two preservation-only fields to the layout text-run contract, kept on
separate axes per ECMA's own categorization:

- TextRun.bidi (RunBidiContext): direction signals only - run rtl flag now,
  embedding (w:dir) and override (w:bdo) wired in Wave 1c.
- TextRun.script (RunScriptContext): complex-script flag + per-script
  language metadata (default / complexScript / eastAsian) per §17.3.2.20.

Both populated by pm-adapter from raw run properties when present. Wave 1a
does not render either; Wave 1b will gate the formatting-stack selection on
script.complexScript, Wave 1c will read bidi.embedding/override.

Why now: ECMA puts direction (rtl, dir, bdo) and script formatting (cs,
lang/@bidi) in different categories. Lumping them under one bidi field
would collapse the axes and lie about the schema. Adding both fields now
means Wave 1b/1c don't have to introduce both the data path and the
rendering at once.

The RunScriptContext.language field expanded from a single string to a
structured object with three optional tags. No production consumer reads
the field yet (preservation-only since #3184), so the shape change is
safe.

Tests:
- bidi populated only on rtl, script populated only on cs/lang
- explicit rtl=false preserved (a meaningful override)
- the three lang tags land on separate fields
- axis non-collapse: rtl never leaks into script, cs never leaks into bidi

* fix(contracts): import RunBidiContext + RunScriptContext locally for TextRun

The previous commit added `bidi?: RunBidiContext` and `script?: RunScriptContext`
to TextRun in contracts/src/index.ts but only re-exported the type names; they
were not in local type scope, so `tsc --project` failed:

  src/index.ts(324,10): error TS2304: Cannot find name 'RunBidiContext'.
  src/index.ts(330,12): error TS2304: Cannot find name 'RunScriptContext'.

Vitest's transform doesn't enforce type-only imports the way tsc does, so the
unit tests passed even though the build was broken. CI build would have caught
it; the missing piece was running `pnpm build` locally before pushing.

Adds the two names to the existing local `import type` statement alongside
ParagraphDirectionContext (same pattern, same line 19).

Also adds two integration tests in pm-adapter/src/integration.test.ts that
exercise the full PM -> FlowBlock conversion through the unmocked
applyInlineRunProperties pipeline. The previous unit tests in
common.test.ts mock computeRunAttrs, which is why they couldn't catch
shape-level regressions in the contracts package. The integration tests
prove a real PM doc with raw runProperties (rtl/cs/lang on a run-wrapper
node) produces a TextRun with populated bidi/script, and that runs without
signals don't gain empty objects.

* docs: drop duplicate ticket prefix on SD-2781 test block comment

Repo convention puts the ticket reference in the `describe` label only
(e.g., `describe('SD-1333: ...')`). Trims the redundant `SD-2781:` prefix
from the block comment, keeps the non-obvious "Wave 1a preserves these
signals; nothing renders them yet" note.

* fix(pm-adapter): read bidi/script from raw inline runProperties + reassign on token runs

Two codex findings on PR #3203:

1. Cascade-leak: applyInlineRunProperties was receiving cascade-resolved
   runProperties from runNodeChildrenToRuns and populating bidi/script from
   them. Style-inherited runs ended up with bidi/script metadata they did not
   have inline, making preservation indistinguishable from direct formatting
   and bloating the layout tree on every styled run.

   Fix: thread the raw inline runProperties through InlineConverterParams
   (alongside the existing cascade-resolved runProperties). applyInlineRunProperties
   gains a fourth parameter that bidi/script populate from. When the caller
   doesn't opt in (no inline parameter), no metadata is attached - safer
   default than reading the cascaded view.

2. Token-run drop: generic-token.ts called applyInlineRunProperties without
   reassigning the return value. Since the helper builds a new object via
   spread, all merged fields (including the SD-2781 bidi/script) were lost
   for page-number / total-page-count token runs inside an rtl run wrapper.

   Fix: change the local to `let` and reassign. Also forwards inlineRunProperties
   through. no-break-hyphen.ts (another caller) updated for consistency.

Tests added:
- 3 unit tests in common.test.ts proving bidi/script populate from
  inlineRunProperties only, never from cascade-resolved runProperties
- Existing 7 SD-2781 unit tests updated to pass inlineRunProperties
  (the new opt-in source)
- 1 integration test proving bidi/script propagate to page-number tokens
  inside an rtl run wrapper

All 1800 pm-adapter tests, 12644 super-editor tests, and 1201 layout-bridge
tests pass. Contracts + pm-adapter both build clean.

* fix(pm-adapter): cs absence != false + forward inlineRunProperties through nested converters

Two round-3 findings on PR #3203:

1. RunScriptContext.complexScript was required (boolean), so a run with only
   w:lang and no w:cs ended up with { complexScript: false, language: {...} }.
   Per ECMA §17.3.2.7, absent w:cs inherits from the style hierarchy and
   ultimately falls back to Unicode-based script detection - it is NOT the
   same as explicit false. Misrepresenting absence as false would mislead
   Wave 1b CS-formatting selection.

   Fix: make complexScript optional. Only set it when raw cs is explicitly
   present (true OR false - both are meaningful toggle states per §17.17.4).
   Leave undefined otherwise so consumers can fall back correctly.

2. Three nested inline converters (page-reference, bookmark-start,
   structured-content) called visitNode without forwarding the new
   activeInlineRunProperties arg, so text inside a PAGEREF result, a bookmark
   span, or an SDT wrapper lost run-level bidi/script metadata even when the
   enclosing run wrapper had it.

   Fix: thread inlineRunProperties through all three converters. In
   page-reference's bookmark path, also pass the locally-scanned run's raw
   runProperties (not the cascade-resolved version) as inlineRunProperties
   for the synthesized token run.

Tests:
- cs absent + only lang -> script exists, complexScript is undefined
- cs=false explicit -> script.complexScript === false (meaningful off)
- existing 'three lang tags' test unchanged
- new integration test: text inside structuredContent wrapper preserves
  bidi/script from the enclosing run wrapper

All 1803 pm-adapter tests pass (+3 new), 12644 super-editor pass, contracts
+ pm-adapter both build clean.

* docs: drop round-counter prefix from SD-2781 integration test header

Per project CLAUDE.md, code comments shouldn't reference iteration / review
state ("round-3 (codex finding)") - those belong in the PR description and
rot once the PR merges. The load-bearing content (which converters, what
invariant) stays.

---------

Co-authored-by: Caio Pizzol <caiopizzol@gmail.com>
* feat(types): deep public-type audit gate (SD-2977)

The existing public-type checks lock in exported type names and top-level
`any` assertions, but they do not catch `any` on public members, callback
params, return types, or nested type arguments. Consumers can still lose
IntelliSense after touching a public API surface that resolves to `any`.

Add a deep public-type audit that builds a TypeScript Program from the
packed-and-installed `superdoc` tarball, recursively walks every owned
type reachable from public export entries, and fails CI if a finding is
not in `deep-type-audit.allowlist.json`. Also fails on stale allowlist
entries (so fixes must remove their entry), on compiler diagnostics in
the published surface, and on private `@superdoc/*` specifiers that
survived rewriting in the installed package.

Allowlist seeded with the current 293 owned findings, tagged by tier so
follow-up PRs can drain by surface (Pinia stores, toolbar config,
helpers, curated public contract, other).

The gate runs in PR CI and release CI, after the consumer matrix
prepares the installed tarball. Closes the gap where release publish did
not run packed consumer checks.

The allowlist is a starting line, not a waiver. Per the README: do not
drain by replacing `any` with `unknown` unless the value is genuinely
opaque - prefer precise upstream or local types so IntelliSense actually
restores.

Verified locally:
- node tests/consumer-typecheck/typecheck-matrix.mjs: 53 passed
- node tests/consumer-typecheck/deep-type-audit.mjs: 293 findings, 0 new, 0 stale
- removing an allowlist entry produces NEW finding output and exit 1

* fix(types): walk construct signatures and index types in deep audit (SD-2977)

Codex review on the initial PR pointed out two walker blind spots:

1. The walker only visited `getCallSignatures()`, so a public
   `constructor(...args: any[])` (SuperConverter, DocxZipper, and others)
   shipped without producing a finding.
2. The walker only enumerated `getProperties()`, so `[key: string]: any`
   index signatures on public classes were never traversed.

Both gaps confirmed in the installed dist before the fix. Walker now
visits construct signatures alongside call signatures and queries
`getStringIndexType()` / `getNumberIndexType()` for index members. New
findings are tagged `tier-4-public-contract` for SuperConverter and
DocxZipper (per SD-2966's done-when criteria) and `tier-5-other` for
the remaining constructor-surface findings.

Allowlist regenerated: 293 → 337 entries.

Verified:
- node tests/consumer-typecheck/deep-type-audit.mjs: 337/0/0 PASS
- SuperConverter[string] and DocxZipper[string] now in tier-4

* fix(types): close 3 deep-audit walker blind spots (SD-2977)

Code review surfaced three coverage gaps in the seeded allowlist:

1. Sibling members sharing an instantiated container type (`a: any[]; b: any[]`)
   were path-order-dependent. TypeScript caches `Array<any>` and `Promise<any>`
   per-shape so siblings share a type id; the persistent-visited gate
   short-circuited the second sibling before its inner `any` could be
   recorded. Fix: pre-record `any` inside array elements and type
   arguments BEFORE the visited gate. Cycle protection still applies to
   structural members.

2. Class exports walked only the instance side (getDeclaredTypeOfSymbol),
   so `constructor(...args: any[])` and `static foo(): any` on public
   classes (SuperConverter, DocxZipper) never produced a finding. Fix:
   walkExport now also walks the value type (getTypeOfSymbolAtLocation)
   when it differs from the declared type, prefixed with `.<value>` so
   constructor / static findings are clearly distinguished.

3. `--pack` bootstrap failed on fresh checkouts because typescript was
   resolved at module-load (line 49) before the doPack block (line 52)
   that runs the fixture install. Fix: move the typescript require after
   the optional pack-and-install block so the documented bootstrap
   command actually works on a clean tree.

A stack-scoped visited variant was tried first but caused combinatorial
blow-up on highly interconnected types (16+ minutes with no progress on
the public surface). The pre-record + persistent-visited combination
keeps cycle protection bounded while still catching sibling regressions.

Allowlist regenerated: 337 -> 678 entries, with new tier-4 entries
covering SuperConverter / DocxZipper class-side `any`s and tier-5
absorbing the long tail of sibling-shared findings.

Verified:
- node tests/consumer-typecheck/deep-type-audit.mjs: 678/0/0 PASS
- SuperConverter.<value>.new(args)<0>, .new(args)[], static methods all in tier-4
- DocxZipper.<value>.new(args)<0>, .new(args)[] all in tier-4
- Run completes well under 60 seconds

* fix(types): raise deep-audit depth cap and surface truncation (SD-2977)

A third review round flagged three potential walker issues. Two were
stale (P1: SuperConverter/DocxZipper class-side findings — already
caught in fcf84a2, 16 entries in allowlist; P2: visited-singleton-any
skip — already prevented by isAnyType returning before the visited gate
in walkType lines 220-223). The third is real:

MAX_DEPTH=8 silently truncated 302,642 paths in a single audit run,
leaving deep public types invisible to the gate and contradicting the
README's claim of walking every reachable type. Persistent visited
handles cycles, but TypeScript materializes generic instantiations on
demand with fresh type ids that visited cannot dedupe, so the cap is
load-bearing memory protection (cap=256 OOM'd at ~4GB).

Empirical sweet spot at MAX_DEPTH=16: deep enough to reach 451 more
legitimate findings (allowlist 678 -> 1129), shallow enough to bound
memory. depthCapHits counter now surfaces in the run report so any
remaining truncation is visible rather than silent.

The new findings are concentrated in pinia store internals, vue
reactive chains, and prosemirror type expansions — surface areas the
team already knows are noisy. The cap warning lets future maintainers
decide whether further bumps are worth the memory cost.

Verified:
- node tests/consumer-typecheck/deep-type-audit.mjs: 1129/0/0 PASS
- WARN line surfaces depth-cap hits in the report
- No OOM at cap=16

* fix(types): cleanup audit per code review (SD-2977)

Three small cleanups bundled:

1. Strip em dashes from README and code comments. User CLAUDE.md
   prohibits em dashes in all output including code comments and docs;
   replaced with ":", "(", or "." per surrounding grammar.

2. Drop "flagged by Codex on the initial PR, fixed here" trailing
   clauses on two comments. The surrounding rationale (call sigs vs
   construct sigs; index sigs vs properties) is load-bearing and stays;
   only the review-process narration goes. CLAUDE.md says comments
   should not reference the current task or fix.

3. Fix walkExport's stale "stack-scoped" comment + add visited
   snapshot/restore around the value-side walk. The previous comment
   claimed visited pops on exit (the failed try/finally variant), but
   the actual code uses persistent per-root visited. That meant
   structural types reachable from both an export's instance and value
   sides were silently skipped on the value side. Snapshot/restore
   scopes the value walk's visited freshly without polluting subsequent
   exports' declared walks.

The visited fix surfaced 670 previously-hidden findings (1129 -> 1799),
990 of which live on .<value> paths (class value side). Composition
reinforces the umbrella framing: most additional findings are Pinia
store internals and other surfaces that SD-2966's facade should hide,
not type.

Verified:
- node tests/consumer-typecheck/deep-type-audit.mjs: 1799/0/0 PASS
- 0 em dashes remaining in either file
- 0 "flagged by Codex" comments remaining

Two related walker gaps (sig.thisParameter and `<T = any>` defaults)
were investigated and confirmed real but have zero current matches in
the published superdoc dist; deferred as future-hardening rather than
landed pre-emptively.

* refactor(types): pivot audit to report-only inventory; defer gate to post-SD-2966 (SD-2977)

Three independent code-review opinions converged on the same conclusion:
landing a 17K-line allowlist of accidental public surface is the wrong
move. The current 1799 findings are largely from Pinia stores,
EventEmitter generics, Vue SFC component types, and other code that was
never deliberately committed as public API. Committing them as an
allowlist would risk legitimizing internals as the type contract and
forcing the team to type things that should be hidden.

Pivot:

1. Delete the 17K-line `deep-type-audit.allowlist.json`. The walker
   stays; the artifact does not.

2. Make the audit report-only by default. Always exits 0 unless the
   script itself errors. Prints inventory: total findings, by-tier
   counts, top files, depth-cap warnings.

3. Add `--strict` flag for the eventual gate behavior. Not used in CI
   yet because it's only meaningful once SD-2966 defines the public
   facade and the allowlist is re-seeded against that smaller surface.

4. CI workflows updated: both PR CI and release CI run the audit in
   report-only mode. Comments explain that `--strict` is added later.

5. README rewritten with prominent "Status: report-only inventory" at
   the top, explaining what defers the gate and what re-emerges after
   SD-2966.

What this delivers today:

- The walker logic ships and runs in CI from day one
- The 10K-line public artifact goes away
- Inventory data appears in every CI run (visible signal of how much
  accidental surface is leaking)
- No commitment to typing 1799 entries on accidental surface

What this defers:

- Hard gate against new regressions on the current accidental surface.
  Net cost: a few weeks where new `any` could land on already-`any`-heavy
  code. Acceptable because the work to drain that surface shouldn't
  start before SD-2966 anyway.

Verified:
- node tests/consumer-typecheck/deep-type-audit.mjs: PASS, exit 0
- node tests/consumer-typecheck/deep-type-audit.mjs --strict: FAIL, exit 1
- node tests/consumer-typecheck/deep-type-audit.mjs --write: still works
  (creates allowlist; intended for use post-SD-2966)

* refactor(types): make audit report-only; add --strict for post-SD-2966 gate (SD-2977)

Companion to the allowlist removal: rewires the walker so default mode
prints inventory and always exits 0, while a new --strict flag preserves
the original gate behavior for use once SD-2966 defines the public
facade.

- Compiler diagnostics, private specifier leaks, and allowlist
  comparison all switch from process.exit(1) to print-and-continue
  unless --strict is set
- New report section prints by-tier and top-files breakdowns regardless
  of mode (visible CI signal)
- When no allowlist file exists, the run prints an explanatory note
  pointing at SD-2966 and the --write workflow
- CI step comments updated in both ci-superdoc.yml and
  release-superdoc.yml to reflect the report-only intent
- README rewritten with prominent "Status: report-only inventory"
  section at the top; commands section documents --strict and clarifies
  --write is intended for post-SD-2966 baselining

Verified: default exits 0, --strict exits 1, --write still seeds an
allowlist correctly.
#3199)

* feat(pm-adapter): plumb body section direction context through resolver

Per ECMA §17.3.1.41, paragraph w:textDirection inherits from the parent
section when omitted. The previous call site built sectionContext from
`undefined`, so directionContext.writingMode was always 'horizontal-tb'
even when the body's w:sectPr declared a vertical writing-mode.

This wires SectionDirectionContext through ConverterContext, populated
once at top-level conversion from the body sectPr. Paragraphs that omit
their own w:textDirection now correctly inherit writing-mode.

Scope:
- Body-level sectPr only. Per-paragraph-section variation (each section
  with its own sectPr) and table-cell direction context are not yet
  plumbed through. Both gaps are documented inline and tracked under
  SD-2777 (migrate remaining direction-aware consumers).
- No consumer currently reads directionContext.writingMode in production,
  so this fixes the data contract before the first consumer arrives.

Tests:
- New: paragraph inherits body sectionDirectionContext.writingMode
- New: paragraph w:textDirection still wins as explicit override

* fix(pm-adapter): recompute sectionDirectionContext per-document

Codex finding on PR #3199: when callers reuse one ConverterContext across
documents (toFlowBlocksMap does this), the previous `??` cache let the first
document's body sectPr resolve once and stick. A vertical doc 1 followed by
a horizontal doc 2 would have doc 2's paragraphs inherit doc 1's
writing-mode.

Fix: drop the `??` and always overwrite. The shared ConverterContext is
mutated freshly each call before children read it, so per-document
recomputation is enough. (The pre-existing `sectionDirection` field on the
line above has the same pattern but is out of scope for this PR.)

Test added: toFlowBlocksMap with two docs (vertical w:textDirection then
horizontal w:textDirection) sharing one converterContext - asserts each
doc's paragraphs get their own writingMode. Failed before the fix because
the cached vertical-rl persisted into the horizontal doc; passes after.

---------

Co-authored-by: Caio Pizzol <caiopizzol@gmail.com>
* fix(mcp): rename wire fields to contract names before dispatch

The MCP wire schema (generated from apps/cli operation params) exposes
PARAM_FLAG_OVERRIDES renames such as `commentId` → `id` and
`parentCommentId` → `parentId`. Without an inverse translation at
dispatch time, the contract validator rejects the wire field names
because it expects the canonical contract names — so
`superdoc_comment` actions `delete`/`get`/`update` fail with
`comments.<action> commentId must be a non-empty string` even when the
caller follows the documented schema.

The CLI applies the inverse rename in extractInvokeInput()
(apps/cli/src/lib/invoke-input.ts PARAM_RENAMES). This commit adds the
matching layer for the MCP path: a small `applyParamRenames` helper in
a standalone module, applied in `executeOperation` immediately before
`api.invoke`.

Affected operations: comments.create (parentId → parentCommentId),
comments.patch / comments.delete / comments.get (id → commentId),
getNodeById (id → nodeId).

Includes unit tests for the rename helper.

* review: fix comment count/style and document output-rename asymmetry

- param-renames.ts: fix "Three" → "Five" (5 operations, not 3) and
  replace em-dash per style rule (caio-pizzol review suggestion).
- intent.ts: add AIDEV-NOTE explaining why input renames are not
  inverted on output — canonical names in responses are usable directly,
  so inverting adds complexity for no practical benefit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* review: clarify output-rename asymmetry is incidental, not deliberate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix: align rtl date token rendering with word parity

Fix RTL date rendering parity with Word per SD-3098. The browser's UBA does
not reorder numerics inside an RTL run the way Word does, and run-boundary
separator drift breaks mixed-direction date strings like `-03-23` + `2026`.

The fix is paint-time only - it never touches PM/model/export:

1. DomPainter sets dir="rtl" on the span when run.bidi.rtl === true (per-run
   bidi isolation eliminates run-boundary separator drift)
2. DomPainter sets dir="ltr" on date-like LTR runs (per regex) inside RTL
   contexts (prevents the third case where a non-rtl date inside an rtl
   paragraph reorders unpredictably)
3. normalizeRtlDateTokenForWordParity injects U+200F (RLM) around `./- `
   separators inside RTL date-like text so Word and SuperDoc render the
   same visual order (e.g. XML `23/03/2026` -> visual `2026/03/23`)

Three test cases in rtl-dates.docx (the Linear-attached "Date Being Weird"
fixture):
- Header: single <w:rtl/> run `23/03/2026` -> Word visual `2026/03/23`
- Body 1: LTR run `-03-23` + RTL run `2026` -> Word visual `2026-03-23`
- Body 2 (control): single LTR run `2026/03/26` -> unchanged

Other changes:
- bidiCompatible guard in mergeAdjacentRuns: prevents a <w:rtl/> run from
  merging with a plain run and silently losing the bidi flag
- run-visual-marks.ts and versionSignature.ts: include run.bidi in the
  caching hashes so a rtl-only edit invalidates measure/DOM cache
- New painter unit tests (rtl-date-parity.test.ts) verifying dir + RLM
- New behavior spec (rtl-dates-word-parity.spec.ts) using the real fixture

This PR builds on the run-level bidi metadata SD-2781 (#3203) added to the
TextRun contract - reads from TextRun.bidi.rtl (the merged shape), not a
parallel RunMarks.bidiContext field.

* test(rtl-date-parity): cover bidi hash + block-version + painter edge cases

Adds pre-merge coverage for SD-3098 rendering invariants:

- hashRunVisualMarks: bidi field changes the dirty-run hash (rtl=true vs
  absent, rtl=true vs rtl=false, embedding-only changes). Stale hashes
  would let an edit that flips just <w:rtl/> reuse stale measure/DOM.
- deriveBlockVersion: bidi flips invalidate the cached block version.
  Without this, the painter would reuse a cached block snapshot after
  an rtl-only edit.
- DomPainter painter tests: mixed rtl + ltr runs on the same line stay
  as separate spans with distinct dir attrs; non-date-like rtl runs
  keep dir="rtl" without RLM injection; non-rtl plain text leaves the
  span without a dir attribute.

Also adds rtl-mixed-run-line.docx + behavior spec as a negative test
asserting Hebrew + date + Hebrew paragraphs don't regress (Hebrew runs
stay rtl, date run is not RLM-injected since it isn't rtl-tagged).

---------

Co-authored-by: Caio Pizzol <caiopizzol@gmail.com>
Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
Co-authored-by: Caio Pizzol <caio@superdoc.dev>
* feat(types): harden package shape (SD-2978)

* fix(types): gate stable release with same package-shape checks (SD-2978)

The original SD-2978 commit only added consumer matrix + package-shape
gates to release-superdoc.yml. Between sessions, release-stable.yml
landed as the new central orchestrator for the npm `latest` publish
lane, and SuperDoc's stable releases now route through that workflow
instead of release-superdoc.yml (which is now `@next` only).

Without this patch the stable lane would publish without the matrix or
publint/attw gates running, defeating the purpose of "package-shape
honest in CI" because the most-consumed dist-tag would still be
unverified at publish time.

Adds the same three steps (matrix, deep-type-audit, package-shape-gate)
between Build packages and the orchestrator step in release-stable.yml.

* fix(types): teach deep audit to read nested types: { import, require } (SD-2978)

The deep audit assumed `entry.types` is always a string. SD-2978's
manifest changes nest it as `{ import: '...d.ts', require: '...d.cts' }`
for the three entries that publish CJS. The audit threw
ERR_INVALID_ARG_TYPE on `path.resolve(root, entry.types)` when entry.types
was an object.

Add a small helper that picks the ESM target from either shape (string
or condition object). Walking the .d.ts side is sufficient because the
.d.cts is a generated shim of the same surface.

Verified: audit now exits 0 with the same 1799 findings as pre-fix.

* fix(types): make sanitizer re-entrant + route pack:local through pack:es (SD-2978)

Code review found that `pnpm run pack` was broken by the new
prepack/postpack lifecycle. pnpm wraps prepack/postpack around scripts
named exactly `pack`, and the user `pack` script itself invokes
`pnpm pack` which triggers a second prepack. The inner prepack hit the
"backup exists" guard and exited 1, the outer postpack was skipped, and
the workspace was left with `package.json` mutated and
`.package.json.prepack-backup` orphaned.

Two changes:

1. Make `prepare` re-entrant. If the backup file exists AND the current
   manifest already looks sanitized (no `source` conditions, no `unpkg`
   or `jsdelivr` fields), no-op so the inner prepack falls through and
   the inner postpack can restore cleanly. If the backup exists but the
   manifest is NOT sanitized, fail loudly with a clear message — that
   means the workspace is in an inconsistent state from a previous
   failed pack and the developer needs to clean up. `restore` was
   already idempotent (no-op when backup missing).

2. Route `pack:local` through `pack:es` directly. Both ultimately do
   the same thing, but going through `pack:es` (whose name does not
   collide with the lifecycle trigger) avoids the double-fire on the
   common local-pack path.

Verified with a synthetic harness covering 5 cases: clean run,
double-fire (outer + inner), failed run (state inspection), retry after
failure (self-heal), inconsistent state (loud refusal). All pass.

Verified live in the worktree:
- pnpm run pack:es: tarball created, manifest restored, no orphan backup
- pnpm run pack: same (was broken before this commit, now works)
caio-pizzol and others added 24 commits May 18, 2026 11:52
…nt-api-alignment

feat(types): document api alignment for public facade (SD-3185)
* feat: improve format painter ux to match ms word

* fix: address review comments

* fix: calculate state for headless mode

* fix: build issue
* feat: add toc toolbar item

* fix: move toc icon to overflow menu

* fix: check if toc node can be inserted

* fix: address comments
…ta-api

feat(document-api): metadata.* for anchored payloads (SD-3104)
Add facade entries for the two remaining Phase-3 legacy framework helpers.
Each entry re-exports useHeadlessToolbar only. Mirrors SD-3179's posture
for ./headless-toolbar root and keeps the path-as-contract facade
complete for everything except the deferred ./super-editor leaf (SD-3181).

- src/public/legacy/headless-toolbar-{react,vue}.ts: single named export.
- verify-public-facade-emit.cjs: 2 new FACADE_ENTRIES, both with cjs path
  for Phase 4 readiness (mirrors the existing SD-3179 entry shape).
- ensure-types.cjs: 2 new .d.cts shim entries (now emits 8 shims total).
- vite.config.js: 2 new entries in rollupOptions.input.
- 2 smoke tests covering the runtime re-exports.

No package.json#exports change. Phase 4 owns the contract flip.

Verifier: 10 entries clean. Consumer-typecheck: 57/57 matrix scenarios
pass, snapshots unchanged. Smoke tests 2/2.
Customer-facing citation demo built on the metadata.* contract from
SD-3104. Customer-facing counterpart to the developer-shaped SD-3199
demo (#3370, draft); both lean on the same anchored-metadata model.

What this shows:

- Generate draft with sources — mocked stand-in for a chat-driven RAG
  pipeline. Inserts a pre-canned paragraph and attaches citations to
  specific phrases.
- Sources sidebar grouped by sourceId, showing source title, type,
  provider, deep link, and the cited locations within the document.
- Hover popover with displayText, locator, excerpt, provider,
  confidence — the verification surface so a lawyer can trust the
  output without leaving the doc.
- Highlight overlay on cited spans using getRect.rects, so line-wrapped
  citations get clean per-line underlines visible through the text.
- Scroll-to, Edit, Remove per citation. All six metadata.* methods
  exercised.

What this does not show:

- The button is a stand-in for a chat-driven AI workflow, not the
  recommended product UX. A real integration plugs a chat panel +
  Insert into document action.
- Generation is mocked. No LLM is wired.
- Verification status is a render-time concern; external verification
  signals can change over time, so it is not persisted in the payload.
…SD-3208)

- Rename 'Generate draft with sources' to 'Insert sample cited draft' so
  the button doesn't overclaim — the demo is mocked, not a real AI
  pipeline.
- Match the help text and empty-state copy.
- Replace the hardcoded rgba on .citation-highlight with color-mix
  against --accent so the highlight tints follow theme changes.
Repeated clicks were colliding on hardcoded citation ids and surfacing
'Anchored metadata id already exists' errors in the UI. Each draft holds
fixed ids (cite-001, cite-002, cite-003); the cycling-by-index logic
inevitably re-inserted them on the third click and failed.

Single-shot now: one click inserts every MOCK_DRAFTS paragraph and
attaches every citation in one go. The button hides as soon as
citations exist, so the click-collision path is unreachable. To re-run
the demo, remove the citations from the sidebar or reload the page.
…ss-toolbar-react-vue

feat(public-facade): legacy headless-toolbar/react and /vue (SD-3207)
… partial attach (SD-3208)

- mockDraft.ts: drop vendor names and the stale 'Generate draft with
  sources' label from the file header. Vendor/product research belongs
  in the PR/Linear notes, not source comments.
- GenerateDraftButton: if a partial-attach failure leaves an error
  mid-flight, keep the component mounted (and the error message
  visible) instead of silently disappearing on the next render. The
  early-return now gates on 'has citations AND no error'.
… (SD-3201)

Closes the round-trip gap from SD-3104: SuperDoc -> DOCX -> Word save /
edit -> SuperDoc. The SuperDoc -> DOCX -> SuperDoc round-trip was already
proven by tests/doc-api-stories/tests/metadata/all-commands.ts; this adds
Word in the middle as deterministic fixtures replayed in CI.

Two fixtures, both generated from one SuperDoc-side source (one paragraph,
one anchored citation 'fixture-cite-001' over 'duty of care'):

- baseline-word-resaved.docx: opened in Word, re-saved without edits.
  Proves Word does not strip the customXml part or the hidden SDT
  wrapper on save.
- baseline-word-edited.docx: opened in Word with text inserted inside
  the cited span. Proves the SDT survives content edits and the anchor
  expands to cover the edited content (no orphan, no detach).

Replay test under tests/doc-api-stories/tests/word-roundtrip/:
- list({ namespace }) returns the citation
- get({ id }) returns the payload byte-for-byte (Word does not
  renormalize the customXml part)
- resolve({ id }) returns a SelectionTarget
- list({ within: resolvedTarget }) confirms the anchor still covers
  the post-edit range

What this does not cover
- New Word version regressions. A separate live script gated on Word
  API access is the pre-customer-rollout pre-flight; not in this PR.
- Word's 'Inspect Document' / Document Inspector cleanup, which can
  strip Custom XML Data as 'personal information.' Documented as a
  known limitation in the namespace adoption guide (SD-3209).
…3201)

Soften the inline comment to match what the test actually asserts: the
anchor did not detach (resolve returns a selection target with text
endpoints), and the citation is still listable via within-scoped list.
Text-content equality of the resolved range against the edited string
is explicitly not asserted here.

Also drop the 'byte-for-byte' wording on the no-edit fixture — the
assertion is parsed-payload equality, not raw XML byte equality.
…-3211)

TypeScript contract switch for 9 scaffolded subpaths only. Each subpath's
`types` and `typesVersions` field now resolves through the curated
`dist/superdoc/src/public/**` declarations built by SD-3178 and its leaves.

Subpaths flipped:
  ./types, ./ui, ./ui/react,
  ./headless-toolbar, ./headless-toolbar/react, ./headless-toolbar/vue,
  ./converter, ./docx-zipper, ./file-zipper.

Explicitly NOT in this PR:
  Root `.` entry stays on historical types. Dry-run found a 196-name
  consumer-fixture surface vs the 24-name SD-3185 curated facade. The
  mismatch requires classification before flipping; tracked as Phase 4b
  (SD-3212).

  `./super-editor` entry stays on historical types (SD-3181 deferred).

  No `import` / `require` / `source` field changes. Runtime module
  identity unchanged for every consumer.

Verified locally:
  - Facade verifier: clean across 10 entries.
  - Consumer-typecheck matrix: 57/57 scenarios pass.
  - check-export-coverage: 12 entries OK.
  - SD-3176 subpath snapshots: clean.

This is the safe half of Phase 4. The root half (SD-3212) is the
strategic work, scoped separately to keep regressions isolated.
…D-3201)

Replaces the self-referential metadata.list({ within: resolved.target })
block with a slice of the resolved block's text via editor.doc.extract.
The slice now asserts the SDT range covers 'duty' + 'reasonable' +
'care' — proving the Word-inserted word is inside the anchor using a
read surface (extract) that's independent of the metadata store.
…on layout (SD-3208)

P1: CitationEditor had its own useCitations() instance, so save()
re-hydrated only the child's local citations state. Since a payload-
only metadata.update doesn't change the SDT, the parent's content-
controls slice doesn't tick, leaving the parent panel stale after
Save. Lift update from the parent useCitations() and pass it down
so save() refreshes the list being rendered. Verified in browser:
locator field updates immediately on Save.

P2: CitationHighlights remeasured on window scroll/resize only.
Document edits that move cited spans without changing window
geometry (typing above a span, paragraph reflow) left the underline
rects pinned at their old positions. Added a ResizeObserver on the
editor canvas (catches pagination/zoom) plus a MutationObserver on
the canvas DOM (catches text edits). Both funnel through a single
rAF tick so burst events collapse into one remeasure per frame.
Verified in browser: 3x Enter at the document start shifts cited
spans by ~1400px and the rects track them.
…-types-flip

feat(public-facade): phase 4a flip subpath types to public facade (SD-3211)
…ation-showcase

feat(custom-ui): AI citation showcase over editor.doc.metadata.* (SD-3208)
…oundtrip-validation

test(document-api): word-in-the-loop validation for anchored metadata (SD-3201)
chore: editor-neutral layout identity and hit mapping
@superdoc-bot superdoc-bot Bot requested a review from a team as a code owner May 19, 2026 06:29
@github-actions
Copy link
Copy Markdown
Contributor

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: 0072e4f003

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const selectedContent = parentRun.content.cut($from.parentOffset, $to.parentOffset);
const leftContent = parentRun.content.cut(0, $from.parentOffset);
const rightContent = parentRun.content.cut($to.parentOffset);
const sdtNode = nodeType.create(attrs, selectedContent);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve run formatting inside metadata anchors

When metadata.attach targets a range inside a single run, this branch splits the surrounding run with parentRun.attrs but moves the selected content into the hidden inline SDT as bare inline content. For formatted runs, that strips run-level properties such as font family/size or bidi attributes from only the anchored span after wrapping/export, even though the visible document text should be unchanged. The SDT content should preserve the selected run wrapper (or otherwise copy the parent run attrs) rather than inserting selectedContent directly.

Useful? React with 👍 / 👎.

@codecov-commenter
Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.05195% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/superdoc/src/SuperDoc.vue 88.46% 1 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

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.