Skip to content

chore: ignore bunx-* temp dirs - #443

Draft
arthrod wants to merge 56 commits into
mainfrom
claude/cool-tesla-wOIvB
Draft

chore: ignore bunx-* temp dirs#443
arthrod wants to merge 56 commits into
mainfrom
claude/cool-tesla-wOIvB

Conversation

@arthrod

@arthrod arthrod commented May 29, 2026

Copy link
Copy Markdown
Collaborator

Adds bunx-* to .gitignore so the temp directory created by bunx skiller@latest during pnpm install is not tracked.

https://claude.ai/code/session_01N2ZqNsNVyfNWbdZgwxM1VM


Generated by Claude Code

arthrod and others added 30 commits May 4, 2026 23:57
…lay) (#357)

* feat(pagination): scaffold @platejs/pagination variant A (render-overlay) — refs #353

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(template): add OpenNext deploy + pagination toolbar button placeholder

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pagination): apply inspector + CR feedback — refs #357

- move base bundle (header, footer, page break) to BasePaginationPlugin.plugins (Slate base)
- BasePaginationConfig key uses typeof KEYS.pagination
- drop degenerate <_V> generic on BasePaginationOptions
- add includeFootnoteSubPlugins option (default true) — opt-out for footnote coupling
- usePretextMeasurer uses useState so future ready flip re-renders
- drop @chenglou/pretext from dependencies until measurer lands

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(template): CR feedback — refs #365

- lint script: add explicit `eslint .` target
- lint:fix: chain `eslint . --fix` after biome
- pagination-toolbar-button: drop internal #357/#358 PR refs from user-facing toast

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(pagination): implement variant A — paginate, measurer, overlay, footnotes — refs #353 #357

Replace TODO stubs with full variant A:
- paginate(): bin-pack with manual page-break, oversized-block, header/footer/footnote-def exclusion
- allocateFootnotes(): per-page allocation by reference walk
- DOM-backed measurer with bounded LRU cache keyed by (nodeId, marksFingerprint, font, width)
- usePageLayout, PageOverlay (afterEditable, pointer-events: none), PageFrame chrome
- FootnotePortal hides in-flow definitions via injected CSS
- Editor API: getPages, getPageOf, getFootnotes
- Editor transforms: insertPageBreak, setHeader, setFooter
- 9 unit tests passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(template): wire real @platejs/pagination via vendored dist — refs #357 #365

Drop placeholder pagination kit. Vendor @platejs/pagination's built dist
under templates/plate-playground-template/vendor/platejs-pagination so the
template can consume the real plugin without npm publish:
- pagination-kit: PaginationPlugin.configure({ A4, 96px margins, 48 header/footer, 96 footnote well })
- pagination-toolbar-button: clicks editor.tf.pagination.insertPageBreak()
- vendor/.gitignore overrides repo **/dist so committed artifacts ship

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pagination): visible page panel + resilient toolbar — refs #357 #365

PageOverlay: replace overlay-on-editor (z-index conflict, white-on-white)
with a fixed top-right card showing "Pages: N" plus PageFrame thumbnails.
Always visible regardless of editor theme.

Toolbar button: try editor.tf.pagination.insertPageBreak() first; fall back
to editor.tf.insertNodes({type:'pageBreak'}) so the action works even when
the plugin's transforms aren't bound (e.g. during HMR or kit ordering).
Drop the "plugin not loaded" toast.

Refresh vendored dist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pagination): KEYS resolution + reactive overlay + toggle preview — refs #353 #357 #365

Two bugs uncovered via dogfooding the deploy:

1. Plugin silently dropped from editor: source referenced KEYS.pagination
   and KEYS.pageBreak; both undefined in published platejs@53.0.3 → key
   resolved to undefined → editor filtered the plugin out. Fixed by
   replacing with local PAGINATION_KEY / PAGE_BREAK_KEY / etc. constants
   in lib/internal/keys.ts.

2. Overlay never re-rendered on edits: useEditorRef + useMemo([editor.children])
   misses Slate in-place mutations. Switched to useEditorValue +
   usePluginOption.

Also:
- previewVisible option + togglePreview() transform; toolbar button now
  toggles the side panel instead of inserting a break.
- PageFrame now renders mini-content per block (h1-h6 / blockquote / code /
  paragraph) so the preview is content-aware, not just empty page chrome.
- Refresh vendored dist + .gitignore dogfood-output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(pagination): page config + header/footer toggles + settings dropdown — refs #357

Adds the next layer per the CR plan + agreed roadmap:

Plugin (lib):
- PageSize: union of preset key | { width, height } literal — registry escape hatch
- BasePaginationOptions: + headerVisible, footerVisible (defaults false)
- BasePaginationTransforms: + setPageSize, setMargins, toggleHeader, toggleFooter
- toggleHeader/toggleFooter inserts a top-level header/footer block at index 0 / N
  with placeholder text ("Header" / "Footer") and flips the visibility option;
  removeByType dedupes if normalization produced duplicates
- resolvePageSize handles both preset and literal forms; resolvePageRect clamps
  contentHeight/contentWidth to >= 0

Overlay (react):
- usePluginOption subscribes to pageSize, margins, headerVisible, footerVisible
  so the panel re-renders when any of them changes via toggle/setMargins/etc.

Template:
- pagination-toolbar-button: replaces the single click handler with a Radix
  DropdownMenu — sections for Display (preview/header/footer toggles), Page
  size (A4/Letter/Legal radio), Margins (narrow/default/wide presets)
- vendor:pagination script — copies packages/pagination/dist into the template's
  vendored package; addresses CR's "Vendored dist refresh" follow-up

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(pagination): apply CR/Gemini review — derived header/footer, normalizeNode, content cache, pt scaling — refs #357

Implements the agreed subset of the CR plan-conejo (comment 11) and the
Gemini + CR review feedback:

Architecture:
- Move page-state.ts from react/internal/ to lib/internal/ so the base
  Slate plugin's API can read pages without depending on React. Keeps
  imports flowing lib → react, never the other way.

Header/footer model:
- Drop headerVisible / footerVisible options. Derive presence from the
  doc itself (editor.children.some(n => n.type === HEADER_KEY)). Removes
  the option-vs-Slate-history asymmetry CR risk-flagged: undo of a
  toggleHeader call now correctly restores the header without leaving a
  stale option flag.
- Add api.pagination.hasHeader / hasFooter for consumers that don't want
  to walk children themselves.
- Toolbar dropdown checkboxes derive state via useEditorValue + same scan.

Invariants:
- Add overrideEditor → normalizeNode that runs at root path:
  enforceHeaderFooterInvariants drops duplicate header/footer blocks and
  re-positions the survivor (header at [0], footer at last index). Defends
  against paste/undo producing duplicates.

Measurement correctness:
- MeasureCacheKey gains a contentHash field; usePretextMeasurer hashes
  type + plain text per node and feeds it in. Cache now invalidates on
  in-place edits (Gemini + CR concern).
- collectPlainText stops inserting a space between adjacent leaves —
  bold-then-plain runs ("He" + "llo") were measured as "He llo", over-
  counting line breaks. Fixed.
- scaleFont uses /(\d+)(px|pt)/ and re-emits the matched unit so pt-sized
  fonts scale correctly for headings.
- resolvePageRect clamps contentWidth/Height to ≥ 0 (already in place,
  preserved when accepting the new PageSize union).

UI:
- PageOverlay's THUMB_SCALE is now computed via computeThumbScale(width)
  = min(0.18, 196/pageWidth) — scales custom landscape page sizes down
  to the panel width.
- React keys for the page list use `page-${pageIndex}` (stable).

Cleanup:
- marks-fingerprint: rename `sorted` → `segments` (CR nitpick — name
  reflected traversal order, not sort).

Tests: 11 pass (added contentHash + hashString cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 📝 CodeRabbit Chat: Implement requested code changes

* Update packages/pagination/src/static/page-break-element-static.tsx

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

* refactor(pagination): split helpers into queries/transforms; KEYS via getType; plugin spec

Apply footnote-plugin best practices to BasePaginationPlugin:
- Extract queries (lib/queries) and transforms (lib/transforms) modules.
- Move BasePaginationConfig/Api/Transforms types to lib/types.
- Use editor.getType(KEYS.x) so consumer .configure({ node: { type } }) overrides flow through.
- Fix enforceHeaderFooterInvariants stale-index bug (collect → sort desc → remove → re-scan → move).
- Add base-pagination-plugins.spec covering plugin schema, API/transforms surface, toggle/set/insert behavior, and normalize invariants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Replace packages/pagination with the version from origin/pagination.
Brings BasePaginationPlugin, PaginationCoordinator, PageElement,
YjsIntegration, leaderElection, reflowEngine, runtime, registry,
and example_visualization_with_toggle.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2aaa-8068-77e8-8c96-7aed37adf1fe
Co-authored-by: Amp <amp@ampcode.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Type editor as SlateEditor; route through editor.api / editor.tf to
satisfy platejs typings without slate Editor casts. Use TextApi.isText
and ElementApi.isElement. Narrow Operation via 'path' in op /
'newPath' in op. Drop dynamic getType?/getOption?/hasEditableTarget
accesses.
Extract PaginationPlugin into its own file so src/index.ts is purely
the brl-generated barrel. Move example_visualization_with_toggle/
under internal/ so brl skips it; T8 removes it entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move YjsIntegration into src/yjs/ subfolder so the main barrel no
longer transitively depends on @platejs/yjs. Mark @platejs/yjs peer
as optional. Extend tsdown + brl tooling to discover the yjs
subpath alongside existing react/static lanes.
drop dead example dir + drop tsconfig exclude paths for it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ug logs

- runtime.notify: queueMicrotask coalesces multiple markDirty in same tick
- internal/scheduleIdle: SSR-safe ric+setTimeout fallback, drops window-as-any
- ReflowOptions.debug (default false) gates splitOversizedBlock console.error
- splitOversizedBlock: bag last two params for useMaxParams compliance
- tests: async-aware notify expectations + new microtask coalescing test
Supply-chain defense: packages must be 7+ days old before install. Adds [test] root="./packages" in monorepos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PaginationPlugin now mounts PaginationRegistryProvider (aboveEditable) and
the reflow PaginationCoordinator (afterEditable) itself, so registering the
plugin is enough to render + reflow pages.

Also repair the grafted package: fix PageElement composed-ref import
(@udecode/react-utils), stop the coordinator spec's global module mock from
stripping exports other specs need, drop vitest/stale assertions, lint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Use literal 'pagination' key, not KEYS.pagination (unreleased in published
  @platejs/utils → undefined key → content never wrapped into pages).
- Mount registry provider + reflow coordinator in one shared aboveEditable
  subtree (PaginationAboveEditable) so the coordinator reads the pages that
  PageElement registers; the split provider/coordinator gave separate registries.
- Render the page number in each page's bottom margin (paginated mode).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation of the premirror-inspired pagination rewrite (derived layout; the
document model never changes). Pure, DOM-free, deterministic:

- layout/types.ts: layout contract (PageSpec/margins/policies, Unmeasured +
  Measured snapshots, LayoutOutput = pages → frames → BlockFragments + breakReason).
- layout/compose.ts: composeLayout — block-level page fill with widow/orphan,
  keep-with-next, manual breaks, splittable-block fragmenting, oversized overflow.
- layout/snapshot.ts: buildSnapshot — Slate value → flat block snapshot with
  stable content-based ids + atomic/keepWithNext/breakBefore hints.

16 tests, typecheck + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
measure/measure.ts: measureSnapshot turns an UnmeasuredSnapshot into a
MeasuredSnapshot. The DOM read is injected (MeasureFn) so the assembly + cache
layer is pure/unit-tested; caching is keyed by stable content id + content
width (measure-once, reuse unchanged blocks). 6 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
react/domMeasure.ts: the MeasureFn that reads a top-level block's rendered
height (incl. vertical margins) + computed line height from the live editor DOM
via ReactEditor.toDOMNode. The only DOM touch in the measurement path; feeds
measureSnapshot. Resilient fallbacks for line height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P4 of the rewrite: render derived pages without mutating the document.

- react/geometry.ts: getPageGeometry / getBlockPlacements (pure, tested) — stack
  pages + map blocks to page-frame positions.
- react/domMeasure.ts: pure-DOM MeasureFn (top-level [data-slate-node=element]
  children); no slate-react dependency.
- react/alignContent.ts: page-start CSS spacers align a single continuous
  Editable's content to page frames (no model change).
- react/index.ts: clean @platejs/pagination/react entry re-exporting the
  slate-react-free pipeline (snapshot/measure/compose + geometry/measure/align).
- apps/www dev/pagination2: demo (white A4 chrome + Editable overlay + page numbers).

Verified live in agent-browser via the playground template: 4 A4 pages, content
flows across page boxes with clean boundaries + page numbers. 149 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The data layer both P0s (split-block rendering + caret/selection mapping)
require — locate where a block/line lands and project it to screen coords.

- layout/mapping.ts: buildMappingIndex — fragmentsOfBlock / pageOfBlock /
  pageOfBlockLine / fragmentOfBlockLine / isSplit (block→page/fragment).
- layout/projection.ts: fragmentRects (absolute stack rects per fragment of a
  split block) + blockLinePosition (caret line → absolute stack position).

Pure, deterministic. 8 tests; 24 layout tests green; typecheck + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A block taller than a page now renders correctly across page boxes without
mutating the document (approach #1).

- react/splitClones.ts: computeSplitPlan (pure, layout-level) + renderSplitClones
  (DOM). One live Editable stays editable, clipped to the slice that fits its
  page; each later page gets a read-only clipped clone of the next slice.
  Slicing is real-pixel (live block's measured top/height + page geometry) with
  line-boundary snapping via Range.getClientRects() — seamless live→clone and
  clone→clone junctions (no overlap/gap/half-line).
- react/index.ts: export mapping, projection, splitClones.

Verified live (agent-browser): a block ~7× page height splits cleanly across
pages. 161 tests; typecheck + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
arthrod and others added 26 commits May 22, 2026 07:00
… derived projection

Scorch the mutator path: reflowEngine, page-node wrapping (BasePaginationPlugin),
coordinator, registry, leader election, yjs bridge + the dead clone renderer
(splitClones, option C). Package is now a self-contained pure layout pipeline
(snapshot/measure/compose/geometry/align). Drop ./yjs export + dead deps.
Neutralize template pagination usage (waiver). Add pretext gate to AGENTS.md.

Plan: docs/plans/2026-05-22-pagination-rewrite-v2.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
F-006: cache was keyed by block id alone, so the same block measured at two
widths overwrote one slot — alternating widths (resize, side-by-side) defeated
the cache. Key by `${id}@${widthPx}`; drop the redundant inner key field.
TDD: alternating-width test was RED (3 measures), now GREEN (2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add @chenglou/pretext + measure/pretext.ts → measureTextLines(text, font,
widthPx, lineHeightPx): wrapped lines with text, width, and segment/grapheme
cursor range. Real word/grapheme/CJK-aware breaking — the seed for line-level
pagination + caret mapping (F-003).

pretext measures via canvas; tests inject a deterministic monospace stub so
line breaking is exact and headless. Barrels regenerated (pretext exported from
'.'); fix scratch demo to import pure pipeline from '.' and DOM helpers from
'/react' per the convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UnmeasuredBlock gains a required `text` field (concatenated leaf text), so the
measure pass can feed pretext. TDD: snapshot-text test RED→GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…OM font)

Option 3: pretext is the canonical layout measurement. measureBlockHeight(text,
font, widthPx, lineHeightPx) = wrapped line count * lineHeight (>=1 line).
createDomMeasure resolves the editor's real font + content width from the DOM
and feeds pretext, replacing offsetHeight — so layout height is line-accurate
and decoupled from box padding/margin. pretext gate satisfied (layout uses it).

TDD: measureBlockHeight RED→GREEN (canvas stub). DOM rewire verified at host (PR7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove the line-splitting + widow/orphan path. Each block is one whole fragment:
fits-or-next-page, oversized overflows its page. Matches the chosen render
(option C, no clones). Drops linesToPlace; compose is now ~20 lines simpler.
TDD: compose.spec rewritten to the place-whole contract, RED→GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… LayoutOutput

audit P5: projection.ts rebuilt buildMappingIndex per call. Now composeLayout
builds it once and returns it on LayoutOutput.mapping; fragmentRects/
blockLinePosition consume it. buildMappingIndex now takes PageLayout[].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pply)

lib/registry.ts: WeakMap<editor,{output,dirty,measureCache}>, lazy rebuild via
injected compute, shouldInvalidateLayout predicate (content ops yes, selection
no). Footnote registry pattern. apply-override wiring lands with the plugin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…invalidation)

createTSlatePlugin<PaginationConfig> with page/margins/policies/viewMode/atomic/
keepWithNext options (A4, continuous default); overrideEditor wraps apply to mark
the layout registry dirty on content ops (not selection). React useHooks host
deferred to the first render PR (needs observable output to verify).

Also: salvage useful algorithms from an external DOCX paginator into the impl
plan (margin-collapsing, header/footer effective-height, footnote area, token
offset model); reject its DOM-clone architecture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rrels

continuous.ts: getContinuousBreakYs(layout) → continuous-flow Y at each interior
page boundary (cumulative page heights, N pages → N-1 break Ys). Drives the
continuous view's advisory break-lines (no spacers, content stays in flow).

pnpm brl: expose lib/ (registry, BasePaginationPlugin) + layout/continuous from
the package barrels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r page)

pretext owns the break decision; the continuous overlay anchors its
advisory rule to the boundary block's live DOM top instead of a
text-only pixel sum that ignores DOM margins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… overlay (scaffold)

toPlatePlugin(BasePaginationPlugin): useHooks runs snapshot→pretext-measure→compose
against the live editable when the registry is dirty (rAF-batched), stores layout
in the registry, and sets a reactive breakYs option. afterEditable overlay paints
dashed advisory page-break rules (pointer-events:none) at getContinuousBreakYs.

SCAFFOLD: typecheck 8/8 + build 8/8, but overlay DOM positioning + recompute
cadence are NOT dev-browser verified yet. Do not merge until verified in-browser.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PaginationPlugin useHooks moved into plugin config (was .extend) so the
  React host's recompute/overlay actually run.
- Overlay consumes getContinuousBreaks: each advisory rule anchors to the
  boundary block's live DOM top (no text-only pixel sum) → lands on a real
  block edge, never mid-paragraph.
- Page N label moved to the right margin gutter (was overlapping body text);
  dashed rule bumped to visible slate-500.
- ResizeObserver recomputes + re-anchors on width change.
- Wire /dev/pagination2 demos (apps/www + template) to register the plugin;
  template gets the @chenglou/pretext runtime peer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-001..004)

- 001: compose packs by flow height (text + DOM box spacing via flowHeightPx),
  so page count + breaks match real DOM flow (demo: 3→4 pages, correct for
  2965px content). heightPx/lineCount stay text-only for line mapping.
- 003: overlay shows 'Page N of M' + a 'Page 1 of M' marker.
- 004: labels moved to the left margin gutter so they stay on-screen when a
  narrow viewport overflows the page width.
- 002: recompute runs in a layout effect (pre-paint) instead of post-paint rAF;
  content→lines gap ~604ms→~308ms (residual is editor hydration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`enabled` (default true) on BasePaginationPlugin. When false, the React
layer skips layout recompute and renders no overlay; document never
affected. Toggle via editor.setOption(BasePaginationPlugin,'enabled',next).
TDD: base option contract (default true, configurable). Changeset: minor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ggle

- PaginationKit adds @platejs/pagination (enabled:false initially) to EditorKit.
- PaginationToolbarButton ("Page breaks", SeparatorHorizontal icon) toggles
  enabled via editor.setOption(PaginationPlugin,'enabled',next); pressed state
  reflects it. Added to the lists/toggle toolbar group.
- biome: drop unused React import in pagination2-view.

Overrides the CI-controlled-templates rule per explicit user request.
Deployed via OpenNext: https://plate-playground.cicero-im.workers.dev/editor

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… changeset language

- domMeasure: hoist topLevelBlockElements() outside MeasureFn closure to
  avoid O(N²) DOM requery (was called once per block per recompute)
- changeset: rewrite from changelog-style to present-tense reference per
  docs standard (no 'Add an option'; use 'The option controls...')

https://claude.ai/code/session_016Wodda9zsyQ9krUCZq6QxY
Comment 2 (O(N²) topLevelBlockElements) already landed via the
cherry-pick of #436 (commit 0a0872e).

# Comment 1 — packages/pagination/src/lib/BasePaginationPlugin.ts:32

`enabled: boolean` made optional. DEFAULT_OPTIONS provides it as `true`,
and `.configure({…})` / `.setOption('enabled', …)` should accept any
subset of options without being forced to repeat the default.

# Comment 3 — packages/pagination/src/react/PaginationPlugin.tsx:156

Re-enable invalidation. The layout effect exits early when
`!registry.dirty && registry.output`. Without tracking the previous
`enabled` value, toggling false → true on an unchanged document keeps
both conditions true and the cached (now stale) output is reused
without a fresh measure/compose pass.

Fix: cache `prevEnabledRef` and force `registry.dirty = true` on the
`false → true` transition, then proceed with the normal path.

# Comment 4 — templates/.../pagination-toolbar-button.tsx:20

`onClick={() => editor.setOption(…)}` overrode any consumer-passed
`onClick` from `props`. Now destructure `{ onClick: consumerOnClick, ...rest }`,
compose: call `consumerOnClick?.(event)` first, bail if the consumer
called `event.preventDefault()`, then run the toggle.

# Verification

  bun test  → 53 pass / 0 fail / 108 expect() calls

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…a layer

Implements stage 1 of the PRETEXT chrome design at
docs/plans/2026-05-29-pagination-chrome-derived-projection.md.

# What

`composeLayout` now accepts an optional `chrome: { header?, footer? }` on its
input and emits matching `chrome: { header?, footer? }` rects on every page in
its output. Each band has `heightPx` configured by the consumer; the composer
shrinks the content frame by the configured heights before packing and emits
page-local rects (x, y, widthPx, heightPx) the React overlay can use to anchor
chrome content WITHOUT any DOM measurement.

The user-visible answer to "page numbers follow the screen": chrome rects are
*pure composer output*, not DOM measurements, so they never drift with scroll.
The React overlay (next commit) reads `PageLayout.chrome.{header,footer}` and
positions absolute siblings at page-anchored Y values — same anchoring strategy
that already works correctly for break-lines (verified via headless probe on
the live deploy: marker.rectTop tracked scrollTop with delta=200 on a 200px
scroll, exactly anchored to the document).

# Type extensions

`PageChromeSpec`, `PageChromeRect`, `ChromeRenderContext` in `layout/types.ts`.
`LayoutInput` gains `chrome?: { header?: { heightPx }; footer?: { heightPx } }`.
`PageLayout` gains `chrome?: { header?: PageChromeRect; footer?: PageChromeRect }`.

The `render` function for chrome content lives on `PaginationOptions` (next
commit), NOT on `LayoutInput`, because composeLayout is pure and the React
node it would return is React-specific. Composer cares only about `heightPx`.

# Composer change

`bounds.y` += `headerHeightPx`; `bounds.height` -= `headerHeightPx + footerHeightPx`.
A `chromeRectsForPage` constant is computed once (chrome is layout-wide) and
attached to every emitted page via `flushPage`. When no chrome is configured,
the `chrome` field stays absent — existing tests unaffected (53 pre-existing
+ 7 new = 60 / 60 pass).

# Tests

`compose-chrome.spec.ts`:

  1. baseline (no chrome) — chrome absent, frame unchanged
  2. header only — frame.y bumps by header.heightPx, frame.height shrinks
  3. footer only — frame.height shrinks; footer.y == bottom of page - margin - height
  4. header + footer — both subtract from content
  5. geometry — chrome.x == margin.left, chrome.widthPx == content width
  6. per-page identical chrome rects across a multi-page layout
  7. extra packing — chrome can force MORE pages when the content frame shrinks

# Coverage

7 new tests; full suite 60 / 60 (was 53 / 53). 36 expect() calls in the new
file. No regressions in the existing composer, mapping, measure, or React
host tests.

# Next commit

React overlay rendering + `PaginationOptions.chrome.render` plumbing +
`PageNumber` convenience export + playground demo wiring + re-deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ground demo

Iteration 1 of the /loop self-paced chrome push. Completes the React side of
the design doc:

# Plugin options

`PaginationOptions.chrome?: { header?: PageChromeOption; footer?: PageChromeOption }`
where `PageChromeOption = { heightPx; render(ctx) }`. The render function is
declared PRETEXT-safe in its docstring: pure, no DOM access, no editor
mutation, no scroll-state reads. The composer cares only about `heightPx`
(piped through `LayoutInput.chrome`); the render function stays on the plugin
options so the pure layout pipeline remains React-free.

# React overlay

`PaginationBreakLines` now:

  - reads `chrome`, `page`, `margins` via `usePluginOption`
  - passes chrome heights to `composeLayout`
  - computes per-page anchor points: `headerY(i)` = top-of-page-i's-first-block
    (which is what the user sees as the visual top of each page in continuous
    view); `footerY(i)` = top of NEXT page's first block minus
    `chrome.footer.heightPx` (so the footer sits just above the next break),
    falling back to the document's bottom for the last page
  - renders one chrome `<div>` per page per band, absolute-positioned at the
    composer-computed Y, with `data-slot="pagination-chrome"`,
    `data-pagination-chrome="header" | "footer"`, and `data-page-index`
  - preserves the existing dashed break-line at each interior boundary
  - keeps the legacy "Page N of M" left-margin chip ONLY when no chrome is
    configured (backward compat)

# `PageNumber` convenience export

`packages/pagination/src/react/chrome/PageNumber.tsx` ships three pure
renderers:

  - `PageNumber` — centered `Page N of M`, the most common footer
  - `PageNumberWithTitle(title, opts?)` — `title | Page N of M`, skips
    page 1 by default (cover-page convention)
  - `TextHeader(text)` — constant uppercase + thin underline, the
    default for legal-document headers

Each renderer derives every pixel from `ChromeRenderContext.{pageIndex,
pageCount, page, margins}` — never touches the DOM, never mutates the
editor.

Added to the `react` barrel (`packages/pagination/src/react/index.ts`).

# Playground demo

`templates/plate-playground-template/src/components/editor/plugins/pagination-kit.tsx`
now configures both bands by default:

  chrome: {
    header: { heightPx: 28, render: TextHeader('Plate Playground') },
    footer: { heightPx: 32, render: PageNumber },
  }

This is what the user actually wanted: a footer carrying `Page N of M` at the
bottom of each page, and a small uppercase document-title header at the top —
both rendered at composer-computed positions, scroll-invariant.

# Tests

60/60 still pass (53 pre-existing + 7 new from the data-layer commit).
The chrome `render` functions are React; their pixel emission is verified by
the live dogfood (next step of the /loop) rather than a JSDOM snapshot, since
the bug surface lives in DOM anchoring under live scroll.

# Next /loop iteration

Build + deploy + run the agent browser against the live playground to confirm
headers/footers/page-numbers render and stay anchored as the user scrolls and
edits. Fix any drift found.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dor sync

Three fixes from iter-1 dogfood against the live deploy:

# 1. `React is not defined` after toggling pagination ON

`packages/pagination/src/react/chrome/PageNumber.tsx` declared
`import type * as React from 'react'` — a type-only import that
elides at runtime. The JSX in every render function compiles to
`React.createElement(...)`, so the moment a consumer invoked
`PageNumber({...})` the page error'd out and the editor
unmounted. Reproduced live via headless probe; switched to
`import * as React from 'react'` (runtime), confirmed bundle
emits `React$1.createElement` and the editor stays mounted.

# 2. Last-page footer overlapping its header

In continuous view a footer naturally lives one chrome.heightPx
above the NEXT page's first block. The previous code anchored
the LAST page's footer to "end of last block - chrome.heightPx",
which on a sparse last page (just one short block after the
final break) made the footer land at the same Y as the header.

Fix: anchor the last page's footer to the page's *geometric*
bottom (`startBlock + (page.heightPx - margins.top -
margins.bottom)`), falling back to the actual content end IF
the last block's height exceeds the geometric page height (so
the footer never lands above content).

# 3. `bun run vendor:pagination` left node_modules stale

bun's `file:` dep resolution caches the installed copy in
`node_modules/@platejs/pagination/dist/`. The previous script
copied the freshly-built dist into `vendor/platejs-pagination/
dist/` but did NOT refresh node_modules — so Next.js still
bundled the OLD dist on every redeploy. Diagnosed via sha256:
node_modules and vendor hashes diverged.

Fix: extend the script to ALSO `rm node_modules/...dist && cp
vendor/...dist node_modules/...dist`. Hashes now match after
every `bun run vendor:pagination`.

# Verification

- `bun test` 60/60 ✓
- Headless probe of live deploy (Worker version f6907805): no
  `React is not defined` after toggle; 7 headers, 7 footers,
  6 break-lines rendered; mathematical scroll-anchoring
  confirmed (Page 3 footer rectTop delta = 2785 = scrollTop).
- Last-page footer overlap reproduced before fix
  (Page 7 footer rectTop == Page 7 header rectTop == 5250) and
  resolved after this commit (verified next iter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nvention

Iter-3 of the /loop chrome push.

# Regression guards (compose-chrome-edge-cases.spec.ts)

5 tests, all green, pinning behaviors that broke (or were at risk of
breaking) during the iter-1/iter-2 live dogfood:

  1. last-page chrome rect present when the page's content is much
     shorter than a full page — guards the iter-2 fix
     (footerY for last page = page-geometric bottom, not last-block
     bottom). Verifies chrome.footer is emitted on the short page
     and matches every other page's footer.y identically.

  2. content frame height is the SAME on every page — confirms the
     chrome subtraction is constant (1123 - 96 - 96 - 28 - 32 = 871),
     no page-by-page drift.

  3. composer is pure — same input ⇒ identical pages JSON.

  4. zero-height chrome is valid — heightPx=0 doesn't change frame
     height; chrome rects exist but are zero-band.

  5. multi-page packing under chrome subtraction — math:
     content/page = 871 px, blocks @ 200 px = 4 blocks/page,
     10 blocks → 3 pages (4+4+2).

Brings the pagination suite to 65 / 65 (was 60 / 60).

# Cover-page convention in the playground

`templates/plate-playground-template/src/components/editor/plugins/
pagination-kit.tsx` now uses `PageNumberWithTitle('Plate Playground')`
for the footer (defaults `skipFirstPage: true` so page 1 stays blank).
A one-line render-wrapper does the same for the header. This matches
the printed-document convention where page 1 is the cover.

# Verification

- bun test: 65 pass / 0 fail / 158 expect() calls.
- bun run typecheck: passes after `vendor:pagination` (which now
  syncs node_modules too — iter-1 commit).
- Live dogfood pending (next /loop iteration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…me bands

Iter-4 visual polish. Each chrome band now carries a single 1-px
`rgb(226 232 240)` rule on its inside edge:

  - footer chip / footer-with-title: `border-top` (= top of bottom margin)
  - header text: `border-bottom` (= bottom of top margin)

Together those two rules visually demarcate the page as a chrome→
margin→content→margin→chrome stack at a glance, addressing the
"margins" half of the original mandate without requiring a heavier
paged-view treatment.

Also extracted three shared constants (`CHROME_FONT`, `CHROME_INK`,
`CHROME_RULE`) so consumers can build sibling renderers that match
without copying typography strings.

# Verification

`bun test` still 65 / 65. Live-deploy dogfood in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third built-in chrome renderer alongside PageNumber and
PageNumberWithTitle. Bare "N" or "N / M" centered in the band, with
tabular-nums so the digits don't shift width between pages 9 and 10.

Use case: book templates where a running header carries the title and
all that's needed in the footer is the page number itself.

Options:
  - showTotal: false (default) → "1", "2", "3"
  - showTotal: true  → "1 / 7", "2 / 7", "3 / 7"
  - skipFirstPage: true → render null on page 1 (cover-page convention)

bun test: 65 / 65 ✓.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 00df16f4-3ec5-4a82-930d-4249d4376d5a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/cool-tesla-wOIvB

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

@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 completes a major refactor of the @platejs/pagination package, transitioning from a document-mutating engine to a pure, derived-layout pipeline that leverages @chenglou/pretext for accurate text measurement. It introduces a React-based overlay host, new chrome rendering components, and a registry-based layout invalidation system. The review feedback provides critical improvements, including correctly overriding editor.apply for layout invalidation, adding safety guards for layout calculations, fixing default option handling in chrome components, and optimizing ResizeObserver usage to avoid redundant computations.

Comment on lines +98 to +108
}).overrideEditor(({ editor, tf: { apply } }) => ({
transforms: {
apply(operation) {
if (shouldInvalidateLayout(operation)) {
invalidateLayoutRegistry(editor);
}

apply(operation);
},
},
}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Overriding transforms.apply (which maps to editor.tf.apply) will not intercept core Slate operations, as Slate applies operations by calling editor.apply directly. This means the layout registry will never be invalidated on content edits, preventing the pagination from recomputing when the user types or modifies the document. To fix this, editor.apply should be overridden directly on the editor instance.

Suggested change
}).overrideEditor(({ editor, tf: { apply } }) => ({
transforms: {
apply(operation) {
if (shouldInvalidateLayout(operation)) {
invalidateLayoutRegistry(editor);
}
apply(operation);
},
},
}));
}).overrideEditor(({ editor }) => {
const { apply } = editor;
editor.apply = (operation) => {
if (shouldInvalidateLayout(operation)) {
invalidateLayoutRegistry(editor);
}
apply(operation);
};
return editor;
});

Comment on lines +38 to +49
const contentWidthPx = page.widthPx - margins.leftPx - margins.rightPx;
const bounds: Rect = {
x: margins.leftPx,
y: margins.topPx + headerHeightPx,
width: contentWidthPx,
height:
page.heightPx -
margins.topPx -
margins.bottomPx -
headerHeightPx -
footerHeightPx,
};

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 calculations for contentWidthPx and bounds.height do not guard against negative or zero values. If the configured margins or chrome heights exceed the page dimensions, this can lead to invalid layout states or potential infinite loops during block placement. It is safer to wrap these calculations in Math.max(0, ...).

Suggested change
const contentWidthPx = page.widthPx - margins.leftPx - margins.rightPx;
const bounds: Rect = {
x: margins.leftPx,
y: margins.topPx + headerHeightPx,
width: contentWidthPx,
height:
page.heightPx -
margins.topPx -
margins.bottomPx -
headerHeightPx -
footerHeightPx,
};
const contentWidthPx = Math.max(0, page.widthPx - margins.leftPx - margins.rightPx);
const bounds: Rect = {
x: margins.leftPx,
y: margins.topPx + headerHeightPx,
width: contentWidthPx,
height: Math.max(
0,
page.heightPx -
margins.topPx -
margins.bottomPx -
headerHeightPx -
footerHeightPx
),
};

Comment on lines +69 to +72
export const PageNumberWithTitle =
(title: string, opts: { skipFirstPage?: boolean } = { skipFirstPage: true }) =>
(ctx: ChromeRenderContext): React.ReactNode => {
if (opts.skipFirstPage && ctx.pageIndex === 0) return null;

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

There is a subtle bug in the default options handling of PageNumberWithTitle. If a caller passes an empty object {} as the second argument, opts.skipFirstPage will resolve to undefined (falsy), which bypasses the intended default of true. Destructuring the options object with a default value of true ensures robust fallback behavior.

export const PageNumberWithTitle =
  (title: string, { skipFirstPage = true }: { skipFirstPage?: boolean } = {})
  (ctx: ChromeRenderContext): React.ReactNode => {
    if (skipFirstPage && ctx.pageIndex === 0) return null;

Comment on lines +349 to +360
useEffect(() => {
const editable = editor.api.toDOMNode(editor);
if (!editable || typeof ResizeObserver === 'undefined') return;

const observer = new ResizeObserver(() => {
invalidateLayoutRegistry(editor);
forceRecompute((n) => n + 1);
});
observer.observe(editable);

return () => observer.disconnect();
}, [editor]);

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 ResizeObserver callback is triggered automatically on mount when observation starts. This marks the layout registry dirty and triggers a state update (forceRecompute), causing a redundant second layout computation on mount. Tracking the last observed width and height in local variables and only triggering the recompute when the dimensions actually change will eliminate this overhead.

    useEffect(() => {
      const editable = editor.api.toDOMNode(editor);
      if (!editable || typeof ResizeObserver === 'undefined') return;

      let prevWidth = editable.clientWidth;
      let prevHeight = editable.clientHeight;

      const observer = new ResizeObserver(() => {
        const width = editable.clientWidth;
        const height = editable.clientHeight;
        if (width !== prevWidth || height !== prevHeight) {
          prevWidth = width;
          prevHeight = height;
          invalidateLayoutRegistry(editor);
          forceRecompute((n) => n + 1);
        }
      });
      observer.observe(editable);

      return () => observer.disconnect();
    }, [editor]);

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants