Operand

do no harem.

gram: docs

> ./CLAUDE.md

# Eigenpal DOCX Editor
Bun + React/Vue WYSIWYG editor for DOCX. Client-side only, no backend.
Per-package entries: `packages/react/src/index.ts`, `packages/vue/src/index.ts`, `packages/core/src/headless.ts`.
Output must look identical to MS Word. Preserve fonts, theme colors, styles, tables, headers/footers, section layout.
---
## Verify
```bash
bun run typecheck && npx playwright test --grep "<pattern>" --timeout=30000 --workers=4

Test file map

Area File
Bold/Italic/Underline formatting.spec.ts
Alignment alignment.spec.ts
Lists lists.spec.ts
Colors colors.spec.ts
Fonts fonts.spec.ts
Enter/Paragraphs text-editing.spec.ts
Undo/Redo scenario-driven.spec.ts
Line spacing line-spacing.spec.ts
Paragraph styles paragraph-styles.spec.ts
Toolbar state toolbar-state.spec.ts
Cursor-only ops cursor-paragraph-ops.spec.ts
Comments sidebar comments-sidebar.spec.ts

Run comments-sidebar.spec.ts when touching any of these (all under packages/react/src/): components/UnifiedSidebar.tsx, components/sidebar/**, hooks/useCommentSidebarItems.tsx, components/DocxEditor/hooks/useSelectionOverlay.ts (updateSelectionOverlay/onSelectionChange), components/DocxEditor.tsx (onSelectionChange handler, expandedSidebarItem state).

Empty-doc specs (formatting, text-editing) use editor.gotoEmpty(). Demo-asserting specs use editor.goto(). Don't mix in one spec.


Architecture — Dual Rendering

Two renderers. Know which one owns your bug.

Data flow: DOCX → unzipparserDocumenttoProseDoc → PM → painter → pages. Save: PM → fromProseDocDocumentserializerrezip.

Click flow: usePagesPointer.handlePagesMouseDowngetPositionFromMouse (body) or clickToPositionDom scoped to .layout-page-header/.layout-page-footer (HF) → PM setSelection → PagedEditor.handleTransaction → painter re-render.

Header/footer editing follows the same model as the body: the persistent hidden HF PM is the sole editor; the painter is the sole visible renderer in both edit and non-edit modes. The InlineHeaderFooterEditor overlay is UI chrome only (separator bar, options menu, save-on-close) — it does NOT mount its own EditorView. There is no .hf-editor-pm CSS — those workarounds existed to make PM's toDOM tables match the painter's flex layout and are gone now that the painter is the sole renderer. See openspec/changes/unify-hf-editing/ for the design.

Vue host: useDocxEditor() in packages/vue/src/composables/useDocxEditor.ts. Dual-rendering rule applies to Vue too — the composable mounts the same per-rId persistent HF EditorView pattern (via syncHfPMs / getHfPmView / setHfTransactionListener) and routes HF rendering through convertHeaderFooterPmDocToContent in lockstep with React.

React/Vue parity

Changes to layout / measurement / paint behavior MUST land in both adapters in the same PR. The Vue composable mirrors the React PagedEditor; if you touch only one, the other regresses silently.

Before merging a change in packages/react/:

Adapter-only changes are fine for things genuinely scoped to one framework (React-specific hook glue, Vue composition API ergonomics, the demo apps). When in doubt, mirror.

UI styling / colors are single-source-of-truth. All editor chrome CSS + color tokens live in packages/core/src/styles/editor.css; both adapters only @import it (the adapter src/styles/editor.css files must stay thin — enforced by bun run check:adapter-css-thin). Never hardcode hex/rgba in components — use the --doc-* tokens (or shadcn token utilities like bg-primary). The shared Tailwind theme lives in packages/core/tailwind-preset.cjs, extended by all three tailwind.config.js. Dark mode is a token override under .ep-root.dark (scaffold in the core stylesheet). The document canvas (painter output) is intentionally NOT themed — it stays Word-faithful.

FlowBlock invariant — 3 switches

Adding a FlowBlock variant in packages/core/src/layout-engine/types.ts requires updating all three; each ends with assertExhaustiveFlowBlock so bun run typecheck names the missing site:

  1. runLayoutPipeline in packages/core/src/layout-engine/index.ts
  2. measureBlock in packages/react/src/components/DocxEditor/internals/measureBlock.ts
  3. measureBlock in packages/vue/src/composables/useDocxEditor.ts

Painter DOM contract

Stable dataset attrs on painted DOM (CSS, queries, selection map depend on these):

Key file map

Debugging File
Text/paragraph rendering layout-painter/renderParagraph.ts
Image rendering layout-painter/renderImage.ts
Table rendering layout-painter/renderTable.ts
Table borders / cut edges layout-painter/renderTableBorders.ts
Table grid geometry (SoT) layout-bridge/tableWidthUtils.ts (resolveCellGrid)
Table page-break geometry layout-engine/tableRowBreak.ts
Page composition layout-painter/renderPage.ts
Formatting commands prosemirror/extensions/marks/, nodes/
Keyboard shortcuts prosemirror/extensions/features/BaseKeymapExtension.ts
Toolbar ↔ selection prosemirror/plugins/selectionTracker.ts
DOCX XML parsers docx/paragraphParser.ts, docx/tableParser.ts
Document → PM prosemirror/conversion/toProseDoc.ts
Click → PM position components/DocxEditor/hooks/usePagesPointer.ts
Selection rects / caret components/DocxEditor/hooks/useSelectionOverlay.ts
HF persistent PMs components/DocxEditor/HiddenHeaderFooterPMs.tsx
HF caret in painter components/DocxEditor/DocxEditorPagedArea.tsx (hfCaretRect)
HF inline chrome components/InlineHeaderFooterEditor.tsx
Layout pipeline components/DocxEditor/hooks/useLayoutPipeline.ts
Scroll API components/DocxEditor/hooks/usePagedScrollApi.ts
Image resize/drag components/DocxEditor/hooks/useImageInteractions.ts
Font/HF reflow triggers components/DocxEditor/hooks/useLayoutTriggers.ts
Table resize components/DocxEditor/hooks/useTableResizeState.ts
Measure-block cache components/DocxEditor/internals/measureBlock.ts
Sidebar comment Y positions components/DocxEditor/internals/sidebarAnchorPositions.ts
PM position → DOM components/DocxEditor/internals/pmAnchors.ts
Main toolbar components/Toolbar.tsx
Document/PM CSS prosemirror/editor.css
UI chrome CSS + color tokens packages/core/src/styles/editor.css (SINGLE SOURCE OF TRUTH)

Shared React/Vue orchestration lives in core (issue #696, Tier 1) — adapters re-export or delegate, so grepping an adapter lands on a thin wrapper:

Shared op Core module (in @eigenpal/docx-editor-core)
paraId/text helpers prosemirror/paraText.ts
ref-API queries (find/selInfo/page) prosemirror/queries.ts
agent applyFormatting/setParaStyle prosemirror/applyFormatting.ts
comment/proposeChange + ID alloc prosemirror/commentOps.ts
table-resize read/commit + twips prosemirror/tableResize.ts
image resize/drag PM commits prosemirror/imageCommit.ts
cell-selection highlight layout-bridge/cellSelectionHighlight.ts
drag auto-scroll delta math utils/autoScroll.ts

Extensions

src/prosemirror/extensions/nodes/, marks/, features/. StarterKit.ts bundles all. ExtensionManager.buildSchema() (sync) → initializeRuntime() (post EditorState). Singleton in schema/index.ts.

Pitfalls

OOXML reference: reference/quick-ref/wordprocessingml.md, themes-colors.md; schemas in reference/ecma-376/part1/schemas/. PDFs in reference/ecma-376/ are gitignored — run bun run reference:fetch once when you need them.

Website docs (docx-editor.dev/docs/1.x) are authored here in docs/site/content/ (MDX) and synced by the site repo at build time — see docs/site/README.md for the authoring contract. Feature-support claims live in docs/site/data/word-features.ts (typed matrix), never hand-written in prose. A feature PR that changes user-visible behavior should update both in the same PR.


i18n

packages/i18n/en.json is source of truth. Other locales mirror its shape with null = falls back to English. Missing key = CI fails.

import { useTranslation } from '../i18n';
const { t } = useTranslation();
t('toolbar.bold');
t('dialogs.findReplace.matchCount', { current: 3, total: 15 });

Workflow:

Never hardcode user-facing English in components.

Vue composables: declare named Use<Name>Return interface and annotate return type. Without it, core's internal types leak into the API Extractor snapshot.


Public API surface

API Extractor snapshots live in docs/api/<pkg-slug>/<entry>.api.md. CI runs bun run api:check.

CI fails on drift → bun run api:extract → commit. Changing a @public symbol → tag in TSDoc, rebuild package, bun run api:extract, commit snapshot.

bun run docs:json generates downstream-consumer JSON. Output is gitignored; CI runs it as a smoke test.

Parity contract

scripts/parity/parity.contract.json enumerates which DocxEditorProps/DocxEditorRef members are paired across React/Vue. CI runs bun run check:parity-contract.

Adding adapter prop/ref method:

  1. Edit adapter, bun run api:extract.
  2. Add to contract bucket: paired, deferredInVue (React-only), pairedViaInheritance (React explicit, Vue via EditorRefLike), or vueExclusive.
  3. bun run check:parity-contract.

Releasing (changesets)

Every code PR → bun changeset → commit .changeset/*.md. Skip only for test/docs/CI-only PRs.

Release: merge the bot's chore: release PR. Publish runs via OIDC, tags, GH release. ~3 min.

Branches: main = 1.x line. 0.x = pre-rename maintenance, patch/minor only.

Packages: @eigenpal/docx-editor-{react,core,agents,i18n,vue}, @eigenpal/nuxt-docx-editor. All published.

Don't


PR style

Short factual title (conventional-commit prefix). Body is the minimum the diff doesn't show — often one sentence.

Don't: @-mention contributors, reference unrelated PR/issue numbers, list changed files, add tooling footers, use emojis.


Bugs

Issue tracker: gh issue view <N> --repo eigenpal/docx-editor. Dev server: bun run devhttp://localhost:5173/. Commit format: fix: ... (fixes #N).

Toolbar icons: Material Symbol SVGs, saved locally. Screenshots → screenshots/.