Skip to content

refactor(painter): make DomPainter measurement-clean (SD-2957)#3178

Merged
tupizz merged 10 commits intomainfrom
tadeu/sd-2957-dompainter-measurement-clean
May 6, 2026
Merged

refactor(painter): make DomPainter measurement-clean (SD-2957)#3178
tupizz merged 10 commits intomainfrom
tadeu/sd-2957-dompainter-measurement-clean

Conversation

@tupizz
Copy link
Copy Markdown
Contributor

@tupizz tupizz commented May 5, 2026

Closes SD-2957. Follow-up to SD-2836.

What

Two patterns survived SD-2836 that contradicted "the painter is dumb":

  1. 4 paint-time DOM measurementspageEl.clientHeight / elem.offsetWidth / clone-and-measure, used as fallbacks when resolved fields were missing or zero.
  2. 14 dead ?? fragment.X fallbacksresolvedItem?.X ?? fragment.X reads. The producer already copies these fields; the fragment fallback was equivalent to resolvedItem?.X.

Both deleted. PageDecorationPayload.offset tightened from optional to required (the producer already always sets it). New architecture-boundary Guard E ratchets the invariant — any future paint-time DOM measurement on rendered content fails CI.

Commits

# Commit
1 test(architecture): Guard E forbids paint-time DOM measurement
2 refactor(painter): drop pageEl.clientHeight fallback for ResolvedPage.height
3 refactor(contracts,painter): require PageDecorationPayload.offset
4 refactor(painter): drop offsetWidth fallbacks for LineSegment.width
5 refactor(painter): delete 14 dead fragment.X fallbacks
6 docs(painter): invariants in README + AGENTS/CLAUDE

Out of scope

  • block.width ?? fragment.width (different semantics — natural vs layout width)
  • fragment.lines ?? measure.lines.slice(...) (needs ResolvedTextLineItem[] migration — separate ticket)
  • fragment.scale ?? 1 (literal default, no resolved equivalent)

Verification

  • 22/22 architecture + contract-shape tests pass
  • 1050/1050 painter-dom; 12345/12345 super-editor; 18390/18390 full repo
  • tsc --noEmit clean
  • Behavior: same output for healthy docs; painter throws (rather than silently rescuing) when resolve/measure stages emit invalid data

Test plan

  • CI green
  • Layout corpus diff vs origin/main: 0 unintended changes

tupizz added 6 commits May 5, 2026 18:38
…(SD-2957)

Add Guard E to architecture-boundaries.test.ts. The guard fails when
production source under packages/layout-engine/painters/dom/src reads DOM
measurements (clientHeight, clientWidth, offsetHeight, offsetWidth,
getBoundingClientRect) off rendered content.

Allowlists are intentionally narrow:

* ALLOWED_INTERACTION_FILES: ruler/ruler-renderer.ts (drag handle pointer
  mapping). New entries require reviewer sign-off.
* ALLOWED_MEASUREMENT_RECEIVERS: this.mount, mount, scrollCont. Scroll
  container references used to detect scrollability and map pointer coords.
  New entries require reviewer sign-off.

The guard currently fails with 4 violations in renderer.ts (pageEl page-
height fallback, pageEl footer-offset fallback, elem segment-width defensive
read, measureEl clone-and-measure). Subsequent commits in SD-2957 delete
those four sites; the guard then passes and ratchets the invariant.
….height (SD-2957)

ResolvedPage.height is non-optional in the contract (number, not number?).
The painter's fallback to pageEl.clientHeight was dead by the type system
and only fired if a producer skipped its own contract — which would surface
as silently mismeasured footer offsets rather than an error.

Replace the fallback with a direct page.height read and a fail-fast assert
that mirrors the existing 'missing resolved paragraph block/measure for
fragment' invariant. The unused pageEl parameter is dropped from the
function signature; the sole caller is updated.

Architecture-boundary Guard E now reports 3 violations (was 4).
…-2957)

Tighten PageDecorationPayload.offset from optional to required and delete
the painter's pageEl.clientHeight fallback. The producer
(HeaderFooterSessionManager) already populates offset on every payload it
returns (header path: box.offset; footer path: pageHeight - footerMargin -
containerHeight; both via #computeMetrics). The optional typing was a SD-2836
transition artifact.

Add a contract-shape lockdown matching the existing items-required pattern
so any future regression to optional fails the type-shape suite.

Architecture-boundary Guard E now reports 2 violations (was 3).
…SD-2957)

LineSegment.width is required (number) by contract. The producer
(measuring-dom) always emits it. Two paint-time DOM measurement fallbacks
existed to rescue cases where the contract was violated:

* Image segment renderer at the runSegments[0] read fell through to
  elem.offsetWidth when width was undefined. Replaced with a direct
  resolved-stage read.
* Inline text segment renderer cloned the element off-screen and read
  measureEl.offsetWidth when segment.width was <= 0. Deleted; the painter
  now trusts the resolved width verbatim. If a producer emits 0 for a
  non-empty segment, that's a producer bug and surfaces as a layout
  miscalculation rather than being papered over at paint time.

Architecture-boundary Guard E now reports 0 violations.
…m reads (SD-2957)

The painter coalesced ~14 resolved-item field reads with the legacy fragment
back-pointer (resolvedItem?.X ?? fragment.X). The resolve producer
(resolveLayout) already copies these fields onto the resolved item whenever
the source fragment has them, so the fragment fallback was mathematically
equivalent to just resolvedItem?.X — dead code preserved from the SD-2836
transition.

Sweep covers:
* paragraph: continuesFromPrev, continuesOnNext, markerWidth (truthy use)
* list-item content: continuesFromPrev, continuesOnNext, markerWidth (used as
  Math.max input — defaults to 0 when absent rather than reading fragment)
* list-item wrapper frame: markerWidth (used as Math.max input — defaults to 0)
* drop cap / paragraph / list marker / image: sourceAnchor
* image: pmStart, pmEnd, metadata
* fragment z-index: zIndex

Test fixture updated: the list-item-wrapper geometry test was relying on
the dead fallback (its resolved item omitted markerWidth). Populate
markerWidth on both initial and updated resolved items so the test reflects
the contract.

Out of scope (different semantics, not the same dead-fallback shape):
* block.width ?? fragment.width (image-block natural width vs fragment
  layout width — separate concepts).
* fragment.lines ?? measure.lines.slice(...) — flagged as stretch.
* fragment.scale ?? 1 — literal default with no resolved-stage equivalent.
Update painter-dom README and layout-engine AGENTS.md (CLAUDE.md is a
symlink) to document the two hard invariants enforced by the architecture
boundary tests: no paint-time DOM measurement (Guard E) and resolve stage
as the unique source of truth (no resolvedItem?.X ?? fragment.X coalescing).

Replaces aspirational 'painter is dumb' language with what's actually
ratchet-protected.
@tupizz tupizz requested a review from a team as a code owner May 5, 2026 22:04
@linear
Copy link
Copy Markdown

linear Bot commented May 5, 2026

Copy link
Copy Markdown

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

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d5b2dc10ed

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

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

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

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

Comment thread packages/layout-engine/tests/src/architecture-boundaries.test.ts
tupizz added 2 commits May 5, 2026 19:11
…(SD-2957)

Add Guard F to architecture-boundaries.test.ts. The guard fails when
production source under packages/layout-engine/painters/dom/src contains
'?? fragment.X' or '?? (fragment as Y).X' patterns — the dead-fallback
shape that the SD-2957 soft-pattern sweep targeted.

Allowlist (substring match): the documented out-of-scope cases where the
LHS reads from a different stage:

* block.width ?? fragment.width    (ImageBlock natural vs fragment layout)
* block.height ?? fragment.height  (same)

The previous review (codex on PR #3178) caught four sites the initial sweep
missed because they used the variable name 'resolvedFrag' instead of
'resolvedItem'. Without this guard, future contributors can re-introduce
the pattern under any new variable name and only catch it in review. The
guard fails today on those four sites; the next commit deletes them.
…rn sweep missed (SD-2957)

The first sweep used variable name 'resolvedItem' / 'item' / 'resolvedZIndex'
to find dead 'X ?? fragment.Y' patterns. It missed four sites in the PM
attribute path that bind 'resolvedItem' to a narrower local 'resolvedFrag'
and then coalesce '(fragment as ParaFragment).X':

* renderer.ts:6867 — pmStart fallback
* renderer.ts:6873 — pmEnd fallback
* renderer.ts:6879 — continuesFromPrev fallback
* renderer.ts:6885 — continuesOnNext fallback

resolveLayout populates each of these on the resolved item from the source
paragraph (resolveLayout.ts:286-289), making the back-pointer reads dead
exactly as in the prior sweep. Replace with resolvedFrag?.X. The two
truthy-check fallbacks (continuesFromPrev/continuesOnNext) collapse the
'const X = ... ; if (X)' shape to a direct 'if (resolvedFrag?.X)'.

Caught by Guard F (added in the previous commit) and by codex review on
PR #3178.
@codecov-commenter
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment thread packages/layout-engine/painters/dom/src/renderer.ts Outdated
Copy link
Copy Markdown
Contributor

@luccas-harbour luccas-harbour left a comment

Choose a reason for hiding this comment

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

LGTM

I left just a small comment about dropping a ?? 0 for a required field

…idth (SD-2957)

LineSegment.width is typed 'number' (required) by contract. Inside the
text-segment forEach the painter holds a non-optional LineSegment, so
'segment.width ?? 0' is dead — the producer (measuring-dom) always emits
a numeric width, and the contract type system enforces it.

Drop the fallback at both sites (the appendToLineGeo call and the
cumulativeX advance). Behaviour identical for all healthy segments;
non-finite or undefined width would surface as NaN propagation rather
than being silently masked, matching the SD-2957 fail-loudly principle.

Per luccas review on PR #3178.
@tupizz
Copy link
Copy Markdown
Contributor Author

tupizz commented May 6, 2026

Re: @luccas-harbour's comment on ?? 0 for segment.width:

Done in 94a3b833c. Both ?? 0 defaults dropped — segment.width is number (required) by contract and the producer always emits it, so the fallback was dead. Behavior identical for healthy segments; an invalid width now propagates as NaN rather than being silently masked, matching the SD-2957 fail-loudly principle.

Verified: painter-dom 1050/1050 pass, tsc --noEmit clean, architecture + contract-shape tests 23/23.

Thanks for the review!

Copy link
Copy Markdown
Contributor Author

@tupizz tupizz left a comment

Choose a reason for hiding this comment

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

Updates after adresssing codex reviews:
Good catch. Both fixed in two commits:

  • b7d7f6ffc test(architecture): forbid dead fragment.X coalescing in render path — adds Guard F to architecture-boundaries.test.ts. The guard catches ?? fragment.X and ?? (fragment as Y).X patterns (with a tiny allowlist for the documented block.width/height ?? fragment.width/height exception, which has different semantics — image-block natural width vs fragment layout width). My initial sweep grepped resolvedItem?.X ?? fragment.X and item?.X ?? fragment.X and missed the four sites because they used the variable name resolvedFrag. Without this guard a future contributor can re-introduce the pattern under any new variable name and only catch it in review — exactly what just happened. The guard fails today on the four sites you flagged.

  • 852286e94 refactor(painter): delete final 4 fragment.X fallbacks the soft-pattern sweep missed — fixes the four sites (pmStart, pmEnd, continuesFromPrev, continuesOnNext) at renderer.ts:6867/6873/6879/6885. resolveLayout.ts:286–289 populates each field on the resolved item from the source paragraph, so the back-pointer reads were dead exactly as in the prior sweep. Replaced with resolvedFrag?.X; the two truthy-check fallbacks collapsed to direct if (resolvedFrag?.X).

Verified after both commits:

  • Architecture boundary tests: 23/23 pass (Guards A-F + 3 contract-shape lockdowns)
  • painter-dom: 1050/1050 pass, tsc --noEmit clean

The invariant in the docs is now true and ratchet-protected.

@tupizz tupizz merged commit b26d9e4 into main May 6, 2026
67 checks passed
@tupizz tupizz deleted the tadeu/sd-2957-dompainter-measurement-clean branch May 6, 2026 17:26
@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 6, 2026

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

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 6, 2026

🎉 This PR is included in @superdoc-dev/react v1.2.0-next.105

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 6, 2026

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

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 6, 2026

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

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 6, 2026

🎉 This PR is included in superdoc v1.30.0-next.61

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 6, 2026

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

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 7, 2026

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

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 7, 2026

🎉 This PR is included in superdoc v1.32.0

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 7, 2026

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

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 7, 2026

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

The release is available on GitHub release

@superdoc-bot
Copy link
Copy Markdown
Contributor

superdoc-bot Bot commented May 7, 2026

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants