Operand

engine, you in?

gram: docs

> ./openspec/changes/archive/2026-05-28-unify-hf-editing/proposal.md

## Why
Header/footer editing mounts a second, _visible_ ProseMirror EditorView (`packages/react/src/components/InlineHeaderFooterEditor.tsx`) on top of the painter when the user double-clicks the header. That second renderer emits native HTML `<table>` via PM's `toDOM`, while the rest of the document uses the painter (`packages/core/src/layout-painter/renderPage.ts`) driven by a hidden PM EditorView. The two renderers disagree about column widths, vertical alignment, font line-boxes, and row distribution. Issue #468 alone required three CSS patches (column widths, vAlign mapping, font-strut suppression) to bring them visually closer — the next divergence will produce another bug. The architectural fix is to put HF editing on the body's "hidden PM + visible painter" model so there is one renderer for both edit and display, matching the invariant `CLAUDE.md` enshrines for the body.
## What Changes
- Replace the on-demand visible PM editor for headers/footers with one persistent **hidden** PM EditorView per distinct HF part — keyed by `rId`. Two sections that reference the same header part share one EditorView; two sections that reference different `default`-type headers get two EditorViews. Mounted off-screen at `left: -9999px`, focused only when the user is editing that part.
- Make the painter (`renderHeaderFooterContent`) the only visible HF renderer in both edit and non-edit modes. Remove the CSS that hides the painted HF region during edit (`.paged-editor--editing-header .layout-page-header > * { visibility: hidden }` in `packages/core/src/prosemirror/editor.css` and its react mirror).
- Extend `usePagesPointer.handlePagesMouseDown` to detect clicks inside `.layout-page-header` / `.layout-page-footer`, resolve the painted region to the underlying HF `rId` via the section's `headerReferences` / `footerReferences`, and route to the matching HF PM via a slot-scoped `clickToPositionDom`. Add `findHfPmSpans(rId)` mirroring `findBodyPmSpans`.
- Extend `useSelectionOverlay` (or fork a parallel `useHfSelectionOverlay`) to draw carets and selection rects against the focused HF PM's state inside painted HF DOM. The painter already emits `data-pm-start` / `data-pm-end` markers on HF nodes, so no painter changes are needed for hit-testing.
- Painter consumes `hfPM.state.doc` via `convertHeaderFooterToContent` instead of `headerFooter.content`. `Document.package.headers: Map<string /* rId */, HeaderFooter>` and `Document.package.footers` remain source-of-truth for storage and round-trip (declared at `packages/core/src/types/document.ts:192-195`, populated by `headerFooterParser.buildHeaderFooterMap`, read by `sectionGeometry.resolveHeaderFooter`, written by `rezip/packaging.ts`). The PM doc is a _projection_ of `package.headers[rId].content`. Save path writes `proseDocToBlocks(hfPM.state.doc)` back into `package.headers[rId].content`.
- **BREAKING (internal API only):** `InlineHeaderFooterEditor.tsx` shrinks from ~513 lines to <50 (UI chrome only: separator bar, options menu, dim-body overlay). The component no longer owns a PM EditorView. The `getHfTargetElement` callback and the `hfEditorRef` ref shape change (no PM view returned — instead returns the active hidden HF PM).
- **Deleted:** the entire `.hf-editor-pm` CSS block (~100 lines) in `packages/core/src/prosemirror/editor.css` and `packages/react/src/styles/editor.css`. These rules only existed to patch the gap between PM's `toDOM` tables and the painter's divs; the gap disappears.
- Vue adapter (`packages/vue/src/composables/useDocxEditor.ts`) mirrors the React changes (architectural parity per CLAUDE.md's React/Vue parity rule).
- Undo stack per HF slot stays independent of the body undo stack (each EditorView has its own history plugin). Toolbar undo/redo routes to whichever editor currently has focus — `useActiveEditor` already does this and is reused.
## Capabilities
### New Capabilities
- `header-footer-editing`: WYSIWYG editing of header/footer content using the body's hidden-PM + visible-painter rendering model. Covers PM lifecycle (one hidden EditorView per distinct HF `rId`, focus management on click-into-HF, save-on-transaction to the Document model), painter integration (single render path for edit and display, no edit-time visibility toggling), pointer routing (clicks inside painted HF DOM resolve the painted region's `rId` from the section's header/footer references and translate to PM positions via rId-scoped `clickToPositionDom`), and selection overlay (carets and selection rects drawn against HF PM state inside the painted HF region). Excludes the storage model (`Document.package.headers/footers` stays unchanged) and OOXML serialization (per-part dispatch via existing `headerFooterSerializer.ts` stays unchanged).
### Modified Capabilities
<!-- No existing OpenSpec specs in this repo; nothing to modify. -->
## Impact
- **Removed code:** the visible `<div ref={editorContainerRef} className="hf-editor-pm prosemirror-editor" />` in `InlineHeaderFooterEditor.tsx`, its `EditorView` creation logic (~50 lines), the `.hf-editor-pm` CSS block in both core and react editor.css (~100 lines), and the painter-hiding CSS rules (`.paged-editor--editing-{header,footer} .layout-page-{header,footer} > * { visibility: hidden }`). Total cleanup: ~250 lines.
- **New code:** `useHeaderFooterPM` hook (or equivalent) that owns the persistent hidden PM EditorView per HF slot, a `findHfPmSpans(slot)` helper mirroring `findBodyPmSpans` for selection-rect queries, and extensions to `usePagesPointer` / `useSelectionOverlay` to handle HF slot focus. Estimated ~400 lines of new code.
- **APIs affected:** `InlineHeaderFooterEditorRef` shape changes; consumers of `hfEditorRef.getView()` get the underlying hidden PM EditorView (unchanged interface, different ownership). `useHeaderFooterEditing.ts` is restructured but its public callbacks (`handleHeaderFooterDoubleClick`, `handleHeaderFooterSave`, `getHfTargetElement`) keep the same shape so `DocxEditorPagedArea.tsx` doesn't need surgery.
- **Tests affected:** existing HF playwright specs (`titlePg-header-footer.spec.ts`, `footer-page-number.spec.ts`, `sdt-header-content.spec.ts`, `hf-trailing-rule.spec.ts`, `hf-toolbar-and-zindex.spec.ts`) must continue to pass without modification. A new screenshot-diff spec is added to assert zero geometry change on the `DC_Template_Descricao_Cargo_Controlado_Enterprise.docx` fixture between edit and non-edit states.
- **Dependencies:** none added or removed. The change uses only existing PM, painter, and layout-bridge primitives.
- **PR #610 (open):** ships interim CSS patches that this refactor will delete. #610 lands first; this refactor deletes its patches as part of phase 5.
- **Document storage / serializer:** unchanged. `Document.package.headers`/`footers` (`Map<string /* rId */, HeaderFooter>` at `packages/core/src/types/document.ts:192-195`) and `docx/serializer/headerFooterSerializer.ts` per-part dispatch are preserved exactly. The PM-doc layer is a _projection_; the Document model is source-of-truth, per the OOXML reviewer's recommendation. The dead `Section.headers`/`.footers` field (declared at `packages/core/src/types/content/section.ts:192-194` but never populated by any parser, never read by serializer or renderer) is unrelated to this change and should be removed in a separate cleanup PR.
- **Phasing:** lands incrementally on a long-lived feature branch (`refactor/unify-hf-editing`). Each phase is independently mergeable to that branch with green tests; the branch merges to main once all seven phases complete (see `design.md` for the phase plan).