Skip to content

chore: move docx-core into folio#201

Merged
jan-kubica merged 31 commits into
mainfrom
feat/docx-core-ownership
Jul 13, 2026
Merged

chore: move docx-core into folio#201
jan-kubica merged 31 commits into
mainfrom
feat/docx-core-ownership

Conversation

@jan-kubica

Copy link
Copy Markdown
Contributor

Summary

  • move @stll/docx-core source into the monorepo
  • add build, API, package, changeset, and release validation
  • consume the package through the workspace dependency

* feat: own docx generation pipeline

fix(docx-core): tighten heading-numbering regex + matter-pick / locale follow-ups

- parser: stripManualNumbering now requires a delimiter ('.' or ')')
  for numeric and letter prefixes so headings starting with a year
  ("2024 Compliance Obligations") or a single capital word ("A
  Party's Obligations") are no longer silently rewritten. Multi-
  level numbers (1.1, 1.1.1) still match because the inner dot is
  its own delimiter.
- parser: title pending flushes immediately so body content after a
  markdown '# H1' or '@title' doesn't get absorbed into the title's
  discarded lines array.
- parser: headingless @clause downgrades to a @paragraph block with
  an autofix entry instead of returning a hard error.
- parser: @signatures field keys accept localized aliases (strana,
  podepisuje, funkce, partei, signataire, …) mapped to canonical
  English fields.
- compile: side-by-side borderless signature table with per-locale
  captions for en/cs/sk/de/fr/es/it/pl/pt/nl/hu.
- compile: '[[…]]' placeholders render as runs with
  <w:highlight w:val="yellow"/>.
- compile: refreshed typography (Calibri 11pt, all-caps title,
  per-level clause headings, footer style).
- serialize: per-table formatting.borders is honored (defaults to
  the existing grey rule); allCaps / smallCaps / cs-script font
  now emitted; locale-aware "Page X of Y" footer via PAGE /
  NUMPAGES simple fields.
- api: create-document is client-executed; new POST
  /entities/:workspaceId/create-from-legal-source carries the
  resolved matter and returns entityId + workspaceId so the chat
  card can open the result directly.
- chat: workspace tools always registered (no longer gated on
  thread workspaceId); NeedsMatterCard renders a live streaming
  preview + matter picker + "Open in editor" affordance that pins
  the chat thread in the workspace inspector and asks the inspector
  to start folio edit mode on tab mount.
- inspector: Download button added to the file-tab toolbar (icon
  only, leftmost) — fetches a presigned URL via api.files.url and
  triggers a browser download of the original file.
- rename: PdfTab → FileTab, openPdf → openFile and friends across
  the inspector (cosmetic; the tab type already handled DOCX and
  every other displayable file).

refactor(docx-core): drop hardcoded signature locale matrix + @Preamble alias

- Removed `SIGNATURE_LABELS` (11-locale caption dictionary) and the
  per-locale `resolveSignatureLabels` lookup. The signature table no
  longer adds Date/Place/Signature/Name/Title/`For and on behalf of`
  captions — it renders party name (bold), a signing space, the
  underscore rule, then any `by:` / `title:` values the AI supplied,
  raw. AI controls all caption wording in the document language.
- Dropped the auto-inserted "Signatures" section heading paragraph
  and the `SignatureHeading` + `SignatureCaption` styles. The AI can
  precede `@signatures` with `@clause Podpisy` / `@pagebreak` if it
  wants a section title.
- `@preamble` directive aliased to `@paragraph`; its block type,
  parser/compile branches, and `Preamble` style are gone (it was a
  thin styling variant of `@paragraph` with no real semantic).
- Tool description and `source` field description no longer use the
  internal "Stella Legal Source" brand — described by behaviour
  instead. Directive list lives in one place (the reference block);
  removed the redundant opener list and trimmed the `@signatures`
  section.

* chore: drop unused files flagged by knip

- apps/api/src/handlers/docx/inject-styles.ts — no callers
- apps/api/src/handlers/docx/style-guide.ts — no callers
- packages/folio/src/core/types/lists.ts — only re-exports from
  @stll/docx-core/model, never imported through this path
- packages/folio/src/core/types/styles.ts — same
- packages/docx-core depends on `valibot` but never imports it
  (legal-source compile pipeline uses ai-sdk/valibot through the
  api layer, not in docx-core itself); removed from dependencies.

* chore(api): declare @ai-sdk/provider-utils dependency

It was being used in apps/api/src/handlers/chat/tools/external-mcp-tools.ts
through transitive resolution from the other @ai-sdk packages, but never
listed. Knip flagged it; pinning explicitly keeps the dependency surface
honest.

* fix: address docx review feedback

* fix: handle create document input wait state

* fix: normalize create document tool input

* fix: require legal source for document creation

* fix: address document review followups

* fix: persist generated document scope and audit

* ci: allow verified dependency licenses
* chore: enable no-unnecessary-condition

* fix: address review comments

* fix: restore empty-stream fallback guard

* fix: harden lint cleanup runtime guards

* fix: address inspector review fallout

* fix: harden nullish source parsing

* fix: restore tracked-change navigation

* fix: address nullable review guards

* fix: preserve authored list indents
* chore(deps): bump @tiptap/* to 3.23.4

Patch release covering @tiptap/core and all @stll/web extension packages. Web typecheck and lint clean.

* chore(deps): bump @ai-sdk/* and ai to latest

@ai-sdk/anthropic 3.0.78, azure 3.0.65, devtools 0.0.18, google 3.0.74, google-vertex 4.0.129, mcp 1.0.42, mistral 3.0.37, openai 3.0.64, react 3.0.185, ai 6.0.183. Typecheck clean across api and web.

* chore(deps): bump @tanstack/* family

@tanstack/devtools-vite 0.7.0, react-devtools 0.10.5, react-query 5.100.10, react-router 1.170.0, react-router-devtools 1.167.0, react-router-ssr-query 1.167.0, router-plugin 1.168.0, eslint-plugin-query 5.100.10, eslint-plugin-router 1.162.0.

* chore(deps): bump astro 6.3.3 and @astrojs/react 5.0.5

* chore(deps): bump vite 8.0.13 and @vitejs/plugin-react 6.0.2

* chore(deps): bump better-auth and @better-auth/oauth-provider to 1.6.11

* chore(deps): bump react-email and @react-email/ui to 6.1.4

* chore(deps): bump oxlint 1.65.0 and oxfmt 0.50.0

oxlint 1.65.0 adds new jsx-a11y(control-has-associated-label) rule — fixed lone <input> in apps/playground.
oxfmt 0.50.0 reformats JSX boolean-and operands onto separate lines in two web files.

* chore(deps): bump ultracite to 7.7.0

* chore(deps): bump use-intl 4.12.0 and lucide-react 1.16.0

* chore(deps): misc patch sweep and oxlint 1.65 fixes

Bumps remaining patch/minor outdated deps:
- @aws-sdk/client-sesv2 3.1048.0, @opentelemetry/api-logs 0.218.0,
  bullmq 5.76.8, posthog-node 5.34.2, posthog-js 1.373.5,
  fast-xml-parser 5.8.0, prosemirror-model 1.25.6 (+ resolution),
  @playwright/test 1.60.0, @pydantic/genai-prices 0.0.60,
  react-grab 0.1.34, @stll/anonymize-data 0.0.6, fast-check 4.8.0,
  @types/bun 1.3.14, eslint 10.4.0, knip 6.14.0, turbo 2.9.14,
  @typescript/native-preview 7.0.0-dev.20260519.1.

Adds u flag to regex literals/new RegExp() across the monorepo to
satisfy oxlint 1.65 require-unicode-regexp. Workspace-/org-manifest
keep flagless v.regex literals (@valibot/to-json-schema rejects
flags) with file-scoped disables.

Disables noisier oxlint 1.65 rules:
- unicorn/no-negated-condition (stylistic; clearer in many cases)
- jsx-a11y/prefer-tag-over-role (Base UI primitives need role attrs)
- jsx-a11y/control-has-associated-label (misses dynamic htmlFor)
- logical-assignment-operators (conflicts with no-unnecessary-condition)

Drops a duplicate no-offset-pagination rule block and a stale
oxlint-disable directive in apps/web.

* chore(deps): bump @stll/stdnum to 1.0.0 in @stll/ares

Major bump 0.0.1 → 1.0.0; ./cz/ico export path unchanged. ares typecheck and tests pass.

* chore(deps): bump knip 6.14.1 and @tanstack/router-plugin 1.168.2

* fix: apply oxlint require-unicode-regexp to files merged from main

apps/api/src/handlers/chat/tools/boe-tools.ts, copy-to-workspace.test.ts,
azure-foundry.ts, packages/boe/src/validation.ts. boe-tools v.regex
literals stay flagless (@valibot/to-json-schema rejects flags).

* fix: keep drizzle schema.ts byte-stable to satisfy migration check

CI 'check-migrations.sh' demands a new migration file on any change
to apps/api/src/db/schema.ts. The u-flag bump on SHORTCUT_COMMAND_PATTERN
is a pure code constant with no DB effect, so revert that single regex
to flagless and add a file-scoped eslint-disable.

* fix: scope require-unicode-regexp off for drizzle schema files

check-migrations.sh treats any byte-level change to schema.ts as a
schema diff, so disable the rule for that file (and auth-schema.ts)
via oxlint.config.ts override instead of adding eslint-disable
directives to the schema itself.

* fix: keep 5 sanitization-adjacent files byte-stable to clear CodeQL

CodeQL flagged 5 pre-existing regex patterns (stripHtml, html-to-markdown
renderTableCell, promptFromHtml, cleanWordHtml, fontResolver quoteFontName)
as new alerts when re-analysing the changed files. Each was verified safe
in context: outputs flow to plain text, an HTML AST parser, the CSSOM, or
the LLM API — never innerHTML. Revert the u-flag bump in these files and
disable require-unicode-regexp for them via oxlint.config.ts override.
* docs: plan folio canonical document model

* feat(docx-core): validate canonical document invariants

* feat(folio): add typed prosemirror attr boundaries

* fix(folio): validate parsed and saved documents

* refactor(folio): route conversion through typed attrs

* refactor(folio): validate prosemirror mark attrs

* refactor(folio): validate prosemirror node attrs

* feat(folio): validate prosemirror document boundaries

* test(folio): cover semantic prosemirror roundtrips

* test(folio): guard prosemirror attr boundaries

* refactor(folio): centralize document enum values

* refactor(folio): type editor attr updates

* refactor(folio): type table command attrs

* refactor(folio): validate extension dom attrs

* refactor(folio): validate preservation attrs

* test(folio): validate real docx prosemirror attrs

* fix(folio): preserve docx bookmark boundary markers

* fix(api): grant folio collab rls tables

* fix: address lint error handling findings

* fix: address api typecheck fixture

* fix(folio): harden real-world docx round trips

* fix(folio): preserve sectioned text boxes and column breaks

* fix(folio): address docx boundary review findings

* fix(folio): preserve ragged table merges and empty links

* fix(folio): preserve fully merged table rows

* fix(folio): keep text box boundary wrappers

* fix(folio): preserve adjacent same-target hyperlinks

* fix(folio): repair truncated docx zip directory tails

* test(folio): document image attr clearing semantics

* fix(folio): repaint page on run-level formatting changes

computePageFingerprint only encoded fragment geometry plus a narrow
annotation set (comments, tracked insertions/deletions). A mark-only
edit such as toggling bold can leave fragment width/height/line counts
identical; the incremental render path then matched the cached
fingerprint and skipped repopulating the page, so the painter's
applyRunStyles never ran and the change stayed invisible.

Add a content fingerprint that includes the run kind, text content,
and the formatting attrs the painter consumes (bold, italic,
underline, strike, color, highlight, font, caps, baseline shift,
hyperlink, etc.), and fold it into the per-fragment fingerprint.
Covered by new tests for bold and text-content changes that leave
geometry untouched.

* fix(folio): detect bold/italic/etc. across the whole selection

extractSelectionState read marks from selection.\$from.marks(), which
is left-biased: at a same-paragraph text-node boundary it returns the
prior text node's marks, not the marks on the selected content. For a
forward selection that starts at the first character of a marked run
(e.g. a double-click on a bold word) the toolbar misreported bold as
inactive, so the B button looked off even though clicking it would
remove bold — the inverse of what the UI promised.

For non-empty selections, collect the first occurrence of each mark
type across all text nodes in the range. Empty cursors keep the
existing storedMarks || \$from.marks() path so typing-into-cursor
behavior is unchanged. The new semantics match prosemirror-commands'
toggleMark, so the toolbar's active state agrees with what a toggle
click will do.

* perf(folio): keep mark-only transactions on the incremental layout path

AddMarkStep / RemoveMarkStep inherit Step.getMap() → StepMap.empty,
so their position changes never reached getTransactionDirtyRange's
mapping walk. The function returned null, which forced the layout
pipeline into a full measureBlocks pass on every bold/italic toggle.

Read the affected range directly off the step when the StepMap is
empty so mark-only edits stay on the incremental measure path. Same
precedent already exists in ParagraphChangeTrackerExtension.

* fix(folio): preserve section footnote columns

* fix(folio): parse content-controlled table structure

* fix(folio): accept large docx packages within limits

* test(folio): document grouped textbox page breaks

* fix(folio): preserve inline hyperlink wrappers

* feat(folio): expose font size in the formatting toolbar

The toolbar's secondary controls already gate on a showFontSizePicker
flag (default true) but no picker was rendered, so users had no way
to change font size from the UI even though setFontSize is wired in
DocxEditor and FontSizeExtension. Add a FontSizePicker dropdown next
to the existing FontPicker, mirroring its Select-based shape.

Values flow as points at the UI boundary and are converted to
OOXML half-points by the existing pointsToHalfPoints call in
DocxEditor's fontSize action handler; currentFormatting.fontSize is
already half-points, so the picker divides by two to display the
current size.

Add the fontSize translation key for every locale (proper
translations where the locale is localized, English placeholders
where bold/italic are still English) and regenerate messages.gen.ts.

* fix(folio): tolerate nullable label in FontSizePicker onValueChange

Base UI's Select hands a string | null to onValueChange (null when
the selection is cleared). Guard the cast so typecheck stays clean
and the picker no-ops cleanly on null.

* fix(folio): harden hyperlink and package roundtrips

* fix(folio): accept preserved border styles in pm attrs

* fix(folio): preserve tracked section property changes

* fix(folio): preserve image atoms through pm roundtrips

* fix(folio): preserve table merges and malformed comments

* fix(folio): drop dangling header footer refs

* fix(folio): preserve empty text boxes

* fix(folio): preserve nonstandard header footer parts

* fix(folio): repair out-of-order comment ranges

* fix(folio): tolerate missing numbering definitions

* fix(folio): skip oversized embedded fonts

* fix(folio): prefer exact header footer targets

* fix(folio): tolerate unbalanced move ranges

* test(folio): cover comment boundary normalization

* fix(folio): tolerate oversized media entries
… (#460)

* feat(folio): sync upstream eigenpal docx-editor fixes (late May 2026)

Ports four upstream fidelity fixes since folio #410 (2026-05-21). Each was
reviewed against folio's diverged structure and either ported, improved, or
re-architected to fit folio's layout pipeline.

## Ported

- **#585** Recurse into table cells and text boxes when collecting footnote
  references so refs nested in tables reach the per-page footnote area.
- **#597 + #599** Treat `w:firstLine="0"` and `w:hanging="0"` as no-op
  overrides per ECMA-376 §17.3.1.12 — numbering-level hanging slot stays
  applied.

## Improved / re-architected

- **#596** Stacked-float wrapping. Added `MIN_WRAP_SEGMENT_WIDTH` + new
  `findClearLineY` helper to bump lines past floats that leave no usable
  horizontal room. Adapted to folio's `FloatingImageZone` model (simpler
  than upstream's segment-based zones). `MeasuredLine.floatSkipBefore`
  carries the vertical hop to the painter; totalHeight includes it.
- **#600** Long-marker tab-stop alignment. New `getListMarkerInlineWidth`
  helper does the tab-stop math used by both measureParagraph and
  renderParagraph — long markers like "1.1.1." now grow to the next stop
  instead of folio's fixed +12 px gap. Plumbs `w:suff` from numbering
  level → ListRendering → PM attrs → ParagraphAttrs.listMarkerSuffix,
  and `w:defaultTabStop` from word/settings.xml → DocxPackage.settings →
  toFlowBlocks → per-block `defaultTabStopTwips`.

## Skipped

- #602 legacy /assets cleanup (not applicable)
- #598 Vue parity (not applicable)
- #587 e2e test repairs (folio has its own test architecture)
- #583 header/footer text boxes (already landed as folio #453)

24 new regression tests across 7 files. Local: 885 tests pass, 0 fail;
typecheck clean across folio and docx-core.

* fix(folio): address review comments on list-marker tab-stop math

Three correctness fixes from PR #460 review:

- Hanging-indent lists now handle marker overflow correctly. When a
  marker is wider than its hanging slot, body text is advanced to the
  next tab stop past the marker end (gemini-code-assist).
- measureParagraph subtracts only the excess (markerWidth − hanging)
  from the first-line budget when a hanging marker overflows; the
  hanging slot itself is still reserved through firstLineOffset
  (gemini-code-assist).
- `Math.ceil(searchStart / defaultTabStopPx) * defaultTabStopPx` for
  the default-grid stop preserves the `>=` equality the comment
  promised. The previous `Math.floor(...) + 1` form advanced one full
  interval when minBodyStart landed exactly on a stop
  (chatgpt-codex-connector).

Two new regression tests in listMarkerWidth.test.ts pin the overflow
and equality cases. 887 tests pass; typecheck clean.

* fix(folio): subtract marker footprint unconditionally for first-line list math

For hanging-indent lists with `w:suff="space"` or `w:suff="nothing"`,
`getListMarkerInlineWidth` returns the marker's natural width — smaller
than the hanging slot. The painter applies that natural width as
`min-width`, so body text actually starts BEFORE indentLeft. The
previous "subtract only the excess past hanging" logic did nothing in
that case and over-estimated the first-line budget by the full marker
width, delaying line wrap and producing right-edge overflow.

The unified formula `baseFirstLineWidth -= markerInlineWidth` is correct
for every case:

- Hanging + tab fitting: cancels the `+ hanging` widening cleanly
- Hanging + tab overflow: shrinks budget past the body wrap column
- Hanging + space/nothing: budget grows (hanging − markerInlineWidth)
- First-line indent: subtracts the full marker

Reported by chatgpt-codex-connector on PR #460. New regression test
`hanging-indent + non-tab suffix subtracts the marker's actual footprint`
pins the case. 888 tests pass; typecheck clean.
* feat(lint): add stella-lowercase oxlint rule

Flags mid-sentence `Stella` in string literals, template quasis, and JSX
text. Allows sentence-start, PascalCase identifiers, kebab asset names,
bundle IDs, and import/export source strings.

* fix: lowercase mid-sentence Stella per stella-lowercase rule

Lowercase the brand wordmark across product code, AI prompts/tool
descriptions, test descriptions, dev runner messages, DOCX producer
metadata, and transactional translations. Regenerates the transactional
i18n message types from the updated JSON sources.

* fix(lint): address stella-lowercase bot review findings

- Expand sentence-start opener set to include typographic quotes
  („ " " « » ‹ ›) and Spanish ¿ ¡ so localized copy doesn't flag.
- Track template-quasi position so non-head quasis (text after
  ${…}) don't get the start-of-text sentence-start carve-out.
- Add lint:fixtures script wired into bun run lint so the
  regression fixture in .oxlint-plugins/__fixtures__ is actually
  evaluated in CI under --report-unused-disable-directives.
- Cover the new behaviors in the fixture (non-head quasi flag,
  German/French/Spanish/curly-quote sentence-start allows).

* fix(ci): wrap lint:fixtures so CI's --affected arg gets swallowed

CI invokes `bun run lint -- --affected`, which appended `--affected`
to the chained `lint:fixtures` oxlint call and failed. Route the
chained step through scripts/lint-oxlint-fixtures.sh; the wrapper
exec's oxlint with fixed args and drops anything bun forwarded.

* fix(lint): treat JSX text after sibling output as continuation; forward --affected to turbo

- isJsxContinuation flags JSXText children that follow an expression
  or element sibling so `<p>{name} Stella…</p>` no longer gets the
  start-of-text sentence-start carve-out.
- isSentenceStart also skips leading newlines in continuations,
  since JSX source formatting wraps post-expression text onto a new
  line that isn't a prose line break.
- Reorder the root lint script so `bun run lint -- --affected`
  forwards --affected to turbo instead of the (now leading) fixtures
  wrapper.
- Cover JSX-after-expression in the regression fixture.

* fix(lint): flag Stella literals inside non-head JSX expressions

- New isJsxExpressionContinuation marks string / template literals
  whose enclosing `{…}` follows other JSX siblings; threaded into
  the Literal and TemplateLiteral visitors so
  `<p>Welcome to {"Stella"}</p>` now flags.
- Tightened WHOLE_PATH to require a real path separator (`/ \ . :
  ~ @`); bare identifier-shaped strings like `"Stella"` no longer
  get the path-string carve-out and reach the token-level checks.
- Covered the case in the regression fixture.

* fix(lint): keep generated i18n bundles under stella-lowercase

oxlint doesn't lint JSON, so the only check that catches a
mid-sentence Stella reintroduced into packages/transactional/i18n/
langs/*.json is the rule firing against messages.gen.ts after
typegen. i18n:check already enforces gen ↔ source drift, so the
chain only works if the generated TS stays under the rule. Drop
the off-override here.

* chore: drop redundant lint:fixtures script

`bun run lint` already invokes scripts/lint-oxlint-fixtures.sh,
and the wrapper can be called directly for fast iteration.
…ggesting mode (#502)

* feat(folio): round-trip paragraph-mark tracked changes (ECMA-376 §17.13.5)

Word emits a paragraph-mark insertion or deletion as `<w:ins/>` or
`<w:del/>` inside `<w:pPr><w:rPr>` — the marker for "the paragraph break
itself was authored in track-changes mode" (Enter mid-paragraph, or
Backspace-at-start / Delete-at-end pending deletion). The model, parser,
serializer, and PM round-trip previously dropped these markers silently,
so any DOCX with paragraph-mark revisions lost them on open + save.

- `Paragraph.pPrMark: { kind: "ins" | "del"; info: TrackedChangeInfo }`
  in `@stll/docx-core/model`, modeled as a discriminated union to mirror
  folio's existing `TrackedChangeWrapperType`.
- Parser reads it from `<w:pPr><w:rPr><w:ins|w:del/>`.
- Serializer emits it as the first child inside `<w:rPr>`, per
  EG_ParaRPrTrackChanges (wml.xsd:1837); strict readers reject other
  orderings. `<w:rPr>` is now also emitted when only `pPrMark` is set.
- PM schema gains a `pPrMark` paragraph attr; toProseDoc / fromProseDoc
  preserve it across edits.

13 regression tests (parser, serializer, PM round-trip).

* feat(folio): extend accept/reject to paragraph-mark revisions

`acceptChange` / `rejectChange` (and the bulk all-doc variants) walked
inline insertion/deletion marks only; paragraph-mark `pPrMark` attrs
were left in place forever.

- accept-ins / reject-del → clear the attr (the paragraph break stays).
- reject-ins / accept-del → join with the next paragraph (the break
  goes away) and clear the attr.
- Doc-terminal `pPrMark='del'` is a no-op — Word treats it the same
  way; PM has no next sibling to join with.
- pPrMark ops run after inline-mark resolution in a single transaction
  and from end → start so earlier positions stay valid as later
  paragraphs collapse.

7 regression tests in pPrMark-accept-reject.test.ts.

* feat(folio): record paragraph-mark revisions from the keyboard

Suggestion-mode editing previously split paragraphs (Enter) and joined
them (Backspace-at-start, Delete-at-end) silently, producing DOCX output
indistinguishable from edits made outside track-changes mode.

- Enter composes with `splitBlockClearBorders` (newly exported) and
  stamps `pPrMark = { kind: 'ins' }` on the FIRST (upper) paragraph of
  the split, per ECMA-376 §17.13.5. A prior author's `pPrMark` is left
  alone — revisions never silently overwrite each other.
- Backspace at paragraph start sets `pPrMark = { kind: 'del' }` on the
  PREVIOUS paragraph (whose closing mark is being deleted in OOXML
  terms). The merge itself is deferred to `acceptChange`.
- Delete at paragraph end sets `pPrMark = { kind: 'del' }` on the
  CURRENT paragraph.
- `paragraphBoundaryTarget` is exported as a pure function so the
  boundary detection is unit-testable without an `EditorView`.

8 regression tests in suggestionMode-pPrMark.test.ts (4 pure-function,
4 driving the plugin's handleKeyDown through a stub view).

* fix(folio): satisfy oxlint on pPrMark tests

* fix(folio): correct paragraph mark suggestions

* fix(folio): scope paragraph mark resolution to boundaries
…rip (#508)

* fix(folio): preserve fields and nested SDTs inside inline SDT round-trip

OOXML allows `<w:fldSimple>`, complex fields, nested `<w:sdt>`, and
math equations directly inside `<w:sdtContent>`. The folio parser
narrowed `InlineSdt.content` to runs and hyperlinks only, so docProps-
bound title fields and similar template content escaped the SDT
wrapper on parse and a load → edit → save round-trip silently rewrote
the document. The serializer and PM converters mirrored the same
narrowing.

Widens `InlineSdt['content']` in `@stll/docx-core` to the full OOXML
union and threads the change through every site that handled the
inline SDT contract: parser segment split, layout-bridge dispatcher
(extracted into a recursive `pushRunsForChild` helper that recurses
on `sdt`), PM `convertInlineSdt` / `createInlineSdtFromNode`, the
exhaustive serializer switch, and the validation pass.

Mirrors upstream eigenpal/docx-editor PR #482 (parser + bridge) and
PR #486 (serializer + save path) with their attribution kept in
NOTICE.md and in-source references.

Regression coverage:
- 3 new parser tests in `paragraphParser.test.ts` (simple field,
  complex field, nested SDT survive parse inside an inline SDT)
- 6 new round-trip tests in `serializer/sdt-content-roundtrip.test.ts`
  (each preserved type, a mixed run+field case, and the full
  parse → toProseDoc → fromProseDoc → serialize loop)

* fix(folio): make find/replace see text inside inline SDTs

PR 508 keeps run/hyperlink/field/nested-SDT/math children inside the
inline SDT wrapper instead of lifting them as paragraph siblings. The
find/replace utilities still only walked top-level runs and hyperlinks,
so text wrapped in an inline SDT (including docProps-bound fields like
TITLE) became unsearchable. Recurse into inline SDTs and field content
when extracting paragraph plain text and when mapping offsets back to
top-level paragraph content.

* chore(docx-core): apply formatter

* chore(folio): apply formatter to sdt-content-roundtrip test

* fix(folio): recurse via visitItem into widened SDT content
* feat(folio): render <a:srcRect> image crops (eigenpal #424)

Ports the image-crop subset of eigenpal/docx-editor #424. Folio's image
parser previously ignored `<a:srcRect>` inside `<pic:blipFill>`, so
images cropped in Word rendered at the full bitmap, and the crop was
silently dropped on save.

- Parse `<a:srcRect l/t/r/b>` (1/100000 units) into a new `ImageCrop`
  field on the shared `Image` model.
- Carry crop fractions through the full pipeline: parser -> Image ->
  ImageAttrs -> ImageRun/ImageBlock -> renderer; round-trip back on save
  via the OOXML serializer.
- Apply crop in the painter as `clip-path: inset(...)` on inline,
  block, and floating images via a shared `applyImageVisualAttrs`
  helper in `renderImage.ts`.

Regression tests
- `image-crop-roundtrip.test.ts`: parse full/partial/zero `<a:srcRect>`,
  serialize with zero sides omitted, full parse->serialize->reparse.
- `renderParagraph-inline-image.test.ts`: inline image with crop emits
  `clip-path: inset(...)`; an image without crop emits no clip-path.

* fix(folio): scale cropped images instead of clipping in place

Word's `<a:srcRect>` sizes the drawing extent to the visible (cropped)
region; with `<a:stretch><a:fillRect/>` the cropped source is stretched
to fill that extent. The first pass painted crop fractions as
`clip-path: inset(...)` on an extent-sized `<img>`, which left the
bitmap at extent size and only masked the rest — visibly squished, with
blank bands and the wrong region showing.

Replace the clip-path with an overflow-hidden wrapper sized to the
visible extent, then scale the inner `<img>` by 1/(1-l-r) × 1/(1-t-b)
and shift by the negative crop offsets so the cropped slice exactly
covers the wrapper. Inline and block image runs now wrap a `<span>`;
floating/anchored images already had an overflow-hidden container, so
only the helper math changes there.

Addresses bot review on PR #510 (gemini-code-assist, chatgpt-codex).

* chore(folio): apply oxfmt to wrapImageWithCrop signature

* fix(folio): keep marginLeft cleared for rotated block images
* fix(folio): close 3 image OOXML round-trip gaps (eigenpal #424)

Mirrors three sub-items of eigenpal docx-editor PR #424 that folio still
needed (image cropping is handled separately by PR #510):

1. <wp:effectExtent> vs wp:inline/wp:anchor distT/B/L/R
   ECMA-376 §20.4.2.8: dist* on wp:inline / wp:anchor carry text-wrap
   distance. Per §20.4.2.5, image.padding is the visual-effect reservation
   that belongs on the separate <wp:effectExtent> element. The serializer
   used to fold image.padding into dist*, conflating the two semantically
   distinct properties and breaking round-trips for any padded image.

2. <a:alphaModFix> image opacity
   Parse the alpha amount (1/100000 units) inside <a:blip> into a new
   Image.opacity field on the model and serialize it back on save.
   Fully opaque images keep the model field undefined and emit no
   <a:alphaModFix> element.

3. wp:anchor layoutInCell / allowOverlap tri-state
   The serializer used to hard-code layoutInCell="1" and allowOverlap="1",
   silently discarding the author's intent for floating images that
   escape table cells or refuse overlap. Parse both as tri-state on the
   anchor (true/false/undefined) and round-trip the explicit "0" only
   when the model carries `false`; absent or `true` folds to the
   OOXML default "1".

Includes 12 new round-trip tests covering parse, serialize, and full
parse→serialize→parse for each property; updates one existing #417
EMU-integer test whose original expectation encoded the dist*/padding
conflation. No render-pipeline changes for opacity — that lands once
ImageBlock and ImageAttrs gain the field; the model round-trip is the
prerequisite this commit unblocks.

* fix(folio): accept full OOXML ST_OnOff literals on anchor flags

Bot review on PR #513 flagged that the `layoutInCell` / `allowOverlap`
parser only recognized "1"/"0"; OOXML's `ST_OnOff` also allows
"true"/"false"/"on"/"off". For a DOCX that encoded the attribute as
"true", the parser returned `false`, which on save flipped to "0" and
inverted the author's intent.

- Extract the recognition logic into a small `parseOnOffAttr` helper
  that honours the full literal set and folds anything unrecognized
  (including an absent attribute) back to `undefined` so callers keep
  the existing tri-state semantics.
- Clarify in `parseImageOpacity` that `parseNumericAttribute` already
  returns `undefined` for non-numeric strings, so no separate NaN
  guard is required; document that and add a regression test for the
  non-numeric path so the contract stays pinned.
- Drop the redundant `Math.min(1, image.opacity)` in the alphaModFix
  serializer (the `image.opacity < 1` branch above already guarantees
  the upper bound).
- Two new tests in `image-effect-extent-opacity-overlap.test.ts`:
  "true"/"false" literal round-trip and the amt="oops" guard.

CC on behalf of @jan-kubica
* feat(folio): full page-borders coverage (eigenpal parity)

Close the gap between folio's `<w:pgBorders>` support and the OOXML spec
(ECMA-376 §17.6.10). The painter already wired offsetFrom, display, and
zOrder; this commit fills in the remaining round-trip gaps and adds the
test coverage that was missing.

Parser/serializer:
- Preserve the `w:id` attribute (BorderArt enum) on each side so files
  that use art borders survive open/save and Word can re-paint the
  glyphs. Folio still renders the underlying line style; painting art
  glyphs is deferred.
- Round-trip attribute-only `<w:pgBorders w:display="..." w:offsetFrom=
  "..."/>` elements (Word emits these to clear all sides while pinning
  the section-level placement).

Tests:
- 14 parser/serializer round-trip cases covering display, offsetFrom,
  zOrder, per-side independence, auto/theme colors, shadow/frame,
  art-id preservation, unknown-style fallback, and nil/none no-ops.
- 13 painter cases (Bun-native FakeElement harness — mirrors the
  eigenpal `border-overlay-layout` test) covering offsetFrom=page vs
  text geometry, display=firstPage/notFirstPage/allPages gating, zOrder
  DOM order + z-index, per-side independence, hairline + zero-sz floor,
  and no-op style handling.

Per-section page-border overrides remain at `⚠️` parity: the model
preserves per-section properties but the paginator does not yet set
`Page.sectionIndex`, so the painter receives the document-level
sectionProperties for every page. That refactor is tracked alongside
the broader per-section layout work.

* fix: preserve page-border art relationship ids
* fix(folio): improve DOCX rendering fidelity for legal templates

A pass over a real shareholders' agreement surfaced a cluster of
parser, layout and styling bugs that broke fidelity vs. Word.

Key fixes, all grouped because they all surfaced from the same
template and several share plumbing:

- Header logo right-alignment. Image-only line branch in renderLine
  promoted to flex but never mapped the paragraph's alignment onto
  justify-content, so right-aligned logo headers rendered left.

- Schedule TOC entries no longer drop "Cap Table". The TOC field
  begins in the first entry's paragraph; the inner PAGEREF field's
  begin run was wiping accumulated result runs. A nested begin while
  the outer is past its separator now flushes the outer's results
  into contents first; unclosed outer fields are flushed at end of
  paragraph.

- Schedule TOC entries no longer wrap. measureParagraph and
  renderParagraph computed the first-line text body cursor as
  indentLeft + firstLineOffset, ignoring the list marker that fills
  the hanging zone for w:suff="tab" lists. The body cursor was
  understated by `hanging`, the right-tab width overshot the line
  budget, and the line wrongly wrapped. Now markerInlineWidth is
  folded into the first-line cursor.

- Definition list no longer carries phantom decimal numbering. When
  a level's lvlText is empty and numFmt is "none" (Word convention
  to attach numbering metadata without painting a marker), the
  layout-bridge fallback was synthesising "5.", "6." instead of
  returning null.

- Display math <m:oMathPara> centres on its own line. The line
  containing a single block math run now gets text-align: center
  (jc="centerGroup" is the OOXML default for display math).

- Paragraph-mark-only highlight no longer paints the whole paragraph.
  <w:pPr><w:rPr><w:highlight/></w:rPr></w:pPr> describes the pilcrow
  only; folio was propagating it onto bare runs. highlight/shading
  are now stripped before the paragraph-mark rPr is folded into the
  run inheritance path (paragraph-mark-only booleans like bold/italic
  still flow through the existing suppression helpers).

- "Schedule 1" renders in upper case. The numbering level's rPr
  <w:caps/> is now captured as listRendering.markerAllCaps,
  threaded through to attrs, and applied at marker substitution time
  so "Schedule %1" → "SCHEDULE 1".

- OutNum2 paragraphs carrying inline LISTNUM render and advance
  correctly:
  - FieldChar's <w:numberingChange w:original="…"/> child is now
    captured as originalValue; LISTNUM lacks a separate run and
    stashes its cached display value there.
  - Parser folds the LISTNUM cached value into the host paragraph's
    marker as "7.1\t(a)", strips the field plus its trailing tab
    from the inline content, and records markerSecondSlotOffsetTwips
    from the next ilvl's hanging.
  - renderListMarker splits the marker on the TAB and absolutely
    positions the second slot at that twips column so "(a)" lines up
    with the OutNum3 "(b)" below. text-align-last: left on the
    marker stops the parent paragraph's justify from stretching the
    marker's internal whitespace.
  - Counter at ilvl+1 advances by the LISTNUM count both in
    blockContentParser (parse-time substitution) and toFlowBlocks
    (runtime template), so the following OutNum3 paragraph renders
    "(b)" instead of restarting at "(a)". LISTNUM added to the
    FieldType enum so the parser recognises it.

- Footnote text rendered in docDefaults' Calibri 11pt instead of
  FootnoteText's Times New Roman 8pt, making it look heavier and
  bigger than Word. resolveParagraphDefaultTextFormatting was routing
  formatting.runProperties through resolveTextFormatting, which folds
  docDefaults back in and overwrote the paragraph style's font at
  the final merge. Now only the actual paragraph-mark rPr (plus its
  own character style via getRunStyleOwnProperties) is merged — no
  docDefaults re-injection.

* fix(folio): address review feedback on LISTNUM folding

- Skip metadata content (bookmark / commentRange / commentReference
  ranges) when hunting for the trailing tab after an inline LISTNUM
  field. Word inserts those between the field and its tab; the
  previous loop cleared `dropNextTab` on the metadata node, leaving
  the tab in place and breaking alignment.

- Drop the dead `marginLeft = offsetWidth - …` calculation in
  renderListMarker. `offsetWidth` is `0` pre-append and the value is
  immediately overwritten when the slot is positioned absolutely;
  removing the line clarifies that the second slot's column is
  driven solely by the absolute `left` offset.
…ts) (#587)

* fix(folio): parse SDT dateFormat from w:dateFormat, not w:date@w:fullDate

`w:fullDate` is the bound *value* of a date SDT; the display format lives
in the child `<w:dateFormat w:val="..."/>` element (ECMA-376 §17.5.2.7).
Picked up from upstream eigenpal/docx-editor#661.

* feat(folio): preserve block-level SDT properties + raw sdtPr on parse

Block w:sdt wrappers were flattened on parse so content controls were
silently dropped: no properties, no identity, no round-trip. The parser
now emits a BlockSdt that carries the modeled SdtProperties plus
verbatim rawPropertiesXml / rawEndPropertiesXml strings so unmodeled
OOXML features (w:dataBinding, w15:repeatingSection, w:sdtEndPr) can be
replayed losslessly by the serializer.

To support this:
- HeaderFooter.content and BlockSdt.content widen to BlockContent[] so
  block SDTs are first-class in headers/footers and can nest.
- The shared sdtProperties module replaces the duplicated parser in
  paragraphParser; both inline and block paths feed off one source.
- The block-SDT serializer is lifted into a shared helper used by both
  the document and header/footer serializers (still alias/tag-only;
  raw-XML replay lands in the next commit).
- Walkers (paragraphTraversal, commentReferenceNormalization,
  headerFooterReferenceNormalization) recurse into BlockSdt.content
  instead of using the legacy (Paragraph|Table)[] narrow walker.
- toProseDoc and headerFooterToProseDoc flatten BlockSdt into its
  children for now so editing still works; the PM blockSdt node lands
  in a later commit.

Picked up from upstream eigenpal/docx-editor#653.

* feat(folio): serialize block SDT by replaying the captured raw sdtPr

The shared block-SDT serializer now replays the verbatim
`<w:sdtPr>`/`<w:sdtEndPr>` strings captured by the parser instead of
re-emitting a hand-rolled alias/tag subset. That preserves OOXML element
order (`CT_SdtPr` is an `xsd:sequence`) and round-trips unmodeled
features (`w:dataBinding`, `w15:repeatingSection`, `@lastValue`, custom
XML mappings) without the serializer having to enumerate them.

Programmatically-constructed BlockSdts (no parse snapshot) fall back to
a minimal projection from the modeled fields so the result is still a
valid `<w:sdt>` with escaped attributes.

Picked up from upstream eigenpal/docx-editor#653.

* feat(folio): blockSdt PM node + StarterKit wiring

New BlockSdtExtension models OOXML's block w:sdt as an isolating,
defining ProseMirror node with `content: block+`. Attributes carry the
modeled SdtProperties projection (sdtType, alias, tag, id, lock,
placeholder, dateFormat, listItems, checked) plus rawPropertiesXml /
rawEndPropertiesXml for lossless round-trip.

The doc content spec now accepts blockSdt alongside paragraph, table,
horizontalRule, pageBreak, and textBox. Since blockSdt uses
`group: block`, nesting works for free.

Known follow-up (matches upstream eigenpal #658): no prosemirror-gapcursor
yet, so there is no caret slot before a doc-starting control or between
two adjacent controls. Add the plugin when widening selection support.

Picked up from upstream eigenpal/docx-editor#658.

* feat(folio): toProseDoc/fromProseDoc round-trip for blockSdt

toProseDoc now emits a real blockSdt PM node wrapping the recursively
converted children of a BlockSdt instead of flattening it. rawPropertiesXml
and rawEndPropertiesXml are passed through as attrs so the serializer can
replay them verbatim after the user edits the doc.

A trailing empty paragraph is guaranteed after a doc-final blockSdt so
the caret is never trapped inside an isolating wrapper. Idempotent —
since proseDocToBlocks materializes the trailing paragraph into the
model, the next toProseDoc cycle does not stack a second one on top.

fromProseDoc recovers BlockSdt model nodes (including nested ones) by
recursing extractBlocks into a synthetic doc carrying the blockSdt's
children; properties are read back from the PM attrs.

The PM document validator accepts blockSdt and walks its children; the
schema enforces shape so attribute validation is delegated.

Picked up from upstream eigenpal/docx-editor#658.

* feat(folio): paint block-SDT boundary chrome via SdtGroup tagging

toFlowBlocks now recurses into a blockSdt PM node and tags each child
flow block with an outer→inner SdtGroup stack carrying the modeled
SdtProperties projection. The layout engine copies that stack into the
ParagraphFragment / TableFragment so the painter sees per-page, per-
fragment SDT identity.

The painter stamps `data-sdt-*` attributes on rendered blocks via a new
shared `applySdtDataAttrs` helper: `data-sdt-boundary`, `data-sdt-id`,
`data-sdt-type`, `data-sdt-tag`, `data-sdt-alias`, plus the outer→inner
JSON stack. CSS draws a soft dashed border on hover/focus-within using
Stella's semantic `--color-border` token so the chrome stays visually
quiet in the common case.

Body editor only (matches upstream eigenpal #658's deferral); HF chrome
follows when the HF painter loop is unified with the body painter.

Picked up from upstream eigenpal/docx-editor#658.

* feat(folio): headless content-control addressing + write API

New `core/content-controls` module:

- findContentControls / findContentControl / getContentControlText —
  walk the Document model recursively (including nested SDTs and table
  cells) and surface controls by tag / alias / id / sdtType. Read-only,
  never mutates the input.

- setContentControlContent — fill a control with plain text (becomes one
  paragraph) or an explicit BlockContent[] (deep-cloned). Clears
  `showingPlaceholder` so the new content is not styled as placeholder.

- setContentControlValue — typed value setters with a discriminated union
  input shape: dropdown (validates against listItems), checkbox (writes
  the modeled state + ☒/☐ glyph), or date.

- removeContentControl — drop or unwrap (`{ keepContent: true }`).
  Refuses to unwrap a w15:repeatingSection because that would orphan
  w15 row items.

- Errors are TaggedError subclasses (ContentControlLockedError,
  ContentControlTypeError) carrying structured context. Every refusal
  is overridable per call with `{ force: true }`.

All helpers are pure functions over the model; the ProseMirror editor-
ref wiring lands in the next commit so writes become undoable
transactions.

Picked up from upstream eigenpal/docx-editor#659.

* feat(folio): DocxEditorRef methods for content controls

New ProseMirror transaction helpers in
`core/prosemirror/commands/contentControls.ts`:

- findBlockSdtMatches / findBlockSdtMatch — walk PM nodes by filter
- setContentControlContentTr — replace inner content with text
- setContentControlValueTr — typed value setters (dropdown / checkbox /
  date), discriminated-union input
- removeContentControlTr — drop or unwrap (`{ keepContent: true }`)

Writes go through a normal transaction so they are undoable and play
nicely with the suggestion-mode `isHistoryTransaction` guard. Lock and
type validation mirrors the headless API; refusals throw the same
TaggedError subclasses.

DocxEditorRef exposes five new methods wired against the live view:
getContentControls, scrollToContentControl, setContentControlContent,
setContentControlValue, removeContentControl. Block-content fill via
the editor ref is deferred to a follow-up (currently throws
ContentControlTypeError); use the headless API + loadDocument until
then.

Picked up from upstream eigenpal/docx-editor#659.

* feat(folio): interactive content-control widgets (checkbox/dropdown/date)

New `contentControlWidgets` PM plugin intercepts clicks on painted blocks
that carry `data-sdt-type` (stamped in commit 6) and dispatches the
appropriate `setContentControlValueTr` so the mutation lands on the
normal undo stack.

- Checkbox: a click anywhere on the painted block toggles the modeled
  state and updates the ☒/☐ glyph. Fully wired in this commit — no shell
  changes required.

- Dropdown / date: the plugin emits a typed `dropdownOpen` / `datePick`
  event through its `onEvent` callback so the editor shell can render a
  coss `Menu` popover or a native date picker without re-implementing
  the dispatch rules. `dispatchDropdownPick` / `dispatchDatePick`
  helpers are exported for those shells to call.

The plugin is registered in StarterKit so existing folio editors get
checkbox interactivity for free; dropdown / date UI surfaces remain
opt-in (the shell decides what to render and how).

ContentControlLockedError / ContentControlTypeError thrown by the
transaction helper are caught and logged; refusals never crash the
editor.

Picked up from upstream eigenpal/docx-editor#661.

* refactor(folio): route blockSdt attr reads through typed reader

Add `BlockSdtAttrs` plus matching `readBlockSdtAttrs` /
`expectBlockSdtAttrs` so conversion and layout-bridge code stops
reading PM attrs via raw bracket indexing — this is the same boundary
discipline `adapterBoundary.test.ts` enforces for every other PM node
type.

`fromProseDoc.convertPMBlockSdt` and `toFlowBlocks.visit` now resolve
SDT attrs through the cached reader; the runtime behaviour is
unchanged, but path-aware validation diagnostics now point at the
blockSdt boundary if a saved doc carries an unexpected attribute shape.

* fix(folio): reconcile raw sdtPr with modeled property mutations on save

The block-SDT serializer used to replay properties.rawPropertiesXml
verbatim, which dropped any interactive change the editor made to the
modeled props: a user toggling a checkbox, picking a date, or choosing
a dropdown value would round-trip through the model correctly but the
old <w14:checked w14:val="0"/> from the source DOCX stayed in the raw
string and got written back on save. Word would reopen the document
with the change silently discarded.

New `reconcileRawSdtPr` surgically patches just the relevant child
elements of <w:sdtPr> before the serializer replays it:

- checkbox: rewrites w14:checked (or w:checked) val; inserts the whole
  w14:checkbox wrapper if neither existed
- date: rewrites w:date@w:fullDate and the child w:dateFormat
- dropdown / comboBox: rewrites w:lastValue (display→value lookup uses
  the modeled listItems so OOXML stores the value, not the friendly
  label)
- showingPlcHdr: toggles the marker based on properties.showingPlaceholder

Unmodeled markers inside <w:sdtPr> (w:dataBinding,
w15:repeatingSection, custom XML mappings) are untouched, so the
lossless round-trip guarantee for those still holds.

Addresses gemini-code-assist[bot] feedback on PR #587 — the most
consequential of the round-trip bugs flagged by the reviewer.

* fix(folio): split content vs container lock semantics for w:lock

Per OOXML §17.5.2.16, w:lock has three values with distinct effects:

- `contentLocked` — blocks content edits; the SDT container itself can
  still be removed.
- `sdtLocked` — blocks container removal; content inside is editable.
- `sdtContentLocked` — blocks both.

`lockBlocksMutation` (headless) and `ensureNotLocked` (PM) both checked
only the content-lock pair (`contentLocked` | `sdtContentLocked`) and
were used for every mutation path. That meant `removeContentControl`
wrongly refused on `contentLocked` (which only locks content) and
wrongly allowed on `sdtLocked` (which only locks the container).

Split into `ensureContentNotLocked` (called by
setContentControlContent and setContentControlValue) and
`ensureSdtNotLocked` (called by removeContentControl) in both layers
so each path matches the OOXML semantics.

New regression tests assert: contentLocked allows removal,
sdtLocked refuses removal but allows edits, sdtContentLocked refuses
both.

Addresses gemini-code-assist[bot] feedback on PR #587.

* fix(folio): getContentControls surfaces every modeled SdtProperties field

The editor-ref `getContentControls` projected only sdtType / alias /
tag / id and silently dropped lock, placeholder, showingPlaceholder,
dateFormat, listItems, and checked. Downstream tooling (AI agents,
template scanners) inspecting these properties saw an incomplete view
and made wrong decisions (e.g. trying to write into a contentLocked
control because the projection said `lock` was undefined).

Centralize the projection in a new
`blockSdtAttrsToSdtProperties(node)` helper that routes through the
typed `expectBlockSdtAttrs` reader and forwards every modeled field.
listItems is decoded back into the structured `{displayText, value}[]`
shape the headless API surfaces.

Addresses gemini-code-assist[bot] feedback on PR #587.

* feat(folio): wire prosemirror-gapcursor for caret slots near isolating SDTs

blockSdt is isolating + defining, which means regular text selections
refuse positions immediately before a doc-starting SDT, between two
adjacent SDTs, or after a doc-final SDT. Without a gapcursor those
slots are unreachable — the user can't place the caret to type before
the first SDT or split the gap between two of them.

Add prosemirror-gapcursor (now an explicit dependency; it was already
transitively installed) via a GapCursorExtension wired into StarterKit
between dropCursor and the rest of the feature plugins. The CSS draws
a thin blinking caret matching folio's existing semantic-token style.

Addresses the upstream eigenpal #658 follow-up explicitly noted in the
PR plan as a known limitation.

* feat(folio): block-content fill via the editor ref

`setContentControlContent` on DocxEditorRef used to throw
ContentControlTypeError when called with `BlockContent[]`, making the
editor-ref method strictly less capable than the headless API. The
caller had to fall back to setContentControlContent on the model and
loadDocument the result, which loses cursor / selection / focus.

New `setContentControlContentBlocksTr` runs the input through
`headerFooterToProseDoc` (which reuses the body's per-block converters)
to produce a PM `block+` node sequence and replaces the SDT's children
via the same `replaceWith` shape the string path uses. Locks are
checked first (shared `replaceBlockSdtChildrenForFill` helper) so the
lock semantics from the previous commit apply uniformly.

The block-content path lives in a separate file because the conversion
pipeline reaches the PM schema and StarterKit, and the widgets plugin
imports the basic `contentControls` helpers — pulling the converter
into the same module would create a cycle.

* feat(folio): editor-shell wiring for dropdown/date widgets + typed refusal event

The widgets plugin used to emit `dropdownOpen` / `datePick` events into
the void — no editor-shell component subscribed, so clicking a dropdown
or date SDT did nothing visible. The "interactive widgets" commit
landed as wired only for checkboxes; this commit closes the loop:

- Plugin: events are dispatched both through the `onEvent` callback and
  as a `folio:content-control-widget` CustomEvent on the editor view DOM
  (typed name exported as CONTENT_CONTROL_WIDGET_EVENT_NAME) so a React
  shell can subscribe without owning a closure over the plugin.

- React shell: new ContentControlWidgetsOverlay component subscribes to
  the CustomEvent, positions a small floating picker at the click
  anchor — a list of items for dropdown/comboBox, a native
  `<input type="date">` for date — and dispatches back through
  dispatchDropdownPick / dispatchDatePick so the mutation rides the
  normal undo stack. Closes on Escape, scroll, or selection.

- Refusal logging: dropped the ad-hoc `console.warn` (CLAUDE.md asks for
  structured logging via shared analytics helpers, and folio has none
  yet). The plugin now emits a typed `refused` event carrying the
  TaggedError so the host can surface it via toast / telemetry / no-op,
  per their own product preferences.

Wired into DocxEditor next to TextContextMenu so the picker mounts as
soon as the editor is interactive.

* fix(folio): model date value separately from formatted body display

Date SDTs displayed a format-rendered string in the body ("2 June 2026"
per dateFormat "d MMMM yyyy") but the serializer's reconcile path was
reading that body text and writing it back into w:date@w:fullDate —
which OOXML requires to be an ISO-8601 string. Word would reopen the
document with a corrupted date binding.

Model the bound value on SdtProperties.dateValueISO (separate from
dateFormat) and route it through the schema attrs / typed reader /
toProseDoc / fromProseDoc projections / editor-ref projection.

setContentControlValue (both headless and PM transaction) now writes
the format-rendered display into the body via the existing fieldParser
`formatDate` helper, while parking the ISO input on dateValueISO. The
reconcile path reads dateValueISO into w:fullDate; the body text is no
longer treated as the bound value.

When dateFormat is unset the body shows the ISO input verbatim, so the
old "ISO in, ISO out" tests still pass alongside the new
"format-rendered display + ISO round-trip" coverage.

* fix(folio): address content controls by pmPos, not tag (duplicate-tag bug)

Two SDTs sharing the same w:tag are common in templates (a clause with
two named-fields), and the widgets plugin / editor-ref API filtered by
tag — so a click on the second instance routed to the first.

Add a `pmPos` filter to ContentControlFilter and thread the clicked
SDT's PM position end to end:

- SdtGroup carries `pmPos` (the blockSdt's open token position)
- toFlowBlocks records it from the visit()'s `pos`
- applySdtDataAttrs stamps `data-sdt-pm-pos` on the painted element
- the widgets plugin reads `data-sdt-pm-pos`, parses it, and filters by
  pmPos when it builds the click transaction or emits an event
- dispatchDropdownPick / dispatchDatePick take pmPos, not tag
- the typed widget event carries pmPos so the React overlay routes the
  picker selection back to the exact instance the user opened

tag stays on the event for telemetry but is no longer the addressing
key. New regression test asserts that filtering by pmPos returns only
the instance at that position.

* fix(folio): keep nested SDTs non-empty when removing their only child

removeContentControl inserted a placeholder paragraph at the body root
when nextContent became empty, but it never patched the same hole one
level deeper — removing the only inner SDT from an outer wrapper left
the outer with `content: []`. Round-trip through PM stayed valid
because toProseDoc's `convertBlockSdt` safety net re-inserts a
paragraph, but consumers reading the headless model directly (AI
agents, template scanners) would see an inner-empty BlockSdt with no
recourse.

mapBlock now inserts an empty placeholder paragraph at the SDT layer
too, when transforming the children emptied a previously non-empty
wrapper. The model stays self-consistent without depending on a
downstream consumer's safety net.

* fix(folio): bypass suggestion mode for content-control writes

setContentControlValueTr / removeContentControlTr produce replaceWith
transactions that the suggestion-mode catch-all appendTransaction
would otherwise re-classify as inserted content, stamping insertion
marks on the new body text. A reviewer would see pending changes for
every widget click — false positives that obscure the user's real
edits.

Tag the value-set and remove transactions with SUGGESTION_BYPASS_META
so the catch-all skips them. The same pattern is already used by
history rejoin paths.

New regression test toggles a checkbox under active suggesting mode
and asserts the new body text carries no insertion mark.

* fix(folio): per-SDT boundary chrome instead of per-fragment

The hover/focus chrome painted a full dashed box around every block
inside an SDT, so a multi-paragraph control showed several stacked
rectangles instead of one outline.

toFlowBlocks now tags each block emitted under a block-SDT with its
position within the SDT (`first` / `middle` / `last` / `only`) on the
innermost SdtGroup. The painter stamps `data-sdt-position` from this
and the CSS draws partial borders so the dashed outline is continuous
across the SDT's block sequence — top edge on `first`, bottom edge on
`last`, only side edges in between. `only` and the unmarked legacy
case keep a full closed box.

Multi-page pagination still draws first/last per logical block (not
per page-split fragment); that's a smaller follow-up if the visual
seam ever shows up in real templates.

* refactor(folio): overlay subscribes via stable view DOM ref, uses coss Menu + i18n

Three issues the overlay had that the self-review called out, fixed
in one pass since they all touch the same component:

1. The useEffect dep was `getEditorView` — a new function literal each
   parent render. The listener re-attached on every commit, opening a
   gap where events could fire between teardown and re-subscribe. Now
   we track the editor view's DOM node identity and read the current
   getter from a ref, so the subscription only re-establishes when the
   underlying view actually changes.

2. The dropdown list was bespoke `<button>`s — no menu role, no
   keyboard navigation semantics, visually inconsistent with the rest
   of folio. Replaced with `MenuItem` from `@stll/ui/components/menu`,
   wrapped in `role="menu"` so SR/keyboard a11y matches every other
   menu in the editor.

3. The ARIA labels and the empty-state copy were hard-coded English.
   Now route through `use-intl` (`useTranslations("folio")`) using
   three new keys — `contentControlDropdownAriaLabel`,
   `contentControlDateAriaLabel`, `contentControlDropdownNoOptions`.
   `bun run i18n:sync` synchronized the new keys across every existing
   `apps/web/src/i18n/langs/*.json` file.

Also swap the `left: ...` CSS rule for `insetInlineStart` so the
overlay positions correctly in RTL editors.

* test(folio): cover nested-path addressing + table fill in editor-ref helpers

Two coverage gaps the self-review flagged:

- `findBlockSdtMatch.path` was only asserted for top-level matches.
  Adds a test for an SDT nested inside another SDT; expects the path to
  reflect the doc → outer → inner ancestry so callers can walk back to
  the node via `doc.child(path[0]).child(path[1])`.

- `setContentControlContentBlocksTr` only exercised paragraph blocks in
  its existing fixture. Adds a test that fills a control with a
  two-cell table and asserts the PM blockSdt now has a `table` child.
  The conversion pipeline (`headerFooterToProseDoc`) has tighter
  coverage on its own; this test pins the editor-ref entry point so a
  future refactor cannot silently drop table support there.

* fix(folio): cleanup pass 3 — bot review + self-review findings

Six items addressed across the parser, serializer, PM transactions, and
validator:

1. parseSdtProperties stripped only `w:` prefixes, so `w14:checkbox`
   fell through to default and Word checkboxes parsed as richText with
   no `checked` state. Use the local-name helper so any namespace
   prefix routes to the right case. (codex P2)

2. removeContentControlTr deleted the only top-level SDT but the doc
   content spec requires `+`, so the resulting transaction was schema-
   invalid. Substitute an empty paragraph in that one case, matching
   the headless removeContentControl behavior. (codex P2)

3. reconcileRawSdtPr's checked-element rewrite only matched the
   self-closing form. A source DOCX written with `<w14:checked .../>`
   in the expanded `<w14:checked ...></w14:checked>` form left the
   stray closing tag behind, producing invalid XML. Match the opened
   form first, then fall back to self-closing / wrapper / synthesized.
   (codex P2)

4. `new Date("2026-06-02")` parses as UTC midnight; the formatter's
   local-time accessors then returned the previous day for any user in
   a negative-offset TZ. Parse date-only ISO inputs as local-midnight
   so the rendered display matches the picked calendar date in every
   timezone. (self-review)

5. validation.ts skipped readBlockSdtAttrs in the blockSdt case, so
   malformed projections (wrong sdtType, non-string raw XML) leaked to
   the serializer. Route through the typed reader like every other
   node type. (gemini P1)

6. setContentControlValueTr's dropdown path called `JSON.parse(...)`
   bare on the listItems attribute. A malformed attr would crash the
   transaction. Wrap in try/catch and treat malformed input as "no
   list", falling back to writing the raw value. (gemini P1)

* fix(folio): preserve picked time in date SDTs + logical chrome corners

Two minor polish items the self-review on cleanup pass 3 surfaced:

- `parseSdtDate` only matched `YYYY-MM-DD`, so a date SDT with a
  time-bearing format ("yyyy-MM-dd HH:mm") rendered the picked
  "2026-06-02T15:30:00Z" as "2026-06-02 00:00". Extract hours / minutes
  / seconds when the input is `YYYY-MM-DDTHH:mm[:ss]` and build a local
  Date carrying those wall-clock components, so the format's local-time
  accessors return the picked time in every TZ. The trailing `Z` /
  offset is intentionally ignored — for a date SDT, what the user
  picked is the displayed wall time, not a UTC moment.

- The boundary chrome's `first` / `last` rules used physical corners
  (`border-radius: 2px 2px 0 0`). Replaced with `border-start-start-
  radius` / `border-start-end-radius` / `border-end-...-radius` so the
  rounded corners stay on the visually-leading inline edges regardless
  of writing-mode / RTL.

Regression test: a date SDT with `dateFormat: "yyyy-MM-dd HH:mm"` and
a "T15:30:00Z" input now renders "2026-06-02 15:30" — previously
"00:00".

* fix(folio): cleanup pass 4 — 5 codex P2 bot findings

1. parseSdtProperties only treated `w14:checked w14:val="1"` as
   checked. OOXML OnOff also recognizes "true" / "on" / absent val (the
   bare `<w14:checked/>` is `true`). Reuse `parseBooleanElement` so
   real Word documents render and round-trip the checked state
   correctly instead of silently flipping on save.

2. parseSdtDate's regex matched the YYYY-MM-DD prefix and built a Date
   via `new Date(year, month-1, day)`, which JavaScript silently
   normalizes for overflow ("2026-99-99" → some distant real date).
   That violated the "return unchanged if the input does not parse"
   contract. Round-trip the captured components against the resulting
   Date and reject the parse on mismatch.

3. getContentControls on DocxEditorRef returned `{properties, path}`
   only. The mutation / scroll filters added in earlier cleanup
   passes take `{ pmPos }`, so an enumeration of duplicates was useful
   only for read-back — there was no way to address a later duplicate
   for a write. Include `pmPos` on each entry.

4. The outer SDT's position-stamping loop in toFlowBlocks called
   `b.sdtGroups.at(-1)` on every emitted block, including blocks
   already nested inside an inner SDT — overwriting the inner group's
   first/middle/last with the outer sequence position. The painter
   then drew nested SDTs with wrong / missing boundary edges. Find the
   entry by `pmPos` and update that one, leaving inner/outer siblings
   intact. New regression test exercises `outer = [pre, inner, post]`
   and asserts the inner SDT keeps its first/last while the outer
   correctly marks the inner's blocks as `middle` of the outer.

5. parseListItems recorded an empty `displayText` when only `w:value`
   was present (and vice versa). The dropdown overlay then rendered
   blank options and setContentControlValue wrote empty paragraphs.
   Each missing attribute now falls back to the other; a listItem
   with neither is dropped entirely.

bun test src on folio still passes 1562 of 1565 — the 3 remaining
failures are the pre-existing perf-budget / unzip-timeout / roundtrip-
property flakes documented in PR #584's body.

* fix(folio): cleanup pass 5 — 2 codex P2 bot findings (inline + fallback)

Two issues exposed by the cleanup-pass-4 commits (sharing the SDT
property parser and patching raw XML for block SDTs left two gaps on
adjacent code paths):

1. The shared `parseSdtProperties` now populates `dateValueISO`, but
   the inline-SDT path (SdtAttrs / convertInlineSdt /
   createInlineSdtFromNode / serializeInlineSdt) carried only
   `dateFormat`. For an inline date SDT with
   `<w:date w:fullDate="2026-06-02T00:00:00Z"><w:dateFormat w:val="MMMM d, yyyy"/>`
   the parse → edit → save cycle wrote the display format into
   `w:fullDate` (mirroring the bug we fixed on the block path a few
   passes ago) and discarded the ISO value. Thread `dateValueISO`
   through the inline schema attrs, both conversion sides, and
   serializeInlineSdt — emitting fullDate on `<w:date>` and the
   format on `<w:dateFormat>` separately.

2. `serializeFallbackSdtPr` (the path that runs for a BlockSdt
   constructed programmatically without `rawPropertiesXml`) only
   emitted generic metadata. A dropdown with modeled `listItems`
   serialized as bare `<w:sdtPr>` with no `<w:dropDownList>`, so
   Word would reopen it as richText and discard the choices.
   `reconcileRawSdtPr` only patches existing markers — it doesn't
   insert a missing `<w:dropDownList>` / `<w14:checkbox>` / `<w:date>`.
   Emit the type-specific marker (plus dropdown / comboBox listItems,
   date fullDate + dateFormat, checkbox w14:checked) from the
   fallback so programmatic controls round-trip with their type
   intact.

New regression tests pin both: an inline date SDT and three fallback
forms (dropdown, date, checkbox) round-trip with the right markers.

* fix(folio): cleanup pass 6 — preserve sdtPr prefix + inline date test

1. `upsertSdtPrChild` (the path that inserts a missing child into the
   captured raw `<w:sdtPr>` — e.g. when toggling showingPlaceholder or
   adding a checkbox marker that wasn't there) matched any prefix on
   the closing tag but rewrote with a hard-coded `</w:sdtPr>`. A
   source that uses an exotic prefix (`<ns0:sdtPr>...</ns0:sdtPr>`)
   would end up with mismatched open/close tags — malformed XML that
   Word refuses on open. Capture the matched prefix and reuse it.
   (codex P2)

2. Self-review: the pass-5 inline date round-trip had no regression
   coverage. Add a paragraph-level test exercising parse → serialize
   for an inline `<w:date w:fullDate="..."><w:dateFormat .../></w:date>`
   SDT to lock in that `w:fullDate` carries the ISO bound value and
   `<w:dateFormat>` carries the display format — independently. A
   second case confirms `<w:date/>` alone round-trips when neither
   field is modeled.

* fix(folio): preserve cleared showingPlaceholder through PM round-trip

`convertPMBlockSdt` had a truthy-only check (`if (attrs.showingPlaceholder)`),
so when the widget / editor-ref path filled a placeholder-bearing
control, `replaceBlockSdtChildren` set `showingPlaceholder: false` on
the PM attrs but the false value was dropped on the way back to the
model. `reconcileRawSdtPr` then never observed `props.showingPlaceholder
=== false` and could not strip the source `<w:showingPlcHdr/>` from
`rawPropertiesXml`, so a saved-and-reopened DOCX still treated the
newly-filled body as placeholder text.

Mirror the inline path's `!== undefined` check so an explicit `false`
survives. New round-trip regression test pins it.

The same site previously checked `if (attrs.checked)` truthy-only too,
but that path was already using `typeof === "boolean"` and correctly
preserves both true and false. Other truthy-only attr checks
(`attrs.dateFormat`, `attrs.placeholder`) target strings where an empty
value should drop the field — those stay as-is.

Addresses codex P2 review on PR #587.

* fix(folio): parse w:showingPlcHdr as an OOXML OnOff value

`case "showingPlcHdr": props.showingPlaceholder = true` ignored the val
attribute and treated every occurrence as `true`. A source DOCX with
`<w:showingPlcHdr w:val="false"/>` (rare from Word itself but possible
from a third-party producer or after a tool round-trip) was parsed as
`true`, so reconcileRawSdtPr would later flip the user's intent on
save.

Use `parseBooleanElement` so all OnOff forms are honored — bare
presence still means `true`, but explicit `false` / `0` / `off` round-
trip correctly. New regression tests pin every variant.

Found during a self-rabbit-round pass — codex hadn't flagged this one
yet but the same OnOff-vs-strict-string problem we fixed for
`w14:checked` in pass 4 applied here too.

* fix(folio): tolerate alternative namespace prefixes in OOXML OnOff parser

`parseBooleanElement` was reading `val` only at the caller-supplied prefix
(`w14:val`) or unprefixed. A doc that binds the same URI under a different
prefix — `<ns0:checked ns0:val="0"/>` — slipped through `findChild`'s
local-name match but lost the val attribute, so an unchecked box was
parsed as checked and would be re-saved with the wrong state.

Derive the element's actual prefix from `el.name` and use it as a first-
attempt namespace for `getAttribute`. The standard `w14:` / `w:` paths
still hit on the first try; alt-prefix docs now resolve correctly. The
showingPlcHdr OOXML negation case from pass 8 benefits too, since the
helper change applies to every caller.

Codex P2 finding on PR #587. Regression test pins ns0-prefixed
`val="0"` / `"1"` / `"false"` behaviour.

* fix(folio): three codex P2 findings on PR #587

1. parseSdtDate: anchor regex at end so partial-prefix inputs
   (`2026-06-02abc`, `2026-06-02T`) fall through to the strict
   `new Date(iso)` path instead of silently succeeding and rendering a
   formatted body that disagrees with the bound `dateValueISO`.
   Allow a trailing `Z` / numeric offset so the legitimate suffix cases
   we already accepted still match. Two parseSdtDate copies updated
   (headless + PM-command path).

2. reconcileRawSdtPr: strip the expanded-empty `<w:dateFormat …>
   </w:dateFormat>` form in addition to the self-closing one. A
   producer that wrote the open/close pair would otherwise leave a
   stale sibling next to our freshly-prepended replacement, ending up
   with two dateFormat children on the next save.

3. mergeRunInParagraphs: stop merging when adjacent paragraphs sit in
   different SDT stacks. The merged ParagraphBlock spread only `a`'s
   sdtGroups; a `<w:specVanish/>` paragraph straddling an SDT boundary
   would otherwise carry SDT membership onto outside text (or drop it
   from inside text), so chrome and widget click targets would line up
   against the wrong range. Compare pmPos stacks since pmPos is unique
   per SDT instance within one toFlowBlocks call.

Regression tests cover each fix.

* fix(folio): stop padding doc-final blockSdts with synthetic caret paragraphs

Two codex P2 findings on PR #587 trace to the same converter behavior:

1. `toProseDoc` / `headerFooterToProseDoc` appended an empty trailing
   paragraph after any doc-final blockSdt so the editor caret could
   land past the isolating wrapper. `fromProseDoc` has no marker to
   distinguish that synthetic paragraph from a real one, so a
   parse → save round trip silently grew the DOCX by one `<w:p/>` on
   every cycle (visible blank space + pagination drift in legal
   templates).

2. `setContentControlContentBlocksTr` runs the same
   `headerFooterToProseDoc` converter, so a caller doing
   `setContentControlContent(filter, [oneBlockSdt])` got an unintended
   blank paragraph injected inside the target SDT next to the
   replacement content.

`prosemirror-gapcursor` (wired in pass 7) already provides the caret
slot at the doc-end-after-isolating boundary at runtime, so the
synthetic paragraph is no longer needed. Removing it eliminates both
the round-trip growth and the block-fill padding without losing caret
reachability. Regression tests pin "no trailing paragraph injected"
for both paths.

* fix(folio): 2 codex P2 findings + structural CodeQL fix

Three issues in the SDT cluster:

1. parseListItems read displayText / value at the canonical `w:` prefix
   only. A producer that bound the Word namespace under any other
   prefix (`<ns0:listItem ns0:displayText="A" ns0:value="a"/>`) matched
   the element via local-name fallback but came back with null attrs
   and the item was silently dropped, so the dropdown opened with no
   options and `setContentControlValue` rejected valid picks. Read by
   the element's own prefix first via a new helper, mirroring the
   `parseBooleanElement` fix from the same review round.

2. dispatchDropdownPick / dispatchDatePick let ContentControlLockedError
   escape into the React picker handler, which does not catch it — a
   locked dropdown or date picker crashed the UI instead of refusing
   the change. Two-layer fix: preflight the lock at click time and
   emit `refused` instead of opening the picker; wrap the dispatch
   helpers in try/catch returning false so a mid-picker doc change
   that locks the control after the preflight can no longer crash the
   React shell.

3. escapeXmlAttr's sequence of replaceAll calls kept tripping CodeQL's
   "incomplete multi-character sanitization" heuristic on each new
   call site we added. Switched to a single-pass replace over a small
   lookup table so the structural concern (multi-step decoding could
   resurrect a tag) doesn't apply, and added apostrophe coverage for
   completeness even though we always emit `"`-delimited attrs. Output
   is still XML embedded in a DOCX zip — no HTML / browser context
   downstream.

Regression tests cover ns0-prefixed listItems and the locked-dropdown
/ locked-date picker paths.

* fix(folio): broaden namespace-prefix tolerance across the SDT parser/patcher

Codex flagged two more variants of the same pattern from the previous
round (alternate Word-namespace prefixes), so this round promotes the
fix from an ad-hoc helper into a structural one:

- New shared helper `getAttributeAnyPrefix` in xmlParser that tries the
  element's own prefix first, then canonical `w:`, then unprefixed —
  matches the existing `parseBooleanElement` tolerance.
- Replaced every `getAttribute(el, "w", …)` call in `parseSdtProperties`
  with the new helper so id / alias / tag / lock / placeholder val /
  dateFormat val / fullDate now round-trip on docs that bind w: under
  ns0 (or any other prefix). `getContentControls({ tag })` and
  tag-keyed mutations now find those controls.
- `reconcileRawSdtPr`'s lastValue stripper used a literal " w:lastValue="
  marker and missed `ns0:lastValue`, then appended a fresh `w:lastValue`
  on save (two attributes, Word free to keep the stale one). Rewrote
  the scanner to match any `prefix:lastValue` (or unprefixed) preceded
  by whitespace, and re-emit the modeled selection under the source
  element's own prefix so we don't mix prefixes within the same node.

Regression tests pin ns0-prefixed top-level attrs and the dropdown
lastValue round-trip.

* fix(folio): carry dropdown selected value on the model, not body text

Codex P2 (PR #587): when two `<w:listItem>` entries share a displayText
but have different `w:value`, picking the later item via
`setContentControlValue` wrote only the friendly label into the SDT
body, and `extractDropdownLastValue` then resolved the saved value by
displayText match — which returned the FIRST collision. Word reopened
the DOCX with the wrong selection.

Add a modeled `dropdownLastValue` field on `SdtProperties` symmetric
to how `dateValueISO` already carries the bound date independently of
the displayed body text. Threaded through:

- `SdtProperties` (docx-core) gains the new field
- `setContentControlValue` / `setContentControlValueTr` write it on
  the dropdown branch alongside the body update
- `BlockSdtAttrs` / `SdtAttrs` schemas, `BlockSdtExtension` /
  `SdtExtension` PM attrs, `toProseDoc` / `fromProseDoc`,
  `blockSdtAttrsToSdtProperties`, and the attrs validators all carry
  the value across the PM boundary
- `parseSdtProperties` reads any existing `w:lastValue` on the source
  `<w:dropDownList>` / `<w:comboBox>` into the modeled field so
  parse-then-save preserves Word's prior selection even when display
  text would have been ambiguous
- `extractDropdownLastValue` prefers the modeled value and only falls
  back to body-text matching for documents that have neither been set
  via the API nor parsed from a doc that carried a `w:lastValue`

Regression test pins the duplicate-displayText case.

* fix(folio): placeholder docPart attr + clarify pmPos staleness in editor API

Two codex P2 findings on PR #587:

1. `parseSdtProperties` looked for a nested `<w:val>` child inside
   `<w:placeholder><w:docPart …/></w:placeholder>`, but per §17.5.2.27
   the placeholder reference is an attribute on `<w:docPart>` itself
   (`<w:docPart w:val="…"/>`). `props.placeholder` was therefore
   never populated for parsed controls, and `getContentControls()` plus
   the reverse serializer both lost the placeholder metadata. Read
   `val` directly from `docPart` via the prefix-tolerant helper.

2. `getContentControls()` returns `pmPos` values that disambiguate
   duplicates within the snapshot at fetch time, but they don't
   survive a mutation that resizes earlier content — a follow-up
   `setContentControl*({ pmPos })` on a stale value misses the
   intended control or hits whatever now sits at the old position.
   Documented the staleness invariant on both the editor-ref method
   and the returned `pmPos` field so batch-fill callers refetch via
   `getContentControls()` after each mutation or address by stable
   `{ tag }` / `{ id }` / `{ alias }`.

Regression test pins the placeholder docPart reference.

* fix(folio): collapse dateFormat strip into one regex to silence CodeQL

The pass 10 fix added a second `replaceAll` to also catch
`<w:dateFormat …></w:dateFormat>` (expanded-empty form) alongside the
self-closing one. CodeQL's incomplete-multi-character-sanitization
heuristic kept flagging the chain on PR #587 (alerts #63 / #64,
gating the CodeQL check) even after pass 12's single-pass
`escapeXmlAttr` refactor, because the heuristic models the chained
replaceAll pattern as a partial HTML sanitiser.

Merge the two patterns into one alternation so the strip happens in a
single pass. Same semantics, cleaner code, and the heuristic no longer
fires. The underlying assessment from earlier rounds still applies:
this is XML buffer manipulation that never reaches a browser, so the
XSS framing does not apply — but the structural form is the better
fix anyway.

* fix(folio): match repeatingSection by local name, not literal w15: prefix

Codex P2 (PR #587): both `isRepeatingSection` helpers (the headless
mutate path and the PM-command path) hard-coded a substring search
for `"w15:repeatingSection"`. A DOCX that binds the Word 2012
namespace under a non-canonical prefix (`<ns0:repeatingSection/>`) is
identical for our purposes but slipped through the check, so
`removeContentControl(..., { keepContent: true })` would unwrap it and
orphan the section's row items — the exact data loss the surrounding
type-error refusal was meant to prevent.

Switch both copies to a `<\w+:repeatingSection\b` regex so any
namespace prefix bound to the same URI is recognised. Regression
test pins the alt-prefix unwrap refusal.

* fix(folio): accept fractional seconds in parseSdtDate regex

Codex P2 (PR #587): new Date(iso).toISOString() always emits a
fractional-seconds suffix (.SSS), so a caller round-tripping a date
through Date and back hits an ISO that the anchored regex didn't
match (2026-06-02T00:00:00.000Z). The helper then fell back to
new Date(iso) and lost the timezone-safe component path — in a
non-UTC zone the rendered body would shift forward or back a day
while the model's dateValueISO stayed correct, so the visible date
silently disagreed with the bound value.

Allow an optional .\d+ after the seconds group in both parseSdtDate
copies. Hoist the regex to a module-level constant with the
sonarjs/regex-complexity disable directive placed between `=` and
the literal so the formatter's wrap survives lint (the rule trips at
35 for an inherently long ISO 8601 alternation; splitting it would
obscure intent rather than help). Regression test pins the .000Z
shape rendering as the correct calendar day.

* fix(folio): normalize alt-prefix sdtPr to canonical w: on capture

Codex P2 (PR #587): when a DOCX binds the WordprocessingML namespace
under a non-canonical prefix at the document root, the parser
captures `<ns0:sdtPr>…</ns0:sdtPr>` verbatim into
`rawPropertiesXml`. The serializer replays that string inside the
`<w:document>` body, whose root only declares the canonical `w` /
`w14` / `w15` prefixes — so the saved DOCX would carry an unresolved
`xmlns:ns0` and Word refuses to open it.

Normalize at capture: if the source `<*:sdtPr>` (or `<*:sdtEndPr>`)
uses any prefix other than `w`, rewrite element-name, end-tag, and
attribute-name occurrences of that prefix to `w:` in the stored raw
snippet. fast-xml-parser doesn't surface namespace URIs in
preserveOrder mode, but a non-`w` prefix on an SDT property element
is itself evidence the source has it bound to the WP URI (the doc
would otherwise be invalid). Other URIs (w14, w15) are left intact
since callers seldom rebind them.

Regression test pins the alt-prefix capture rewriting `<ns0:sdtPr>` /
`<ns0:tag>` to canonical `<w:sdtPr>` / `<w:tag>` in
rawPropertiesXml.

* fix(folio): preserve intentionally empty dropdownLastValue on serialize

Codex P2 (PR #587): the extractor gated the modeled value on
`modeled !== undefined && modeled.length > 0`, which treated a
legitimate `dropdownLastValue: ""` as absent and silently fell
through to body-text matching. When two list items share a
displayText and the selected one has an empty `w:value`,
the wrong sibling's value got serialized.

`""` is a valid OOXML `w:value`: a producer can author
`<w:listItem w:value=""/>` and `setContentControlValue` will pick
it. Drop the length check so only `undefined` means "no modeled
selection." Regression test pins the collision case.

* fix(folio): 2 codex P2 findings — headless pmPos refusal + child-prefix normalize

1. controlMatchesFilter / matches (headless) ignored filter.pmPos, so
   a stray pmPos-only filter on a headless `setContentControlValue(doc,
   { pmPos }, ...)` / setContentControlContent / removeContentControl
   silently matched every blockSdt and mutated them all. The headless
   `BlockSdt` model has no PM positions; refuse pmPos-only filters in
   both matchers so the mutation no-ops on the unsatisfiable filter.

2. The pass-19 prefix normalization only canonicalized the captured
   `<*:sdtPr>` wrapper. A canonical `<w:sdtPr>` whose CHILDREN inherit
   alt prefixes from the source document root (`<x:checkbox>` /
   `<y:repeatingSection>`) still stored raw XML with undefined
   namespace prefixes; the saved DOCX would be invalid and Word would
   refuse it. Extended normalizeWordPrefix with
   normalizeChildrenForLocalNames: any element whose local name is in
   the known w14 / w15 child set (checkbox, checked,
   repeatingSection[Item], color, appearance) gets re-emitted under
   the canonical prefix regardless of source prefix, and attributes on
   those elements are rewritten the same way.

Regression tests pin the pmPos-only refusal on the headless matcher
and the alt-prefix w14 / w15 child normalization on raw capture.

* fix(folio): scope w15 normalization + handle nested-only-child remove

Two codex P2 findings on pass 21:

1. Adding `color` and `appearance` to W15_LOCAL_NAMES caused
   `normalizeChildrenForLocalNames` to silently rewrite a placeholder
   `<w:color w:val="…"/>` inside `<w:rPr>` to `<w15:color …/>` — that
   element name is shared between run formatting and the w15 SDT
   appearance namespace, and we have no way to tell them apart by
   local name alone. Restrict W15_LOCAL_NAMES to truly w15-only
   element names (`repeatingSection`, `repeatingSectionItem`) so run
   colors round-trip cleanly. Same applies to `appearance`.

2. removeContentControlTr only substituted an empty placeholder when
   the target was the doc's sole top-level child. A nested blockSdt
   that was the only child of an outer blockSdt got bare-deleted,
   leaving the outer with zero children — failing the `content:
   "block+"` schema. Substitute whenever the resolved parent's
   childCount is 1, matching the doc-root case and the headless
   `removeContentControl` guard.

Regression tests pin both: the `<w:color>`-in-rPr survival and the
nested-only-child remove.

* fix(folio): normalize alt-prefix w-namespace SDT children too

Third variant of the prefix-tolerance story for sdtPr raw capture
(codex P2, PR #587):

- Pass 19 rewrote the wrapper prefix (`<ns0:sdtPr>` → `<w:sdtPr>`).
- Pass 21 rewrote w14 / w15 child element names (checkbox /
  repeatingSection) so canonical-wrapper docs with alt-prefix
  Microsoft-namespace children stayed valid.
- This pass handles the same case for the WordprocessingML (w:) SDT
  property children themselves: a canonical `<w:sdtPr>` whose
  `<x:tag>` / `<x:alias>` / `<x:date>` etc. inherit a non-`w` prefix
  from the source document root would still serialize undefined
  prefixes and Word would reject the file.

Added a `W_LOCAL_NAMES` set covering SDT property children that the
spec defines only in the w: namespace (alias, tag, id, lock,
placeholder, docPart, showingPlcHdr, the SDT-type marker elements,
and the date-localisation siblings). Run-formatting children inside
nested `<w:rPr>` are intentionally not in this list because their
local names overlap with theme / appearance and we have no way to
disambiguate by local name alone (same reason `color` was removed
from W15 in pass 22).

Regression test pins a canonical `<w:sdtPr>` wrapper with `<x:tag>` /
`<x:alias>` children rewriting to `<w:tag>` / `<w:alias>` in
rawPropertiesXml.

* fix(folio): read OOXML attrs by local-name suffix as a final fallback

Codex P2 (PR #587): when a producer binds the WordprocessingML
namespace under TWO prefixes and uses the canonical `w:` for an
element but the alt prefix for one of its attributes
(`<w:tag x:val="client"/>`, `<w:listItem x:displayText="A" x:value="a"/>`),
the modeled read missed the attribute entirely. The earlier
wrapper-prefix path didn't trigger (the wrapper IS canonical), the
canonical-prefix path returned null (the attribute key is `x:val`,
not `w:val`), and the rawPropertiesXml normalizer rewrote the
attribute on save but `props.tag` / `props.listItems` / etc. stayed
empty — `getContentControls({ tag })`, dropdown widget population,
and listItem validation all silently failed on these controls.

Add a final local-name suffix scan to both `getAttributeAnyPrefix`
and `parseBooleanElement`: if neither the element's own prefix nor
the canonical prefix matched, iterate the attribute map and return
the first entry whose key is the bare local name or ends with
`:localName`. Order is preserved as a deterministic last resort.

Regression tests pin both the SDT property attr case (tag, alias)
and the dropdown listItem attrs case.

* fix(folio): skip xmlns + tighten attr-rewrite boundary in raw normalizer

Codex P2 (PR #587), and a deeper bug surfaced while writing the
regression test for it:

1. The `attrRe` negative lookahead in `normalizeChildrenForLocalNames`
   didn't exclude `xmlns:*`, so a locally-declared alt-prefix
   (`<x:tag xmlns:x="…" x:val="…"/>`) had its namespace declaration
   rewritten to a bogus `<w:tag w:x="…">` attribute on save. Add
   `(?!xmlns:)` to the lookahead.

2. The regex also relied on a bare `\b` for the prefix boundary, which
   matches inside attribute VALUES too — `<w:tag xmlns:x="http://…"
   x:val="…"/>` would have the `http:` substring inside the URI
   rewritten to `w:`, silently turning the namespace URI into
   `"w://schemas.openxmlformats.org/…"`. Require a whitespace anchor
   before the candidate prefix instead, so only real attribute-name
   prefixes (which always follow whitespace at this position) match.

Regression test pins both: the xmlns:x declaration survives intact,
no bogus `w:x=` attribute is introduced, and the modeled `x:val`
attribute still rewrites to canonical `w:val`.

* fix(folio): declare w16* namespaces needed by raw-replay SDT XML

Codex P2 (PR #587): the parser preserves unmodeled OOXML extension
children verbatim through `rawPropertiesXml` / `rawEndPropertiesXml`,
which includes the w16-family namespaces (w16sdtdh, w16cex, w16cid,
w16, w16se) — w16sdtdh:dataHash is the obvious case Word emits inside
modern `<w:sdtPr>` controls. The `NAMESPACES` lookup in the document
serializer already knows these URIs, but `buildNamespaceDeclarations`
emitted only a "minimal" subset, so the replayed XML carried
undeclared prefixes and Word refused the saved file.

Add every w16* prefix the serializer otherwise knows about to the
root-emitted set in both `documentSerializer` and
`headerFooterSerializer` (the latter's NAMESPACES table was missing
the URIs entirely; mirror the document serializer's list there too).
The extra declarations cost a few bytes on the document root but
make every raw-replay path safe.

Regression test pins every w16 prefix on the document root.

* fix(folio): drop synthetic empty-SDT filler paragraph on save

Codex P2 (PR #587): toProseDoc inserts a synthetic empty paragraph
into any blockSdt whose source had an empty `<w:sdtContent/>` because
the PM `block+` schema requires at least one child. Without a guard,
fromProseDoc round-tripped that filler back into the model and the
serializer wrote `<w:sdtContent><w:p/></w:sdtContent>` on save —
silently adding a paragraph Word did not emit.

convertPMBlockSdt now post-processes its extracted children: if the
result is exactly one paragraph with no inline content and no
significant attrs (formatting, original-formatting, section props,
property changes, mark changes), emit `content: []` instead. A user
who types into the filler or styles it bails the heuristic and the
paragraph is preserved verbatim.

Regression test pins the round-trip: source `<w:sdtContent/>` → PM
with synthetic filler → recovered model with empty content again,
and a second toProseDoc reaches the same PM shape (idempotent).

* fix(folio): use correct Paragraph field names in synthetic-filler check

Pre-push typecheck rejected the previous commit because the empty-SDT
filler guard referenced PM-side attribute names (_originalFormatting,
_sectionProperties, _propertyChanges, _paragraphMarkChange) that do
not exist on the docx-core Paragraph type. The corresponding model
fields are formatting, sectionProperties, propertyChanges, and
pPrMark. Dropped _originalFormatting (PM-only) and renamed the others
to match.

* fix(folio): mark synthetic SDT filler with explicit attr, not shape heuristic

Codex P2 follow-up (PR #587): the pass-27 shape-only check dropped
*any* "single empty unformatted paragraph" child of a blockSdt on
save. That correctly fixed the original case (empty `<w:sdtContent/>`
+ synthetic filler accreted into `<w:p/>` on every round trip) but
also broke the case the codex follow-up flagged: a source DOCX with
an authored `<w:sdtContent><w:p/></w:sdtContent>` looks identical in
PM and lost the authored paragraph on save — intentional blank space
disappeared from legal templates.

Add an explicit `_originallyEmpty` boolean attribute on the blockSdt
PM node. `toProseDoc` sets it true ONLY when the source content was
empty (and inserts the synthetic filler). `fromProseDoc` drops the
filler only when the attr is true AND the body still matches the
synthetic shape. Authored single-`<w:p/>` content has no marker and
round-trips intact. Plumbing through:

- BlockSdtExtension PM schema attr (default false) + DOM round-trip
- BlockSdtAttrs type + attrs validator (optionalBoolean)
- toProseDoc.convertBlockSdt sets `_originallyEmpty: blockSdt.content
  .length === 0`
- fromProseDoc.convertPMBlockSdt branches on the attr

Regression tests pin both: empty `<w:sdtContent/>` → empty content,
authored `<w:p/>` → preserved paragraph.

* fix(folio): preserve sdt-sibling range markers per MS-OE376 §2.5.2.30

ECMA-376 says only paragraphs and tables can be children of a
block-level SDT. Microsoft's implementer note for §2.5.2.30 documents
that Word also emits 16 range-marker elements as direct siblings of
`<w:sdtContent>` inside `<w:sdt>`: bookmark start/end, comment range
start/end, both tracked-change range pairs, and all four custom XML
range pairs. These appear in real DOCX files whenever a comment
thread, tracked change, or bookmark crosses an SDT boundary.

The block parser only descended into `<w:sdtContent>`, so any such
sibling marker was silently dropped on parse. A round trip then wrote
one delimiter without its pair, breaking the comment thread or
tracked-change pair in the saved DOCX.

Capture the verbatim XML for non-content sdt direct children at parse
time, split into `rawSdtChildrenBeforeContent` and
`rawSdtChildrenAfterContent` so position is preserved. Replay them
in the serializer in their original positions relative to
`<w:sdtContent>`. Threaded through SdtProperties (docx-core),
BlockSdtAttrs + attrs validator + BlockSdtExtension PM schema, and
both PM converters so the markers survive the PM round trip too.

Regression test in blockSdtSerializer.test pins the
bookmark-start-before + bookmark-end-after layout with a
commentRange wrapping it for good measure.

Source: [MS-OE376
§2.5.2.30](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/1aee8ae1-fe3a-4ca4-96f4-e416cf43e461).

* feat(folio): refuse content writes on bound SDTs (ContentControlBoundError)

When a producer ships `<w:sdtPr><w:dataBinding w:xpath="…"/></w:sdtPr>`,
Word regenerates the SDT body from the bound custom-XML store on every
open. A direct write to `<w:t>` is silently overwritten and the
caller's edit disappears.

Aspose.Words handles this by requiring an explicit `XmlMapping.Delete()`
before write. Mirror that pattern with a new tagged error
`ContentControlBoundError`. Thrown by `setContentControlContent` and
`setContentControlValue` (both headless and PM-tx paths) when the
target SDT has a binding. Caller can:
- pass `{ force: true }` — folio strips `<w:dataBinding>` from
  rawPropertiesXml in the same transaction (the edit will stick on
  reopen);
- catch the error and route to the customXml store directly;
- or strip the binding via a separate API call before retrying.

Sources: python-docx #965 (the user-side symptom), Aspose forum
"Remove Custom XML Part Data Binding or Mapping" thread (the
established precedent).

Threaded through:
- `ContentControlBoundError` in `content-controls/errors.ts`
- public re-export from `content-controls/index.ts`
- `ensureContentNotBound` helper in headless mutate path; xpath +
  storeItemID extracted from the captured raw XML for the error
  payload
- mirror helper in `prosemirror/commands/contentControls.ts` PM-tx
  path; the stripped raw XML is threaded into the existing
  `propertyOverrides` of `replaceBlockSdtChildren` so the bypass
  path persists the binding-free rawPropertiesXml on the new node

Regression tests pin both: thrown without force on a bound control,
force succeeds + the saved raw XML has the binding removed while
sibling sdtPr children (w:tag) survive.
…ies (#599)

* fix(folio): preserve block-level SDTs inside footnote and endnote bodies

`parseNoteBlockContent` only recognised `<w:p>` and `<w:tbl>` as
children of a `<w:footnote>` / `<w:endnote>`, so any block-level
`<w:sdt>` inside a note body — citation slots, bound metadata, the
repeating-section markers legal templates put around evidence
citations — was silently dropped on parse. `getContentControls` and
the headless SDT mutate API then couldn't see those controls at all.

Recognise the `<w:sdt>` element directly in the note-body walker and
emit a BlockSdt node with the same `sdtPr` / `sdtEndPr` raw replay
treatment the main body uses. Widen `Footnote.content` and
`Endnote.content` to `(Paragraph | Table | BlockSdt)[]` to match.

Regression tests pin both the structural recovery (paragraph,
blockSdt, paragraph in order) and the dataBinding round-trip via
rawPropertiesXml for the bound case.

Picked up from upstream eigenpal/docx-editor#678.

* fix(folio): recurse into BlockSdt in getFootnoteText / getEndnoteText

The previous commit added BlockSdt as a possible child of footnote /
endnote bodies, but the text-extraction helpers `getFootnoteText` and
`getEndnoteText` only walked direct paragraph children. A citation
slot in a note then silently hid its inner paragraphs from text
extraction.

Refactor both helpers to share a recursive `collectNoteBlockTexts`
walker that descends into BlockSdt children. Pre-existing limitation
(tables not surfaced) is left as-is — separate scope.

Addresses gemini-code-assist review on PR #599.

* feat(folio): walk notes from findContentControls + setContentControl* mutators

After the previous commits recognised `<w:sdt>` inside footnote and
endnote bodies, the headless API still couldn't see those SDTs:
`findContentControls` walked only `doc.package.document.content`, and
`setContentControlContent` / `setContentControlValue` /
`removeContentControl` mutated only the main body. Citation slots in
notes parsed correctly but were unreachable by tag / alias / id.

Extend the walkers to also iterate `doc.package.footnotes` and
`doc.package.endnotes`. New `ContentControlMatch.location` field
carries `{ kind: "body" }`, `{ kind: "footnote", noteId }`, or
`{ kind: "endnote", noteId }` so callers can tell where a match
lives — useful for audit trails, navigation, and routing scoped
mutate APIs in future work.

The three setters share a common pattern now: build the match
callback once, transform the main body, transform every note body,
return a Document carrying all three updated arrays.

Regression tests pin both ends: findContentControls returns matches
from notes with the right location tags, and setContentControlContent
mutates a citation slot inside a footnote and the change shows up via
getFootnoteText.

Addresses chatgpt-codex-connector P2 review on PR #599.

* docs(folio): note save-path follow-up scope for note-SDT mutators

`setContentControlContent` (and its siblings) now thread mutations into
note bodies, but `repackDocxFromRaw` replays `word/footnotes.xml` /
`word/endnotes.xml` verbatim from the source zip. So in-session reads
see the update, but the saved DOCX still contains the original note
text. Document the limitation on the public API surface so callers
know to expect the gap until the selective-save layer learns to
re-serialize note parts.

Addresses chatgpt-codex-connector P2 follow-up on PR #599.

* fix(folio): accept note SDTs in footnote layout
* feat(folio): detect VML and DrawingML watermarks in header parts

Word emits document watermarks behind page content as either VML
WordArt (`<v:shape type="#_x0000_t136"><v:textpath string="…"/>`),
VML picture shapes (`<v:imagedata r:id="…"/>`), or — in modern
Office builds and producers like Aspose — DrawingML behind-content
anchors (`<wp:anchor behindDoc="1">`). Folio previously dropped all
three at the run level (VML and DrawingML are not modeled inline),
so callers couldn't see or render a doc's watermark.

Add a structural watermark walker that runs after `parseHeader`'s
block-content extraction and detects the first watermark shape in
the header. Modeled as `HeaderFooter.watermark: Watermark` with two
variants: text (string, font, color, diagonal) and picture
(image relationship id). Wired through docx-core's public type
exports.

The DrawingML detection is a folio improvement over upstream
eigenpal/docx-editor#679, which covers only VML. Modern Word builds
and several third-party generators emit DrawingML watermarks; folio
recovers both shapes so templates from those tools round-trip.

This PR is phase 1 of the watermark port: model + parser + regression
tests. Follow-up phases: serializer, layout/painter, headless API,
PM bridge, editor UI.

Regression tests cover four cases: diagonal VML text watermark,
horizontal VML text watermark, VML picture watermark, DrawingML text
watermark, plus the watermark-absent baseline.

* fix(folio): watermark walker validates IDs and stops shadowing real shapes

Three connected gaps in the initial parser:

1. `findVmlWatermarkShape` returned the first VML shape with a textpath
   or imagedata. A header carrying both a logo image and a real text
   watermark would return the logo (with no textpath) — wait, the logo
   has imagedata, so it'd be promoted as a picture watermark and the
   real text watermark would never be checked.

2. `findDrawingMlWatermarkAnchor` returned the first behindDoc anchor.
   A header with a behind-content background image followed by a real
   DrawingML watermark would stop at the background and miss the
   watermark.

3. Picture watermark detection accepted any VML shape carrying
   `<v:imagedata>`. A company logo dropped into the header from the
   ribbon would round-trip on save as a full-page behind-content
   image — silently scaling the logo to a watermark.

Walk every candidate shape / anchor instead of returning the first
hit, and require Word's `WordPictureWatermark…` shape id for picture
watermarks. Text watermarks already gate on the WordArt shapetype
`#_x0000_t136`, which separates them cleanly from decorative VML.

Three new regression tests pin the logo-not-a-watermark case, the
logo-doesn't-shadow case, and the DrawingML background-doesn't-shadow
case.

Addresses gemini-code-assist review threads 1-3 on PR #600.

* fix(folio): correct stripQuotes for comma-fallback fonts + handle padded colons

Two bugs in the inline-style helpers:

1. `stripQuotes('Calibri','sans-serif')` returned `Calibri','sans-serif`.
   The whole string starts and ends with `'`, so the matched-quotes
   guard fired before the comma split. Peel the comma list FIRST, then
   strip quotes from the primary family.

2. `parseInlineStyle` matched the literal prefix `"${key}:"`. Producers
   that emit padded colons (`rotation : 315`, `font-family : Calibri`)
   slipped through. Split each declaration at the first colon and
   compare the trimmed left side to the key.

Regression tests pin both: a font-family list with quoted primary +
fallback resolves to the primary name, and a style with spaces around
the colon still extracts the rotation and font.

Addresses gemini-code-assist review threads 4-5 on PR #600.

* feat(folio): emit watermark on header serialize (round-trip + synthesis)

The previous commits added watermark detection on the parser side but
the header serializer still ignored `hf.watermark`. Closes the
round-trip gap codex flagged on PR #600: a parsed DOCX now serializes
the watermark back into the header, and a programmatically-constructed
`HeaderFooter` with a `watermark` field gets a fresh VML paragraph
matching what Word emits from its own Insert → Watermark UI.

Two emission paths:

1. **Raw replay.** `parseHeader` now captures the watermark-hosting
   paragraph's verbatim XML into `HeaderFooter.rawWatermarkXml` and
   detaches it from `content` (otherwise the run-level VML is dropped
   by `runParser` and the body parser would emit an empty placeholder
   paragraph in its place). The serializer splices the raw paragraph
   back as the first child of the header element so byte-exact round
   trip is preserved.

2. **Synthesis.** When `rawWatermarkXml` is absent but `watermark` is
   set (the future `setDocumentWatermark` path), `synthesizeWatermarkParagraph`
   emits a VML text or picture watermark matching the layout Word uses
   for its own watermarks (shapetype #_x0000_t136 for text, #_x0000_t75
   for picture; `PowerPlusWaterMarkObject…` / `WordPictureWatermark…`
   ids so a follow-up parse identifies the synthesised watermark via
   the same id-prefix guards the previous commits added).

Regression tests cover: raw VML round-trip preserves byte content,
synthesized text watermark with all model fields, synthesized picture
watermark, XML escaping for watermark text containing metacharacters,
empty-watermark baseline, and a non-duplication check confirming the
watermark paragraph is not emitted twice when the source carried both
the watermark and additional body content.

* feat(folio): headless watermark API (get/set across header parts)

Adds `getDocumentWatermark` and `setDocumentWatermark` to
`packages/folio/src/core/watermark/`, mirroring the `content-controls`
module's immutable getter/setter shape:

- Getter walks every header part and returns the first watermark found.
- Setter writes the model to every header part and clears any captured
  `rawWatermarkXml` so the serializer's synthesis path takes over.
- Throws on header-less documents — synthesizing a default header
  touches section relationships, content-type registration, and the
  rezip layer, which belongs in a dedicated header-creation helper.

8 unit tests cover the getter (first-match, miss, no-headers), the
setter (write/clear/multi-part), raw-VML invalidation, and input
immutability.

* feat(folio): paint watermark layer behind page content

Adds `renderWatermarkLayer` next to the other layout-painter modules
and wires it into `renderPage` via two new `RenderPageOptions` fields:

- `watermark` — the modeled `Watermark` (callers resolve via
  `getDocumentWatermark(doc)` and pass the same value to every page).
- `watermarkImageSrc` — pre-resolved asset URL for picture watermarks;
  the painter cannot resolve `imageRId` itself because relationship-id
  → asset URL belongs to the package layer. A picture watermark without
  a resolved src is silently skipped.

The layer is absolutely-positioned, page-sized, `pointer-events: none`,
and sits at `z-index: -1` so text, floating images, and "back" page
borders all draw above it (matching Word's stack). Text watermarks use
`textContent` (never `innerHTML`) and a CSS `rotate(-45deg)` for the
default diagonal preset; `diagonal: false` keeps text horizontal. The
default silver color, washout opacity, and font fallback chain mirror
what Word's "Insert → Watermark" UI emits.

9 unit tests cover both watermark kinds, scale/washout/color/opacity
overrides, the missing-src skip path, and the textContent XSS-safety
invariant. All 165 layout-painter tests still pass.

* fix(folio): respect exactOptionalPropertyTypes when forwarding watermark image src

The painter wrapper was constructing { imageSrc: options.watermarkImageSrc }
unconditionally, which tsc rejects under exactOptionalPropertyTypes:
RenderWatermarkOptions declares imageSrc?: string (not string | undefined).
Spread only when the value is defined.

* fix(folio): address watermark P1/P2 review findings

- Declare DrawingML namespaces (`a`, `pic`) on the header serializer
  root. Without them, a captured DrawingML watermark's `rawWatermarkXml`
  (containing `a:graphic` / `a:txBody` / `pic:pic`) saves with an
  unbound prefix and breaks the modern-watermark round-trip path.
- Include `watermark` and `watermarkImageSrc` in `computeOptionsHash`.
  Virtualized documents (8+ pages) keep already-rendered page shells
  when the watermark changes, and `repopulatePageContent` only refreshes
  the content area — without the fingerprint, the old watermark sibling
  stays attached.
- Lift the watermark layer's `z-index` from -1 → 0 so it stays above the
  parent page's painted background. Negative z-index on a positioned
  child paints behind the parent's bg layer (the behindDoc image path
  in renderPage already rewrites -1 → 0 for this same reason). Content
  paints above via DOM order, not z-index.

* fix(folio): guard picture watermarks against multi-header rId scope

Picture watermarks reference `imageRId` scoped to each header part's own
`word/_rels/header*.xml.rels`. The previous setter copied the same rId
into every header part, which would produce `<v:imagedata r:id="..."/>`
entries with no matching relationship in non-source headers and break
the saved DOCX. Cross-header rId cloning belongs in the package layer;
this headless API now throws with a clear message for the picture +
multi-header case. Text watermarks remain unconstrained.

* fix(folio): handle documented watermark color:auto + scale factor

- `TextWatermark.color = "auto"` is a documented model value meaning
  "use the producer default". Both the serializer and the painter were
  prefixing it with `#` and producing invalid VML `fillcolor="#auto"`
  and invalid CSS `color: "#auto"`. Map `auto` to Word's silver default
  (`#C0C0C0`) before applying.
- `PictureWatermark.scale` is documented as a factor (1.0 = native,
  0.5 = half-size). The painter was using the factor as a raw CSS
  percentage, so a model-conformant `scale: 1` produced a 1%-of-page
  thumbnail. Multiply by 100 before emitting; nil scale defaults to
  100% (native).

* fix(folio): three watermark-parser robustness gaps

- VML fillcolor `"auto"` is now preserved lowercase. The model documents
  the lowercase `"auto"` sentinel and the renderer/serializer special-
  case the exact lowercase form when mapping back to Word's silver
  default; the previous uppercase normalization round-tripped as the
  invalid CSS color `#AUTO`.
- DrawingML `behindDoc` now accepts both xsd:boolean serializations.
  ECMA-376 allows `"1"` (Word's default) and `"true"` (the equally
  valid textual form some producers emit) — only matching the numeric
  form silently dropped modern watermarks.
- Refuse to detach a paragraph that mixes the watermark with sibling
  body text (`w:t`) or another shape. Surgically removing just the
  shape is out of scope; instead the parser leaves the original
  paragraph in `content` so it round-trips through the regular block
  parser with all sibling content intact. `setDocumentWatermark` no
  longer drops the rest of the paragraph when callers later mutate
  the watermark. Adjusted the two shadow-prevention regression tests
  to put the noise shape in a sibling paragraph (still exercising
  "logo/background does not shadow a later watermark").

* feat(folio): round-trip watermark opacity + position; wire editor

Three related improvements in response to bot review:

- **Wire the editor.** `PagedEditor` now calls `getDocumentWatermark` on
  the parsed `Document` and threads the result into `renderOpts`, so a
  DOCX opened in the editor actually paints its watermark. Picture
  watermarks still need an image-rId → asset URL resolver; until that
  lands the painter silently skips them.
- **Preserve watermark position on raw replay.** The parser now records
  the original `watermarkBlockIndex` (position of the host paragraph
  among block-level siblings). The serializer splices the replayed or
  synthesized watermark XML back at that index instead of always
  emitting it as the first child, so a header with visible text before
  a watermark-only paragraph round-trips with the same flow.
  `setDocumentWatermark` preserves the index across text/picture
  mutations and clears it when the watermark is removed.
- **Round-trip text watermark opacity.** Synthesis now emits
  `<v:fill opacity="…"/>` (decimal form) inside the VML shape when the
  model carries an explicit opacity. The parser reads both the decimal
  and the fixed-point ("32768f") encodings Word accepts. Without an
  explicit opacity, the synthesizer omits the fill child so the saved
  DOCX matches what Word's UI emits at the default transparency.

* fix(folio): per-section watermark scoping + parser/serializer gaps

Three review fixes from the c6576ff56 round:

- **Per-section watermark scoping.** A document with titlePg, even/odd,
  or section-scoped headers can carry a different watermark on each
  header part. The painter now accepts a `watermarkByHeaderRId` map and
  `applySectionHeaderFooterOptions` resolves the active header's
  watermark per page; the global `watermark` stays as the single-header
  default. `PagedEditor` builds the map from `document.package.headers`.
  Added the map to `computeOptionsHash` so virtualized incremental
  updates invalidate correctly when any per-rId watermark changes.
- **Mixed-paragraph guard handles foreign WordprocessingML prefixes.**
  The earlier guard checked literal `w:t` / `w:pict` / `w:drawing`, but
  the rest of the parser accepts any namespace prefix. The guard now
  matches on local name and excludes the `a:` / `pic:` DrawingML
  namespaces (which carry the watermark's own caption, not body text).
- **Picture watermark scale on synth.** Picture watermark synthesis was
  hardcoded to 415pt × 207pt regardless of `scale`. Multiply both
  dimensions by the model's `scale` factor so a programmatically-set
  `scale: 0.5` saves at 207.5pt × 103.5pt and reopens at half-size in
  Word.

* fix(folio): clear fallback watermark + preserve source xmlns prefixes

Two review fixes from the fe13ac80b round:

- **Clear fallback watermark on watermark-less header.** When
  `watermarkByHeaderRId` is present but a page has no active header
  rId (e.g. titlePg first page without `headerFirst`), the
  page-options object inherited the global `watermark`. The painter
  now treats the per-rId map as authoritative: any time the map is
  present, we either set the per-page watermark from the map or
  explicitly clear it so the default header's watermark doesn't bleed
  through.
- **Preserve source xmlns declarations on raw replay.** A DOCX that
  binds an extension namespace to a non-canonical prefix on the
  header root (e.g. `xmlns:d` for DrawingML core) would lose that
  declaration when only the host paragraph is captured. The serializer
  root declares the canonical prefixes (`a`, `pic`), so a replayed
  `d:graphic` came out with an unbound prefix. The parser now copies
  every `xmlns:*` declaration from the source header onto the captured
  paragraph so the raw XML stays self-contained regardless of the
  source's prefix choice.

Also tightened `isWatermarkOnlyParagraph`: instead of pattern-matching
known namespace prefixes (which broke for foreign DrawingML prefixes),
the walk now stops descending when it enters a shape container
(`w:pict` / `w:drawing`). The watermark's own caption text never
leaves the shape, so it can't be misclassified as body text regardless
of prefix.

* fix(folio): preserve watermark edge cases

* fix(folio): refresh incremental watermarks

* fix(folio): encode picture watermark washout
…elity (#608)

* fix(folio): preserve picture watermark aspect ratio on re-synthesis

A picture watermark was always synthesized into Word's default 415x207pt
box scaled uniformly by `scale`. Re-applying a watermark clears
`rawWatermarkXml` and forces synthesis, so a non-2:1 source image was
stretched to 2:1 when the saved file was opened in Word.

Capture the shape's real width/height (pt) from the VML style at parse and
re-emit them on synthesis, falling back to the default box * scale only when
no dimensions are modeled. Unlike `scale` (uniform-only), the dimensions are
kept even when the source is far from 2:1.

eigenpal/docx-editor#684 (OOXML watermark fidelity), adapted to folio's
pt-based VML pipeline.

* fix(folio): materialize new header/footer parts on save

A header/footer created in memory is keyed in package.headers/footers by an
rId with no relationship in document.xml.rels. The full-repack path dropped it
(collectHeaderFooterUpdates skips unresolvable rIds) and the selective fast
path could not register the new part, so an added header silently vanished on
save.

Promote such entries to real parts before serialization: mint word/headerN.xml
(or footerN.xml), add a document relationship under the existing rId (a valid
NCName, so section headerReferences keep resolving without rewriting
document.xml), and a [Content_Types] Override. Selective save now bails to full
repack when an unmaterialized header/footer is present.

Prerequisite for watermark header coverage (eigenpal/docx-editor#684); also a
standalone fix for the editor's add-header flow.

* feat(folio): rebind picture watermark relationships per header

setDocumentWatermark threw when a picture watermark spanned more than one
header part, because a picture's imageRId is local to each header's own rels.
Remove that limitation: clone the watermark per header, and at save time rebind
each header's imageRId to a relationship that resolves in that header's rels —
keeping it when it already resolves, otherwise reusing an existing relationship
to the same media target or minting one. The image bytes are shared (no
duplicate media part). Selective save bails to full repack when a multi-header
picture watermark needs rebinding.

eigenpal/docx-editor#684 (OOXML watermark fidelity).

* fix(folio): parse w:evenAndOddHeaders from settings.xml

w:evenAndOddHeaders lives in settings.xml (a document-wide flag), but folio
only read it from sectPr, so even/odd mode in real Word documents went
undetected. Parse it into DocumentSettings so consumers (watermark header
coverage) can tell when a distinct even header applies.

* feat(folio): extend watermark coverage to missing header parts

setDocumentWatermark wrote only to existing header parts and threw when a
document had none, so a watermark was absent from cover pages (titlePg without
a first-page header), even pages (evenAndOddHeaders without an even header),
and header-less documents.

Add ensureWatermarkHeaderCoverage: create the default/first/even header a
section needs but lacks, carrying the watermark. A typed header is created only
when no instance of that type exists anywhere, preserving Word's
section-to-section header inheritance. Created headers use synthetic rIds that
the materialization pass promotes to real parts on save; picture-watermark rIds
are rebound per header. Removes the no-header throw.

eigenpal/docx-editor#684 (OOXML watermark fidelity).

* fix(folio): harden watermark rebind against review findings

- rebindWatermarkRelIds now reads every header's rels, not only the headers
  being rebound, so the canonical image target is found even when the source
  header is raw-replayed (not pending). document.xml.rels is deliberately
  excluded: header rIds and body rIds both start at rId1, so scanning it could
  rebind a watermark to a body image that merely shares the rId number.
- hasModelDrivenPictureWatermark bails the selective fast-path for any
  model-driven picture watermark regardless of header count, since a
  single-header rId may not yet resolve in that header's rels.

* fix(folio): materialize header parts before image processing; forward header inheritance

- Move materializeNewHeaderFooterParts ahead of processNewImages in both repack
  paths so collectImageParts can see a newly created header/footer; otherwise an
  image inserted into a new header saved with a dangling rId and no media part.
- ensureWatermarkHeaderCoverage now walks sections in document order and only
  treats a typed header as inheritable when it or a preceding section provides
  one (Word inherits forward only). A global "exists anywhere" check left an
  earlier titlePg/even section uncovered when a later section defined its own
  typed header.

* fix(folio): correct watermark rebind/coverage edge cases from review

- Rebind verifies a header's local picture rId resolves to the *canonical*
  watermark media before skipping; header rIds repeat across parts, so a local
  rId pointing at a different image must still be rebound (else the header
  renders its own unrelated image).
- Coverage runs the forward-inheritance walk for the default header
  unconditionally, so a section that precedes the first existing default header
  (which cannot inherit backward in Word) is still covered.
- Coverage rIds are minted against the live relationship/header/footer ids
  rather than fixed globals, so a producer-chosen id cannot collide and produce
  a duplicate relationship.

* fix(folio): anchor watermark rebind to source media; re-key colliding header ids

- Capture a picture watermark's media target at parse (PictureWatermark.imageTarget,
  resolved against the source header's own rels). The cross-header rebind now
  anchors to that stable target instead of a first-match rId scan, which could
  pick an unrelated image when header rIds repeat across parts.
- materializeNewHeaderFooterParts mints a fresh rId and re-points the section
  references when the requested id is already taken by an unrelated relationship,
  instead of appending a duplicate <Relationship Id>.

* fix(folio): materialize new header parts on the raw repack path too

repackDocxFromRaw called collectImageParts/processNewImages without first
materializing in-memory header/footer parts (the earlier reorder only updated
repackDocx), so saving a new header with an inserted image through the raw path
left a dangling image rId and no media part. Materialize before image
processing in both paths; regression test covers repackDocxFromRaw.

* fix(folio): compute header/footer rels path for subdirectory parts

A part at <dir>/<name> keeps its relationships at <dir>/_rels/<name>.rels, but
the header rels-path helper (and collectImageParts) flattened a subdirectory
target like headers/header1.xml to word/_rels/headers/header1.xml.rels, so an
image or rebound picture-watermark relationship was written to an unattached
rels file. Use a shared headerFooterRelsPath helper matching the parser's
getRelationshipsPathForPart convention.

* fix(folio): rebase watermark image targets for subdirectory headers

A relationship Target is relative to its owning part, so propagating a picture
watermark into a header in a subdirectory must rewrite the target relative to
that header (media/image1.png from a root header -> ../media/image1.png for
word/headers/header2.xml), or Word resolves it under the subdirectory and the
image goes missing. Capture the watermark's media as a package-absolute path at
parse (imageTarget), compare/reuse by absolute path during rebind, and emit a
target rebased to each header's own location.

Also short-circuit materializeNewHeaderFooterParts when nothing is unmaterialized
so the common repack skips the zip scans and relationship walk.

* fix(folio): part-relative image targets for subdirectory headers

processNewImages wrote inserted-image relationships as Target="media/imageN.png"
regardless of the owning part's location, so an image inserted into a header in
a subdirectory resolved to word/headers/media/... while the bytes live at
word/media/... Emit the target relative to the part (../media/...) via the same
relativeTargetForPart helper used by the watermark rebind. Root-level parts are
unchanged (media/...).

* fix(folio): mint header rId above all in-use ids when re-keying on collision

The re-key path seeded its rId counter only from the relationship map, so when
package.headers/footers also use numeric keys (e.g. a new header at rId1 that
collides with an unrelated relationship, plus another new header at rId2),
minting could land on rId2 and overwrite that entry — dropping one new part.
Seed the counter from the relationship map and the header/footer map keys so a
minted id never collides with another not-yet-materialized part.

* fix(folio): let a picture watermark scale edit override captured dimensions

After capturing widthPt/heightPt at parse, the serializer always used them, so
changing only `scale` (the documented resize knob) had no effect on save. Make
`scale` win when set — it is recorded only for uniform (2:1) watermarks where it
agrees with the captured size, and a caller's resize should take effect — and
fall back to the captured dimensions (which preserve a non-2:1 aspect) only when
scale is absent.

* fix(folio): preserve external (linked) picture watermark relationships

An external image relationship (TargetMode="External") has a URL target, not a
package path. Anchoring it as package-absolute media and rebinding it produced a
bogus internal Target="../https:/..." without TargetMode, breaking linked
picture watermarks on save. Skip external relationships in both imageTarget
anchoring (parser) and the rebind's target resolution, so they are left intact.

* fix(folio): propagate external (linked) picture watermarks across headers

The rebind now models a watermark's canonical image as either embedded media (a
package-absolute path) or an external (linked) URL, and writes the matching
relationship into each header: a part-relative Target for embedded media, or the
URL with TargetMode="External" for linked images. Previously a linked watermark
copied to sibling headers had no resolvable relationship there.

* fix(folio): anchor external picture-watermark source target at parse

An external (linked) watermark had no anchored target, so the rebind guessed
its URL by scanning header rels for the source rId — which, with per-part rId
collisions, could pick a different header's linked image. Capture the URL into
imageTarget (flagged via imageTargetExternal) at parse, mirroring the embedded
case, so propagation always uses the true source image.
…icker) (#740)

Port the custom-style numbering fixes from eigenpal/docx-editor#767 (upstream
issue 765) into folio. Three independent sub-fixes:

- Custom zero-padded numbering: Word emits 4-digit list numbering as
  <w:numFmt w:val="custom" w:format="0001, ..."> wrapped in
  <mc:AlternateContent>. The parser now resolves the Choice/Fallback and maps
  the pad width to synthetic decimalZero3/4/5 formats (carried through the
  existing NumberFormat plumbing, never serialized). A shared padDecimal
  consolidates the zero-pad family across the parser, layout bridge, and the
  block-content marker substitution.
- Style-attached numbering precedence + provenance: when a paragraph's numPr is
  inherited from its paragraph style, Word gives the style chain's indents
  precedence over the numbering level's. The parser records the numbering as
  style-sourced (numPrFromStyle) and gates the level-indent application per
  group; the serializer and PM round-trip drop the numPr while it still equals
  the style value, so it is never materialized as direct <w:numPr> (which would
  flip Word's precedence on reload). A numId=0 under a numbered style drops the
  style's hanging, matching Word.
- Toolbar style-picker projection: applying a numbered paragraph style now
  attaches its numbering + resolved marker attrs live, so the list renders
  immediately instead of after a reload.

Folio's richer list-resolution block (LISTNUM, marker offsets, isLgl, allCaps)
is kept intact; only the indent-precedence gate and provenance were added.
computeListRendering is a new picker-path helper, not an extraction of the
parser block.
* fix(web): correct overview deadline filter and dedupe time-entry popover keys

The next-deadline StatCard filtered tasks with status !== "closed", but no
"closed" status exists in TASK_STATUSES (open/in_progress/in_review/done/
cancelled), so the guard never excluded anything: a done/cancelled task with an
earlier (often past) due date could surface and open as the next deadline.
Filter against the real terminal statuses instead.

The per-day time-entry popover keyed rows on entry.description, which collapses
to a dash for entries with no narrative/taskCode, producing duplicate sibling
keys and dropped rows. Carry the time-entry id through dailyEntries and key on it.

* fix(case-law): stop ÚS Roman-numeral section regex matching empty/punctuation lines

SECTION_ROMAN_RE had an entirely optional body (X{0,3} plus a group that can
match the empty string), so it matched "", ".", and ". ". Inside classifyLines
that turned a stray punctuation-only line in the Odůvodnění zone into a bogus
level-3 heading and consumed the following real paragraph as its title. Add a
leading lookahead requiring at least one Roman-numeral character; all real
numerals (I, IV, V, IX, X, ..., XXXIX) still match.

* fix(business-registries): guard ARES formatMoney against non-numeric capital

Number(numeric) returns NaN for a non-numeric VR value rather than throwing, and
NaN.toLocaleString() yields the user-visible string 'NaN,- Kc'. The wrapping
try/catch was dead code (neither Number nor toLocaleString throws). Guard with
Number.isFinite and fall back to the raw value, dropping the unreachable catch.

* fix(business-registries): normalize ORSR deposit-map key to match lookup

buildSharesByName keyed the deposit map on the raw stakeholder reference string,
but parseStakeholders looks it up with pickPersonName(member), which returns a
normalizeName() result (collapsed whitespace, stripped wrapping quotes). When the
two strings differ the lookup misses and the shareholder's deposit/paid-up amount
is silently dropped. Normalize the map key the same way as the lookup key.

* fix(web): capture analytics on case-law open failures

The case-law open flow surfaced a toast but never called captureError, unlike the
sibling entity-open flow. A systematic breakage in opening cited decisions from
chat would be invisible in telemetry. Add the capture to match entity-open.

* fix(infosoud): correct next-hearing cancellation and same-day filtering

getNextHearingCaseEvent had two bugs, now covered by new tests:
- When a hearing detail was present it consulted only hearing.cancelled, which is
  null when the JED_ZRUS attribute is absent/unrecognised (!null === true), so a
  hearing cancelled at the event level (zruseno) but lacking JED_ZRUS was surfaced
  as upcoming. Fall back to event.zruseno.
- Date-only hearings resolve to UTC midnight, so a hearing scheduled for today was
  classified as past once now passed midnight. Keep date-only hearings selectable
  until the end of their calendar day while timed hearings keep instant-precision.

* fix(api): preserve total duration when splitting a time entry

The split handler rounded each non-last split independently with max(1, round(...))
and never clamped the running remainder, so skewed/small percentages made the
per-split durations sum to MORE than the original entry (e.g. 10 min split into
[91,1x9] produced 18 minutes), and each inflated split then rounded up to a full
billing increment, over-billing the client. The comment claimed the total was
preserved exactly; it was not.

Extract a pure largest-remainder apportioner (apportionSplitDurations) that sums
to the original total exactly, stays proportional, and lifts any zero-minute split
to one by borrowing from the largest. Unit-tested in split-durations.test.ts.

* fix(web): restore timer running-state detection

isRunning used `activeTimer?.timerStartedAt !== null`; when no timer exists
activeTimer is null so the optional chain yields undefined and `undefined !== null`
is true, leaving the controls permanently in the running state with the Start
button unreachable (Stop then 404s). Use a truthy check, matching the existing
guard on line 56.

* fix(api): reject deleting a billed time entry or expense

delete-by-id and expenses/delete wrote off every non-DRAFT status, including
BILLED. A billed entry is attached to an invoice, so flipping it to WRITTEN_OFF
left it linked to the (possibly sent/paid) invoice while the invoice total still
counted it, corrupting a finalized billing record. batch-delete already excludes
BILLED; reject it here too.

* fix(docx-core): strip XML-illegal control characters in escapeXml

escapeXml escaped the five entities but passed C0 control bytes (and other
XML 1.0 illegal code points) straight through. A single such byte leaking in from
a source document produced a DOCX that Word refuses to open. Drop anything outside
the XML 1.0 legal set (tab/LF/CR, #x20-#xD7FF, #xE000-#xFFFD, astral planes) before
escaping; unit-tested. (folio's xmlUtils.ts has the same gap - see handover.)

* fix(docx-core): keep pipe-table rows that omit the trailing pipe

parseTableBlock required every row to both start and end with '|', so a row like
'| Consent | Beta Ltd' (a common hand-written/AI variant) was filtered out before
parsing and silently dropped from the legal document with no diagnostic. A leading
pipe is enough to recognise a row; parsePipeRow already tolerates the missing
trailing pipe. Tested.

* fix(boe): escape query_string reserved characters in BOE search

Free text and titles were interpolated verbatim into the Elasticsearch query_string
DSL. A citation like '39/2015' was parsed as a regex (/ ... /) and errored or matched
nothing; parentheses/colons/brackets/operators similarly corrupted searches. Escape
the Lucene reserved set for free-text terms and backslash+quote for title phrases.
Tested.

* fix(api): reject circular clause-category reparenting

updateCategoryHandler only blocked self-parenting; it never walked the ancestor
chain, so PATCHing A's parent to its own descendant B created an A->B->A cycle that
makes the tree non-renderable and loops any ancestor-walking consumer. Port the
ancestor-walk guard the template category handler already has.

* chore(boe): keep buildSearchQuery doc adjacent and apply oxfmt

* fix(api): validate select fallback against options on property update

The create path rejects a single/multi-select fallback that is not one of the
options, but update-by-id only ran the structural content schema, so an
out-of-options fallback (e.g. 'Maybe' for a Yes/No property) was accepted and later
written as a field value, breaking chip rendering and grouping/filtering. Mirror
the create-path membership check.

* fix(docx-core): preserve title case and emit pPr/lvl children in schema order

- The cover title was force-uppercased with locale-blind toUpperCase, baking
  corrupt casing into the run text (e.g. Turkish dotted-i) and duplicating the
  Title style's allCaps. Store the original case and let allCaps render it.
- serializeParagraphProperties and serializeNumberingLevel emitted w:pPr / w:lvl
  children out of the ECMA-376 CT_PPr / CT_Lvl sequence (keepNext/jc misplaced;
  isLgl/suff after lvlText), so generated documents/numbering were schema-invalid.
  Reorder to spec. Tested.

* fix: address review follow-ups

* fix(infosoud): use prague day cutoff for hearings
* refactor(lint): enforce prefer-named-capture-group

Re-enable the rule (deferred from the ultracite 7.8.3 adoption in #751)
and fix all ~373 in-scope violations across apps and packages: name every
capture group that a consumer reads (and read via `match.groups["name"]`),
or make purely-grouping groups non-capturing. Behavior is preserved;
case-law parser tests and the full suite pass. no-await-in-loop remains
deferred to its own follow-up PR.

* refactor(lint): harden tokenizer regex + simplify named-group reads

Address PR #758 review:
- template-conditions: rewrite TOKEN_RE's quoted-string branch to the
  linear unrolled form ("[^"\\]*(?:\\.[^"\\]*)*") to clear a CodeQL
  polynomial-ReDoS alert. Behavior-identical; the pattern pre-existed on
  main but was newly flagged because the line was touched by the rule.
- Consolidate repeated match.groups[...] reads into single destructures
  across case-law adapters, cursor-validation, usage-limit, and a folio
  renderer (gemini review feedback). Behavior preserved; api suite green.

* fix(lint): exempt template-conditions tokenizer regex from prefer-named-capture-group

Naming TOKEN_RE's capture group put the line into the PR diff, which
re-surfaced a CodeQL polynomial-regex false positive on a standard
quoted-string-with-escapes matcher that already exists on main. Revert
the regex to the main baseline and disable the rule for this
single-regex file via a config override (mirrors the existing
require-unicode-regexp exemption block).
…formatting on round-trip (#756)

- per-run RTL survives the live-edit / clipboard / keymap mark paths
  (eigenpal/docx-editor#822, #806).
- a run's character-style reference (w:rStyle) survives the round-trip via an
  inert runStyle mark. On save the link is reconciled against the style: a run
  edited away from its character style (e.g. style-derived bold toggled off)
  drops the reference so the edit wins instead of the style re-applying it
  (eigenpal/docx-editor#833).
- a complex field with no separate result run keeps its run formatting
  (collapsed footer PAGE numbers) via ComplexField.formatting in @stll/docx-core
  (eigenpal/docx-editor#909).
* feat(folio): per-character East-Asian font selection

folio measured and painted every run with its ascii/hAnsi font and never
used the run's eastAsia font for CJK characters, so CJK text rendered in
the OS default fallback face instead of the document's specified
typeface (e.g. a run asking for a Mincho serif showed the OS sans-serif).

A run's text is now split into maximal same-script segments (CJK vs.
non-CJK) shared by the measurer and the painter: CJK code points use the
resolved eastAsia font, the rest keep ascii/hAnsi. The painter splits a
line's sliced runs into per-script sub-runs (each with an exact
pmStart/pmEnd, mirroring sliceRunsForLine) so PM mapping, selection
rects, and click-to-caret stay correct; the measurer sums per-segment
widths so wrapping matches what is painted. The all-Latin path is
unchanged (segmentation is gated behind the presence of an EA font and
CJK content).

This handles runs carrying an explicit w:eastAsia font. Theme-driven
runs whose w:eastAsia slot is empty (resolved via w:themeFontLang) are a
follow-up.

* feat(folio): resolve East-Asian theme fonts via w:themeFontLang

Office's default theme leaves the <a:ea>/<a:cs> slots empty and lists the
real per-script typeface in <a:font script="Jpan" ...>, selected by
w:themeFontLang in settings.xml. folio never consulted themeFontLang, so
a run referencing minorEastAsia resolved to an empty East-Asian font and
the per-character selection fell back to the Latin face.

Parse w:themeFontLang and, before styles are resolved, fill each empty
theme ea/cs slot from the script-specific font for the document's
EastAsian/bidi language (applyThemeFontLang). A minorEastAsia run in a
Japanese document then renders in the document's CJK face. Native CJK
typeface names (as stored in theme1.xml) and their romanized aliases map
to the matching Noto Sans/Serif SC/TC/KR/JP families, preserving serif vs
sans; no CJK webfonts are bundled or fetched (the family resolves to the
viewer's OS face, else the generic serif/sans tail).

Ports eigenpal/docx-editor#949 (theme half) and #957 (font-name mapping).

* fix(folio): EA font for astral CJK + keep authored CJK alias families

Addresses review on PR #779:
- measureRun iterated UTF-16 code units, so astral CJK ideographs
  (Extension B+, surrogate pairs) were not detected as CJK and missed the
  EA font. It now iterates whole code points and pads charWidths with a 0
  for the second unit of a pair, keeping per-character widths aligned with
  ProseMirror offsets. (The wrapping path and painter already iterated code
  points, so only click-positioning widths were affected.)
- resolveFontFamily dropped the authored family when a romanized CJK name
  aliased to another entry (Meiryo / Yu Gothic / Yu Mincho map to the MS
  Gothic/Mincho mapping), so the viewer's own copy of the named face was
  never tried first. The authored family is now prepended to the fallback
  stack when the alias target's stack omits it.

* fix(folio): carry inherited East-Asian font from style/docDefaults

Addresses review on PR #779: per-character EA selection only saw the
eastAsia font from a run's direct rFonts mark. Runs that inherit
w:eastAsia / eastAsiaTheme from a paragraph style or docDefaults flow
through paragraphRunDefaults, which copied only ascii/hAnsi — so CJK in
common Office documents (styles using minorEastAsia resolved via
themeFontLang) still measured and painted with the Latin font.

paragraphRunDefaults now also carries defaultTextFormatting.fontFamily.
eastAsia into eastAsiaFontFamily; a run's own fontFamily mark still wins
via mergeRunFormatting.

* refactor(folio): one runToFontStyle so click/selection measure CJK with EA font

Addresses review on PR #779: clickToPosition and selectionRects built
their FontStyle from only run.fontFamily, so click-to-position and
selection rectangles measured CJK with the Latin font and drifted across
mixed-script runs even though wrapping and rendering used the EA font.

The three near-identical runToFontStyle copies (measureParagraph,
clickToPosition, selectionRects) now delegate to one exported
buildRunFontStyle in measureContainer that threads eastAsiaFontFamily and
every other run field, so a missed field can't recur per call site. Each
caller keeps its own family/size fallbacks.

* fix(folio): Bopomofo in CJK detection + code-point letter spacing

Addresses review on PR #779:
- isCjkCodePoint missed Bopomofo (U+3100-312F) and the adjacent East-Asian
  blocks (Kanbun, Bopomofo Extended, CJK strokes, CJK/Kangxi radicals), so
  Traditional Chinese text using Bopomofo skipped the eastAsiaFontFamily
  path and rendered with the Latin font. Detection now covers the
  contiguous CJK region plus Hangul Jamo extensions and CJK compatibility
  forms.
- letter spacing was counted by UTF-16 unit in measureTextWidth, so astral
  CJK with w:spacing added one extra gap versus measureRun's per-code-point
  count, drifting layout against caret/selection. Both sites now count code
  points (gated behind letterSpacing so the hot path stays allocation-free).

* fix(folio): count astral code points correctly for letter spacing

countCodePoints must count a surrogate pair as one glyph. It now advances
two UTF-16 units for any above-BMP code point (codePointAt > 0xFFFF,
undefined-guarded), so countCodePoints("A𠀀B") is 3 and measureTextWidth's
letter-spacing count matches measureRun for astral CJK / emoji text.

(The prior version detected surrogates with charCodeAt; the prefer-code-point
lint autofix rewrote it to codePointAt, which returns the combined code point
at a leading surrogate and is number|undefined — breaking both the count and
the typecheck. This uses codePointAt correctly.)

* fix(folio): EA font for CJK field results + fingerprint + letter-spacing consistency

Addresses review on PR #779:
- runContentKey (the incremental-render fingerprint) now includes
  eastAsiaFontFamily, so changing only a CJK run's East Asian face repaints
  the page instead of taking the page-unchanged branch.
- CJK field results (REF/PAGEREF/DATE in Japanese/Chinese/Korean) are split by
  script for the font inside renderFieldRun, mirroring the measurer, so the
  resolved text paints in the EA face it was measured with. The field's atomic
  pm range stays on the outer span.
- Letter-spaced runs keep the base font for CJK in both the measurer and the
  painter: CSS letter-spacing cannot bridge the per-script sibling spans, so
  splitting would drift wrapping/caret from the painted width. Gating EA off
  for letterSpacing keeps measurement and rendering consistent (the EA typeface
  is the trade-off for that narrow w:spacing + CJK case).

* fix(folio): EA font in measure cache key + CJK hyperlink field results

Addresses review on PR #779:
- hashParagraphBlock (the paragraph measurement cache key) now includes
  eastAsiaFontFamily. measureTextWidth depends on it, so without it a CJK run
  kept stale measurements when only its East Asian face changed.
- renderFieldRun now renders each script segment of a CJK field result through
  renderTextRun, so hyperlinked field results get the EA font too (each segment
  is its own anchor) and the resolved text paints in the face it was measured
  with. The field's atomic pm range stays on the grouping wrapper. Removes the
  earlier hyperlink bypass that left those results measured-with-EA,
  painted-with-base.

* fix(folio): measure CJK field results with EA font + guard astral caret offsets

Addresses review on PR #779:
- The field-measure path in measureParagraph built its FontStyle manually
  without eastAsiaFontFamily, so a CJK field result (DATE/TIME/PAGEREF/REF)
  wrapped and tab-positioned with the Latin font while the painter used the EA
  font. It now uses the shared buildRunFontStyle.
- A click in the second half of an astral CJK glyph could resolve to the offset
  between its surrogate pair (measureRun emits a 0-width entry for the trailing
  surrogate). snapPastTrailingSurrogate nudges such an offset forward to the
  code-point boundary so ProseMirror never places or edits inside the pair.

* fix(folio): guard codePointAt undefined in snapPastTrailingSurrogate

The prefer-code-point lint autofix rewrites the surrogate check from charCodeAt
to codePointAt (number | undefined), which fails the folio typecheck. Guard the
out-of-range case the length check already excludes; codePointAt at a
low-surrogate index returns that surrogate unit, so the range test is unchanged.

* fix(folio): EA font in painter tab/advance measurement for CJK fields

Addresses review on PR #779: the painter's currentX / tab math measured field
runs (the field-render branch and measureFollowingContentWidth) with only the
base font, so a CJK DATE/REF/PAGEREF field next to a tab drifted leaders and
right-aligned trailing content from where the EA-faced text was painted.

createTextMeasurer now segments by script (EA font for CJK, gated off for
letter-spaced runs to match measureContainer), runMeasureStyle carries
eastAsiaFontFamily, and the field-advance branch routes through runMeasureStyle.
Split text sub-runs already advance with their own per-segment font, so they are
unaffected.

* fix(folio): code-point letter spacing in worker + per-segment decimal-tab prefix

Addresses review on PR #779:
- The font-metrics worker applied letter spacing by UTF-16 length, overwriting
  the main thread's code-point-correct cache entry for astral text once the
  async prewarm landed. countCodePoints moved to the dependency-free
  measureWorkerProtocol and is now shared by the worker and measureContainer, so
  prewarmed widths match.
- The decimal-tab prefix was measured in the first following run's font; after
  the EA split that is a CJK sub-run, so a mixed prefix like 合計12 forced the
  Latin digits into the EA face and drifted the decimal point from the tab stop.
  measureDecimalPrefixWidth now accumulates the prefix run-by-run, each part in
  its own font.

* fix(folio): preserve horizontalScale on split CJK field wrappers

Addresses review on PR #779: a CJK field result with horizontalScale rendered
through the per-script wrapper, but the wrapper was a plain inline span — so
reserveScaledAdvance's width was ignored and the field advanced at its unscaled
width while painting scaled, drifting following text/tabs. The wrapper now
carries the scale itself (display:inline-block + scaleX, like renderTextRun's
scaled path) and the segments drop horizontalScale so they are not double-scaled.
…y (#827)

* chore: migrate folio docx cluster typecheck to tsgo

Switch the typecheck scripts for packages/folio, packages/docx-core,
packages/docx-utils and apps/playground from tsc --noEmit to
tsgo --noEmit. These were the last packages still type-checking with
classic tsc; every other package already uses tsgo. Unifying the
typecheck path removes the chance of a type passing one compiler and
failing the other.

Verified all four pass clean under both tsc and tsgo (zero errors).
Package builds that emit dist/ are unaffected and still use tsc.

* chore: build packages with tsgo and drop the direct typescript dep

Move the five publishable packages that emit dist/ (country-codes,
locales, boe, infosoud, business-registries) from `tsc -p
tsconfig.build.json` to `tsgo -p tsconfig.build.json`. Emit was
verified against tsc: identical .js output, identical declarations
apart from string-literal quote style, only sourcemap formatting
differs.

With every typecheck and build script now on tsgo, drop typescript as
a direct dependency from all workspace packages. It stays as a single
root dev dependency because it is still required transitively by the
Astro type-checker (apps/landing's astro check) and by the eslint
plugins that oxlint consumes; neither has a Go-native equivalent yet.
…(#900)

* chore(packages): make docx-core and docx-utils publish-ready

Mirror the @stll/business-registries publishable setup: dual-condition exports
(bun -> src keeps the monorepo on source, import -> dist for npm consumers),
tsconfig.build.json emitting dist + d.ts, files/main/types/publishConfig/build
scripts, README, and drop private. No package is published by this change; it
only makes the two packages publishable.

* chore(packages): make conditions and template-conditions publish-ready

Same publishable setup as the docx packages and business-registries:
dual-condition exports (bun -> src keeps the monorepo on source, import -> dist
for npm), tsconfig.build.json emitting dist + d.ts, files/main/types/
publishConfig/build scripts, README, drop private. Completes the folio
dependency closure as publishable; nothing is published by this change.

* fix(web): resolve conditions/template-conditions to source in the web build

The two packages are web-consumed but were missing from apps/web's tsconfig
paths, so once their exports gained an import->dist condition, Vite resolved to
the (unbuilt) dist and web-build failed. Map them to src like the other
web-consumed workspace packages (docx-core, folio, ui), so Vite keeps resolving
them to source while the dist condition serves external npm consumers.

* chore(packages): build the publishable packages with tsdown for correct Node ESM

Replace the tsgo emit (which left source's bundler-style extensionless relative
imports intact, so dist failed to load under Node ESM) with a tsdown bundle that
emits Node-resolvable ESM + .d.ts. Point the exports `types` condition at the
built d.ts for external consumers; the `bun` condition still resolves the
monorepo to source. dist now loads under plain Node.

* ci(packages): add hardened OIDC npm publish workflow

Add a workflow_dispatch publish workflow for the shared @stll dependency
packages (docx-core, docx-utils, conditions, template-conditions). It builds
(tsdown) and packs the selected packages in dependency order, then publishes via
the shared npm-publish-hardened action — OIDC trusted publishing with SLSA
provenance, no long-lived token, idempotent on name@version. Default run packs
only; publishing is opt-in per dispatch. Requires an npm trusted publisher
configured per package before first publish.

* fix(ci): pack with bun, not npm, so catalog/workspace deps resolve

npm pack leaves `catalog:` and `workspace:*` dependency protocols as literals
in the published package.json (npm doesn't understand them), shipping
uninstallable packages. bun pm pack rewrites them to concrete versions
(@stll/conditions -> 0.1.0, better-result -> 2.9.2). Pack each package into its
own dir and publish those tarballs via the hardened action.

* fix(packages): keep monorepo type resolution on source

The tsdown export shape pointed `types` at dist/*.d.ts, but the monorepo
typechecks on a clean checkout with no dist, and TypeScript's Bundler
resolution does not honor the `bun` condition the way Bun's runtime does. So
it fell through to the dist .d.ts and failed to resolve (@stll/conditions
resolved to an error type in template-conditions). Point `types` at source
(the business-registries pattern): the monorepo never needs dist, while
`import` still serves the Node-correct dist build to external consumers. Also
map @stll/docx-utils to source in apps/web so Vite resolves folio's transitive
import without a built dist.

* fix(web): add source alias for @stll/docx-core/model subpath

folio imports @stll/docx-core/model; keep Vite resolution on source for the
subpath too, so it never follows the export to an unbuilt dist.

* fix(web): alias transitively-consumed @stll packages to source

Vite's tsconfigPaths only rewrites imports in apps/web's own files. folio and
template-conditions sources import @stll/conditions, @stll/docx-core,
@stll/docx-utils and @stll/docx-core/model transitively, so Vite followed those
packages' `import` export to an unbuilt dist and failed (Rolldown could not
resolve @stll/conditions from template-conditions/src). Add regex-exact global
aliases to source for exactly those specifiers. Verified the production web
build resolves and completes.

* chore: oxfmt vite alias block

* fix(ci): isolate npm OIDC from untrusted build steps

Split the publish workflow into a pack job (contents:read only, runs bun
install + build + bun pm pack) and a publish job (id-token:write, downloads the
prebuilt tarballs and runs the hardened action). Job-level id-token was exposed
to the build/prepack scripts before the publish gate, so a compromised script
could mint the OIDC credential and publish even on a dry run. The token now only
exists in the publish job, which runs no package scripts and only when publish
is requested.

* docs(conditions): describe the root API, not phantom subpaths

The README listed ./schema, ./evaluate and ./walk under an Exports heading, but
the package only exports the root (everything is re-exported from the index).
Reword to describe the three areas of the root surface so consumers don't try
@stll/conditions/schema and hit ERR_PACKAGE_PATH_NOT_EXPORTED.

* chore: sync bun.lock after rebase (tsdown devDep)

* fix(ci): publish one tarball per package via a matrix

The hardened action takes a single required `tarball` input, not a `tarballs`
list; passing the list published nothing. Output the selected package names as
JSON from the pack job and fan the publish job out over them, resolving each
package's single .tgz and invoking the action once per package.

* refactor(packages): consume from source in-repo, build dist on publish

Replace the country-codes-style exports (types -> src, import -> dist plus
per-edge Vite aliases) with the simpler source-only shape: in-repo exports
point at ./src, so every consumer (Bun, tgo, Vite) resolves source directly
with zero aliases or conditions, including transitive imports from folio and
template-conditions. On publish, scripts/prepare-publish.ts derives the dist
exports from the source paths and rewrites main/types/files, so the published
package ships real .d.ts + .js and no source. The publish workflow runs the
transform before packing; scripts/bootstrap-publish-packages.sh does the same
for the manual first publish. Flip template-conditions' internal dep to
workspace:^ so conditions can patch without forcing a republish.

* fix(ci): install npm with --ignore-scripts in the OIDC publish job

The publish job holds id-token: write; installing npm without --ignore-scripts
would run any lifecycle hooks from the fetched package while the OIDC token is
available. npm needs no install scripts to run.

* ci(packages): auto-publish on version-bump push to main

Match the @stll release pattern (stella/anonymize): a version bump to any of the
four package.json files on main triggers the workflow, which packs all and
relies on the idempotent hardened action to skip unchanged versions. Keep the
workflow_dispatch button for manual/dry runs. Add attestations: write for build
provenance and gate publish on push or the dispatch checkbox.
* chore: upgrade typecheck to typescript 7

* fix: invoke native typescript compiler directly

* fix: centralize native typescript compiler

* fix: keep typescript api on ts6

* fix: use default path import in tsc wrapper

* fix: surface tsc wrapper spawn errors
…gration) (#1118)

* chore(lint): adopt oxlint 1.73 and ultracite 7.9.2 with full rule adoption

useEffectEvent -> useLatestCallback migration, preset rule curation,
and adoption of the react-doctor/github backlog: micro-perf and
modernization fixes, a11y (title->Tooltip, ARIA validity, passive
listeners), judged async adoption, only-export-components file
splits, and justified suppressions where sequencing or patterns are
deliberate. Rules colliding with sanctioned repo patterns stay off
with written reasons.

* ci: retrigger workflows after dropped push event
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@jan-kubica, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 46 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 967cd779-18ff-4a31-a704-809a7add7900

📥 Commits

Reviewing files that changed from the base of the PR and between d946b78 and 03843d2.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (36)
  • .changeset/silent-docx-ownership.md
  • .github/workflows/ci.yml
  • .github/workflows/publish.yml
  • api-reports/docx-core/index.api.md
  • api-reports/docx-core/model.api.md
  • package.json
  • packages/core/package.json
  • packages/docx-core/LICENSE
  • packages/docx-core/NOTICE.md
  • packages/docx-core/README.md
  • packages/docx-core/package.json
  • packages/docx-core/src/index.ts
  • packages/docx-core/src/legal-source/compile.ts
  • packages/docx-core/src/legal-source/index.ts
  • packages/docx-core/src/legal-source/legal-source.test.ts
  • packages/docx-core/src/legal-source/parser.ts
  • packages/docx-core/src/legal-source/types.ts
  • packages/docx-core/src/legal-source/validate.ts
  • packages/docx-core/src/model/colors.ts
  • packages/docx-core/src/model/content.ts
  • packages/docx-core/src/model/document.ts
  • packages/docx-core/src/model/formatting.ts
  • packages/docx-core/src/model/lists.ts
  • packages/docx-core/src/model/styles.ts
  • packages/docx-core/src/serialize/docx.test.ts
  • packages/docx-core/src/serialize/docx.ts
  • packages/docx-core/src/serialize/xml.test.ts
  • packages/docx-core/src/serialize/xml.ts
  • packages/docx-core/src/validate/docx.test.ts
  • packages/docx-core/src/validate/docx.ts
  • packages/docx-core/tsconfig.build.json
  • packages/docx-core/tsconfig.json
  • packages/docx-core/tsdown.config.ts
  • scripts/api-reports.ts
  • scripts/check-changeset.ts
  • scripts/validate-dist.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/docx-core-ownership

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jan-kubica jan-kubica marked this pull request as ready for review July 13, 2026 09:27

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the @stll/docx-core package, which provides a typed OOXML/DOCX document model with parsing, validation, and serialization, alongside a legal-source compiler. Feedback on the changes highlights three main areas for improvement: expanding the manual numbering prefix regular expression to consistently strip multi-letter Roman numerals, ensuring that theme-colored and auto-colored border properties are preserved during serialization, and adding an explicit type check for math equations in validateParagraphContent to prevent a potential runtime TypeError.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

};

const ORDERED_LIST_MARKER_RE = /^(?:\d+(?:\.\d+)+|\d+[.)])\s+/u;
const MANUAL_NUMBERING_PREFIX_RE = /^(?:\d+(?:\.\d+)+|\d+[.)]|[A-Za-z][.)]|\([a-zivx]+\))\s+/u;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The regular expression MANUAL_NUMBERING_PREFIX_RE uses [A-Za-z][.)] to match single-letter alphabetical or Roman numeral markers. This means single-letter Roman numerals (like I., V., X.) are stripped, but multi-letter Roman numerals (like II., III., IV., VI., IX., XI.) are ignored and not stripped. This causes inconsistent heading stripping in legal documents. Adding [IVXivx]+[.)] to the choices ensures Roman numerals of any length are consistently stripped.

const MANUAL_NUMBERING_PREFIX_RE = /^(?:\d+(?:\.\d+)+|\d+[.)]|[IVXivx]+[.)]|[A-Za-z][.)]|\([a-zivx]+\))\s+/u;

Comment on lines +430 to +443
const borderEdgeXml = (tag: string, spec: BorderSpec | undefined): string => {
if (!spec) {
return `<${tag} w:val="nil"/>`;
}
if (spec.style === "none" || spec.style === "nil") {
return `<${tag} w:val="nil"/>`;
}
const sz = spec.size ?? 4;
const color = spec.color?.rgb ?? "CCCCCC";
// style/color are typed `string` and may carry preserved "unknown OOXML"
// values from parsed input; escape them like every other attribute so a
// value containing a quote or angle bracket cannot break the XML.
return `<${tag} w:val="${escapeXml(spec.style)}" w:sz="${sz}" w:space="0" w:color="${escapeXml(color)}"/>`;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The borderEdgeXml function only serializes spec.color?.rgb and defaults to "CCCCCC". It completely ignores themeColor, themeTint, themeShade, and auto properties defined on ColorValue for borders. This means any parsed document with theme-colored or auto-colored borders will lose them upon serialization. Updating the function to serialize these attributes if present preserves the visual fidelity of the document.

const borderEdgeXml = (tag: string, spec: BorderSpec | undefined): string => {
  if (!spec) {
    return "<" + tag + ' w:val="nil"/>';
  }
  if (spec.style === "none" || spec.style === "nil") {
    return "<" + tag + ' w:val="nil"/>';
  }
  const sz = spec.size ?? 4;
  let colorAttrs = ' w:color="CCCCCC"';
  if (spec.color) {
    if (spec.color.auto) {
      colorAttrs = ' w:color="auto"';
    } else if (spec.color.rgb) {
      colorAttrs = ' w:color="' + escapeXml(spec.color.rgb) + '"';
    }
    if (spec.color.themeColor) {
      colorAttrs += ' w:themeColor="' + escapeXml(spec.color.themeColor) + '"';
    }
    if (spec.color.themeTint) {
      colorAttrs += ' w:themeTint="' + escapeXml(spec.color.themeTint) + '"';
    }
    if (spec.color.themeShade) {
      colorAttrs += ' w:themeShade="' + escapeXml(spec.color.themeShade) + '"';
    }
  }
  return "<" + tag + ' w:val="' + escapeXml(spec.style) + '" w:sz="' + sz + '" w:space="0"' + colorAttrs + "/>";
};

Comment on lines +347 to +355
if (content.type === "moveToRangeEnd") {
increment(ctx.moveToEnds, content.id);
return;
}

if (content.ommlXml.trim() === "") {
addError(ctx, `${path}.ommlXml`, "Math equation must preserve OMML XML.");
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The validateParagraphContent function accesses content.ommlXml at the end of the function without explicitly checking if content.type === "mathEquation". If any other type falls through (e.g., if a new type is added to ParagraphContent in the future), this will throw a runtime TypeError because ommlXml is undefined. Explicitly checking content.type === "mathEquation" before accessing content.ommlXml makes the code robust and type-safe.

  if (content.type === "moveToRangeEnd") {
    increment(ctx.moveToEnds, content.id);
    return;
  }

  if (content.type === "mathEquation") {
    if (content.ommlXml.trim() === "") {
      addError(ctx, path + ".ommlXml", "Math equation must preserve OMML XML.");
    }
    return;
  }
};

@jan-kubica jan-kubica merged commit 46c6730 into main Jul 13, 2026
8 checks passed
@jan-kubica jan-kubica deleted the feat/docx-core-ownership branch July 13, 2026 09:33
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant