docs/plans/2026-04-06-navigation-feedback-contract.md
Define the long-term shared navigation-feedback contract for Plate so TOC, footnotes, search jumps, and later anchor surfaces can reuse one editor-scoped primitive instead of local flashes, overlay hacks, or selection repair tricks.
@platejs/core shared navigation pluginPros:
Cons:
@platejs/selection shared hostPros:
Cons:
toc / footnote / searchPros:
Cons:
Choose Option A.
Put the permanent contract in @platejs/core as a small shared navigation
plugin, but keep Phase 1 intentionally narrow.
Do not put the permanent contract in toc, footnote, floating, or
primarily in selection.
selection is a reasonable future home for an optional range/overlay adapter,
not the primary owner of the navigation contract.
That recommendation is not “one giant abstraction for everything that moves the viewport.” It is a slim core contract for the overlap Plate already has:
Search is not part of Phase 1 unless the plan intentionally expands to range semantics.
Current law already says:
Those rules now exist in:
What does not exist yet is the shared runtime contract.
The two current consumers are related, but they are not identical:
So the contract should standardize the overlap, not pretend both flows are one primitive today.
Without a shared contract for that overlap, every feature that jumps to a target will keep re-implementing:
That is dumb, slow to evolve, and guaranteed to drift.
Build a shared navigation feedback contract in packages/core.
The plugin should own:
Feature packages should continue to own target resolution only.
Implement the permanent navigation feedback contract in @platejs/core as a
shared plugin surface with shared transforms and shared render-state injection.
Do not lock Phase 1 to a broader “all navigation” abstraction than current consumers justify.
@platejs/selection as primary host@platejs/floatingcore is where editor-wide contracts belong when they are:
Good:
Cost:
selectionUse a two-layer core shape:
That keeps the API permanent without letting a React store quietly become the real contract.
Add a small editor-scoped lib plugin in packages/core that owns:
Shared transforms:
editor.tf.navigation.flashTarget(...)
editor.tf.navigation.navigate(...)
Add only the React-side plugin/hook surface needed to expose the active nav target to renderers and hooks.
Do not default to a separate NavigationFeedbackStore if plugin/editor
state can feed inject.nodeProps and render hooks cleanly.
Start with the lightest seam that works:
Hard requirement:
inject.nodeProps to add and remove highlight attributes without relying on
unrelated selection churnPhase 1 should support two explicit modes:
Selection-driven navigate For consumers like footnote jumps that own a concrete caret/selection point.
Flash-only target feedback For consumers like TOC that should keep their current non-text-selection navigation behavior while still reusing shared flash timing and replacement semantics.
Do not force TOC into the footnote shape just to make the abstraction look clean.
Feature packages keep doing their own lookup:
After resolution, they call the shared core navigation API.
Default visual implementation:
data-nav-target / data-nav-highlight to the landed nodeWhy this wins:
Only add an overlay or range-painting adapter when a real surface needs:
That later adapter can live in selection.
editor.tf.navigation.flashTarget({
target: { type: "node", path },
variant: "navigated",
});
editor.tf.navigation.navigate({
target: { type: "node", path },
flash: { variant: "navigated" },
focus: true,
scroll: true,
select: {
anchor: point,
focus: point,
},
});
Design intent:
flashTarget(...) is first-class, not a fallback helpernavigate(...) is for selection-driven flows that should coordinate select,
focus, scroll, and flash togetherPhase 1A should support:
node target by Slate pathOptional but deferred:
block-idrangeDo not start with a more generic target algebra unless the rollout explicitly adds a consumer that needs it.
That means search is deferred until the team either:
range into the target model intentionallyAdd a lib plugin lane under packages/core/src/lib/plugins/:
packages/core/src/lib/plugins/navigation-feedback/NavigationFeedbackPlugin.tspackages/core/src/lib/plugins/navigation-feedback/index.tspackages/core/src/lib/plugins/navigation-feedback/types.tspackages/core/src/lib/plugins/navigation-feedback/transforms/flashTarget.tspackages/core/src/lib/plugins/navigation-feedback/transforms/navigate.tspackages/core/src/lib/plugins/navigation-feedback/transforms/index.tsWire the lib plugin into:
packages/core/src/lib/plugins/index.tspackages/core/src/lib/plugins/getCorePlugins.tsAdd a React-side lane under packages/core/src/react/plugins/:
packages/core/src/react/plugins/navigation-feedback/NavigationFeedbackPlugin.tspackages/core/src/react/plugins/navigation-feedback/useNavigationFeedback.tspackages/core/src/react/plugins/navigation-feedback/index.tsWire the React layer into:
packages/core/src/react/plugins/index.tspackages/core/src/react/editor/getPlateCorePlugins.tsIf node-prop injection needs a reusable core helper, prefer reusing existing inject seams instead of creating ad hoc render wrappers:
packages/core/src/internal/plugin/pipeInjectNodeProps.tsxpackages/core/src/internal/plugin/pluginInjectNodeProps.tsIf scroll integration needs a shared option surface, inspect:
packages/core/src/lib/plugins/dom/DOMPlugin.tsDefault split:
Do not make a React store the contract itself unless the render pipeline proves it is necessary.
Footnote:
packages/footnote/src/lib/transforms/focusFootnoteDefinition.tspackages/footnote/src/lib/transforms/focusFootnoteReference.tsTOC:
packages/toc/src/react/hooks/useTocElement.tsSearch:
Initial highlight styling should stay in app/editor UI until there is a proven need to publish package-level styles:
apps/www/src/app/globals.cssDo not block core plugin design on package-owned styling publication.
flashTarget and navigate transforms.data-nav-target / data-nav-highlight.Notes:
nodeIntegrate TOC as a flash-first consumer on the same contract.
Only after Phase 1A/1B prove stable:
range targets or only node targetsOnly if needed:
selectionCore:
getPlateCorePluginsflashTarget sets target statenavigate performs selection + scroll + flash in orderFeature consumers:
Deferred:
Too much generic abstraction too early
Mitigation:
start with the overlap we have actually earned:
flashTarget + selection-driven navigate on node targets
Render-layer coupling Mitigation: keep styling thin and data-attribute based
Core surface bloat
Mitigation:
expose only the minimum flashTarget / navigate transforms first
TOC and footnote are not actually one primitive Mitigation: make both consumer modes explicit in the contract instead of hiding the mismatch
Feature packages still doing local flashes Mitigation: explicitly migrate first consumers and delete local highlight logic
Target state updates do not rerender the node tree cleanly Mitigation: prove the render invalidation seam in Phase 1A; only add a minimal React store if plugin/editor state plus hooks cannot repaint the attributes
Best follow-up roles for execution:
architect
to pressure-test core/plugin ownership and API shapeexecutor
to implement the core plugin and first consumerstest-engineer
to add unit/integration/browser coveragecode-reviewer
for final API and layering reviewverifier
for completion evidencehighmediummediumpackages/coreIf search is pulled into the same implementation wave, treat it as a separate follow-up lane only after the target model decision is explicit.
Build the permanent contract in packages/core as a shared navigation plugin.
Do not hide it in selection, floating, or feature packages just because
those seams already exist.