Skip to content

feat(superdoc/ui): toolbar domain + per-command observables (SD-2796)#2980

Merged
caio-pizzol merged 1 commit into
caio/sd-2794-superdoc-ui-skeletonfrom
caio/sd-2796-ui-toolbar-commands
Apr 28, 2026
Merged

feat(superdoc/ui): toolbar domain + per-command observables (SD-2796)#2980
caio-pizzol merged 1 commit into
caio/sd-2794-superdoc-ui-skeletonfrom
caio/sd-2796-ui-toolbar-commands

Conversation

@caio-pizzol
Copy link
Copy Markdown
Contributor

First domain to land on the new superdoc/ui controller. Adds ui.toolbar (aggregate, HeadlessToolbarController-shaped) and ui.commands.<id> (per-command observables, CKEditor 5 pattern) on top of the SD-2794 selector substrate.

Stacks on #2979 (SD-2794 skeleton). Rebase needed once that merges.

import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Aggregate snapshot
ui.toolbar.subscribe(({ snapshot }) => render(snapshot));
ui.toolbar.execute('bold');

// Per-command observable: bold button only re-renders when bold's state changes
ui.commands.bold.observe(({ active, disabled }) => boldBtn.set(active));
ui.commands.bold.execute();
ui.commands['font-size'].execute('14pt');

The three shapes (ui.select substrate / ui.toolbar aggregate / ui.commands.<id> per-command) are all backed by the same internal createHeadlessToolbar instance feeding state.toolbar. Each per-command observe is internally ui.select((s) => s.toolbar.commands[id], shallowEqual) β€” a 50-button toolbar with per-command observables only re-renders the button whose state changed.

Per-command handles are cached so reference identity is stable (matters for React useMemo deps and for consumers comparing handles). Unknown ids fall back to { active: false, disabled: true, value: undefined } rather than throwing β€” leaves room for the ui.commands.register({ id, execute, getState }) follow-up (FRICTION S3) without breaking forgiving consumers.

Stub-bug fix in create-super-doc-ui.test.ts: fireEditor iterated a live Set while the internal headless-toolbar rebound listeners on every change, picking up the new handler mid-loop and recursing β†’ OOM. Snapshot the handler list before iterating, matching how real editor event buses behave.

Verified:

  • 19 unit tests (11 selector substrate + 8 toolbar/commands)
  • super-editor: 11906 / 13 skipped / 0 fail
  • pnpm --filter superdoc build clean β€” dist/ui.es.js + dist/superdoc/src/ui.d.ts emit; import { createSuperDocUI } from 'superdoc/ui' exposes ui.toolbar and ui.commands.<id> at runtime and type-check

Out of scope (filed under SD-2796 as follow-ups):

  • ui.commands.register({ id, execute, getState }) β€” closes FRICTION S3 (closed toolbar registry)
  • Refactor superdoc/headless-toolbar into a shim around createSuperDocUI({ superdoc }).toolbar
  • Refactor SuperToolbar.vue to consume ui.toolbar (visual / behavior parity validation)

@linear
Copy link
Copy Markdown

linear Bot commented Apr 28, 2026

Adds `ui.toolbar` (aggregate) and `ui.commands.<id>` (per-command) on
top of the SD-2794 selector substrate. The toolbar surface is the
first domain to land on `superdoc/ui`; it makes "bring your own
toolbar" a single mental model that consumers wire to React, Vue, or
vanilla DOM.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Aggregate (HeadlessToolbarController-shaped)
ui.toolbar.subscribe(({ snapshot }) => render(snapshot));
ui.toolbar.execute('bold');

// Per-command observable (CKEditor-style fine-grained binding)
ui.commands.bold.observe(({ active, disabled }) => boldBtn.set(active));
ui.commands.bold.execute();

ui.commands['font-size'].observe(({ value }) => fontInput.set(value));
ui.commands['font-size'].execute('14pt');
```

Implementation:

- Internal `createHeadlessToolbar({ superdoc })` is instantiated once
  per `createSuperDocUI` call and feeds `state.toolbar`. `ui.toolbar`
  delegates `getSnapshot` / `execute` to that controller and routes
  `subscribe` through the substrate so subscribers ride the same
  microtask-coalesced burst pattern as `ui.select` consumers.
- `ui.commands` is a Proxy returning per-id handles, cached by id so
  reference identity is stable (matters for React `useMemo` deps and
  for consumers comparing handles). Each handle's `observe` is
  internally `ui.select((s) => s.toolbar.commands[id], shallowEqual)`,
  so a button bound to one command does not re-render when an
  unrelated command's state changes.
- Unknown command ids fall back to `{ active: false, disabled: true,
  value: undefined }` rather than throwing, so consumer code paths
  remain forgiving while custom-command registration (FRICTION S3)
  is filed as follow-up scope.
- Built-in `SuperToolbar.vue` and `superdoc/headless-toolbar` are
  unchanged here; both will migrate to consume `ui.toolbar` in
  follow-up tickets so this PR stays small and review-friendly.

Why these three shapes coexist (per SD-2667 research):

- Aggregate (`ui.toolbar.subscribe`): matches today's
  `HeadlessToolbarController` shape; lets external consumers using
  `superdoc/headless-toolbar` migrate without rewriting render code.
- Per-command (`ui.commands.<id>.observe`): CKEditor 5 pattern. A 50-
  button toolbar with per-command observables only re-renders the
  button whose state changed, not the whole bar.
- Selector substrate (`ui.select`): the underlying primitive. Custom
  toolbars with cross-command derived state (e.g. "is any heading
  active?") consume that.

Stub-bug found in the SD-2794 test fixture: `fireEditor` iterated a
live Set while the internal headless-toolbar rebound listeners on
every change, picking up the new handler mid-loop and recursing. Fix
snapshots the handler list before iterating, matching how real editor
event buses behave.

Verified: 19 unit tests (11 selector substrate + 8 toolbar/commands).
Full super-editor suite: 11906/13 skipped/0 fail. `pnpm --filter
superdoc build` clean.

Out of scope (filed under SD-2796 as follow-ups inside SD-2667):

- `ui.commands.register({ id, execute, getState })` (closes FRICTION
  S3, the closed toolbar registry).
- Refactor `superdoc/headless-toolbar` into a shim around
  `createSuperDocUI({ superdoc }).toolbar`.
- Refactor `SuperToolbar.vue` to consume `ui.toolbar` (visual /
  behavior parity validation).
@caio-pizzol caio-pizzol force-pushed the caio/sd-2796-ui-toolbar-commands branch from 85ea258 to 75cb241 Compare April 28, 2026 22:30
@caio-pizzol caio-pizzol merged commit 457841c into caio/sd-2794-superdoc-ui-skeleton Apr 28, 2026
42 checks passed
@caio-pizzol caio-pizzol deleted the caio/sd-2796-ui-toolbar-commands branch April 28, 2026 23:37
caio-pizzol added a commit that referenced this pull request Apr 29, 2026
Adds `ui.comments` to `createSuperDocUI`: a single subscription +
actions surface for custom comments sidebars built on top of SuperDoc.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Snapshot
ui.comments.subscribe(({ snapshot }) => {
  // snapshot.items: CommentInfo[]
  // snapshot.activeIds: string[]   (mirrors selection.current().activeCommentIds)
  // snapshot.total:  number
});

// Actions β€” every mutation routes through editor.doc.* (Document API)
ui.comments.createFromSelection({ text });
ui.comments.resolve(commentId);
ui.comments.reopen(commentId);   // routes to comments.patch({ status: 'active' })
ui.comments.delete(commentId);
ui.comments.scrollTo(commentId); // ranges.scrollIntoView({ kind: 'entity', ... })
```

Architecture:

- Subscribe runs through the SD-2794 selector substrate
  (`shallowEqual`-deduped). Comments-list is cached at controller
  level and refreshed on `commentsUpdate` / `commentsLoaded` editor
  events ahead of `scheduleNotify` so `state.comments.items` is fresh
  when subscribers see the next snapshot. `activeIds` reads from
  `selection.current()` per `computeState()` call (cheap; one walk
  the resolver already does for `activeMarks`).
- `activeIds` falls back to `[]` when `selection.current()` predates
  SD-2792 (no `activeCommentIds` field). When SD-2792 lands, the
  fallback becomes never-used; nothing in this PR breaks if SD-2792
  is reverted.
- All execution paths route through `editor.doc.comments.*` and
  `editor.doc.ranges.scrollIntoView`. `ui.comments` is a UI-facing
  adapter, NOT a parallel mutation contract β€” `ui.comments.resolve(id)`
  and `editor.doc.comments.patch({ id, status: 'resolved' })` produce
  the same document mutation by construction.
- `ui.comments.reopen(id)` routes to `comments.patch({ status:
  'active' })`. Today doc-API validation rejects 'active' until
  SD-2789 ships the lifecycle inverse β€” that surfaces an
  INVALID_INPUT receipt rather than a silent no-op, which is the
  correct visible behavior for a not-yet-shipped operation.

Stacks on PR #2979 (SD-2794 skeleton). Unrelated to PR #2980 (SD-2796
toolbar) and PR #2981 (SD-2792 active ids); rebases when those merge.

Verified: 26 unit tests (15 substrate + 11 comments). Full
super-editor: 11913 / 13 skipped / 0 fail. `pnpm --filter superdoc
build` clean β€” `dist/superdoc/src/ui.d.ts` re-exports the new
`CommentsHandle` and `CommentsSlice` types.

Out of scope (separate tickets):

- Threaded reply UX
- `ui.comments.reopen` body β€” depends on SD-2789 (today routes to a
  doc-API path that throws INVALID_INPUT)
- Refactoring `dropin-assessment` SuperDocAdapter to consume
  `ui.comments` instead of `superdoc.on('commentsUpdate')` β€” separate
  PR once stack lands
caio-pizzol added a commit that referenced this pull request Apr 29, 2026
…2982)

* feat(superdoc/ui): comments domain (subscribe + actions) (SD-2790)

Adds `ui.comments` to `createSuperDocUI`: a single subscription +
actions surface for custom comments sidebars built on top of SuperDoc.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Snapshot
ui.comments.subscribe(({ snapshot }) => {
  // snapshot.items: CommentInfo[]
  // snapshot.activeIds: string[]   (mirrors selection.current().activeCommentIds)
  // snapshot.total:  number
});

// Actions β€” every mutation routes through editor.doc.* (Document API)
ui.comments.createFromSelection({ text });
ui.comments.resolve(commentId);
ui.comments.reopen(commentId);   // routes to comments.patch({ status: 'active' })
ui.comments.delete(commentId);
ui.comments.scrollTo(commentId); // ranges.scrollIntoView({ kind: 'entity', ... })
```

Architecture:

- Subscribe runs through the SD-2794 selector substrate
  (`shallowEqual`-deduped). Comments-list is cached at controller
  level and refreshed on `commentsUpdate` / `commentsLoaded` editor
  events ahead of `scheduleNotify` so `state.comments.items` is fresh
  when subscribers see the next snapshot. `activeIds` reads from
  `selection.current()` per `computeState()` call (cheap; one walk
  the resolver already does for `activeMarks`).
- `activeIds` falls back to `[]` when `selection.current()` predates
  SD-2792 (no `activeCommentIds` field). When SD-2792 lands, the
  fallback becomes never-used; nothing in this PR breaks if SD-2792
  is reverted.
- All execution paths route through `editor.doc.comments.*` and
  `editor.doc.ranges.scrollIntoView`. `ui.comments` is a UI-facing
  adapter, NOT a parallel mutation contract β€” `ui.comments.resolve(id)`
  and `editor.doc.comments.patch({ id, status: 'resolved' })` produce
  the same document mutation by construction.
- `ui.comments.reopen(id)` routes to `comments.patch({ status:
  'active' })`. Today doc-API validation rejects 'active' until
  SD-2789 ships the lifecycle inverse β€” that surfaces an
  INVALID_INPUT receipt rather than a silent no-op, which is the
  correct visible behavior for a not-yet-shipped operation.

Stacks on PR #2979 (SD-2794 skeleton). Unrelated to PR #2980 (SD-2796
toolbar) and PR #2981 (SD-2792 active ids); rebases when those merge.

Verified: 26 unit tests (15 substrate + 11 comments). Full
super-editor: 11913 / 13 skipped / 0 fail. `pnpm --filter superdoc
build` clean β€” `dist/superdoc/src/ui.d.ts` re-exports the new
`CommentsHandle` and `CommentsSlice` types.

Out of scope (separate tickets):

- Threaded reply UX
- `ui.comments.reopen` body β€” depends on SD-2789 (today routes to a
  doc-API path that throws INVALID_INPUT)
- Refactoring `dropin-assessment` SuperDocAdapter to consume
  `ui.comments` instead of `superdoc.on('commentsUpdate')` β€” separate
  PR once stack lands

* fix(superdoc/ui): clear comments cache when list() refresh fails

Addresses PR #2982 review (P1). When `editor.doc.comments.list()`
threw mid-refresh, the previous code preserved the prior cache so a
"transient" failure wouldn't blank the UI. Real failure mode that
matters more: during a document / editor swap, the new editor can
throw transiently while initializing, and keeping the prior value
silently leaks the old document's comments into the new editor's
snapshot until the next successful refresh.

Reset to `EMPTY_COMMENTS_LIST` on failure. Briefly rendering an empty
list is a much better failure mode than rendering the wrong
document's comments.

Regression test: a `list()` that throws on `commentsUpdate` clears
the cache so the next snapshot reports `total: 0` / `items: []`.

* fix(superdoc/ui): refresh on own mutations + stable activeIds + barrel exports

Addresses three review findings on PR #2982:

P1: own-mutation refresh. Each ui.comments.* mutation now calls
refreshAndNotify() after the doc-API call returns. The underlying
wrappers don't emit a single canonical event for every mutation
(some go through transaction only, some emit commentsUpdate ahead
of the entity-store finishing) β€” relying on those events alone
left subscribers seeing stale items/total until some later event
fired. Refreshing here makes the post-mutation state visible
synchronously to the next snapshot.

P2-3: activeIds reference stability. Pre-SD-2792 selection results
have no activeCommentIds field; the previous fallback `?? []`
allocated a fresh array per computeState() call, defeating
shallowEqual on the comments snapshot β€” every selection event
re-fired ui.comments.subscribe even when the slice was unchanged.
Use a frozen module-scope sentinel (`EMPTY_ACTIVE_IDS`) so the
array reference is stable.

P2-1: barrel exports. The public `superdoc/ui` shim re-exports
`CommentsHandle` and `CommentsSlice` from `@superdoc/super-editor`,
but the super-editor `src/index.ts` UI types block didn't include
them, so TypeScript consumers got "no exported member" on import.
Added both to the barrel.

P2-2 (clear comments cache on list refresh errors) was already
addressed in 12155be from the previous review round.

Tests: regression for own-mutation refresh (createFromSelection /
resolve / delete each produce a fresh snapshot synchronously) and
for empty-activeIds reference stability across snapshots. 29 UI
tests; super-editor 11916 / 13 skipped / 0 fail.
caio-pizzol added a commit that referenced this pull request Apr 29, 2026
…D-2791)

Adds `ui.review` to `createSuperDocUI`: a single subscription +
actions surface for Word/Google-Docs-style review sidebars that merge
comments and tracked changes into one chronological feed.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Snapshot β€” items + openCount + activeId
ui.review.subscribe(({ snapshot }) => {
  // snapshot.items: ReviewItem[]   (discriminated by `kind: 'comment' | 'change'`)
  // snapshot.openCount: number     (open comments + every tracked change)
  // snapshot.activeId: string|null (selection-driven, plus next/previous)
});

// Decide actions (route through editor.doc.trackChanges.decide)
ui.review.accept(changeId);
ui.review.reject(changeId);
ui.review.acceptAll();   // decide({ scope: 'all' })
ui.review.rejectAll();

// Navigation (UI-only, advances activeId in document order, wraps)
ui.review.next();        // null active β†’ first item; last β†’ wraps to first
ui.review.previous();    // null active β†’ last item; first β†’ wraps to last
ui.review.scrollTo(id);  // ranges.scrollIntoView; sets activeId

// Recording (temporary documentMode flip until SD-2667/S4)
ui.review.setRecording(true);   // 'suggesting' (recording on)
ui.review.setRecording(false);  // 'editing'
```

Architecture mirrors SD-2790's comments domain:

- Tracked-changes list cached at controller level alongside the
  comments cache; refreshes on the same `commentsUpdate` /
  `commentsLoaded` events the existing wrappers fire (track-changes
  events ride that channel today). `list({ in: 'all' })` is requested
  so non-body stories (header / footer / footnote / endnote) are
  included in the merged feed.
- All decide mutations route through `editor.doc.trackChanges.decide`
  (the Document API contract); `ui.review` is a UI-facing adapter,
  not a parallel mutation contract.
- Refresh + notify after every own mutation β€” same posture as
  ui.comments after the SD-2790 review fix.
- Empty-cache fallback on list() throw (cross-document leak
  prevention β€” same posture as ui.comments).

Document-order ranking note: cross-list interleaving between comments
and tracked changes is *not* fully resolved because public
`TrackChangeInfo` lacks a positional `target` field today (separate
ticket from SD-2667). The initial implementation interleaves
comments first (in `comments.list()` order) then tracked changes (in
`list()` order); migration-guide consumers get a stable iteration
order and dense `documentOrder` ranks for next/previous navigation.
When `TrackChangeInfo.target` lands, the merge sort gets refined
transparently.

`ui.review.setRecording(enabled)` is the temporary path until
SD-2667/S4 splits recording from view mode; today it flips
`superdoc.config.documentMode` between 'suggesting' and 'editing'
via `superdoc.setDocumentMode?.()`. Once an independent
`trackChanges.setRecording` primitive ships, the implementation
swaps internally without API churn.

Stacks on PR #2982 (SD-2790 comments domain). Independent of
PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids).

Verified:
- 44 unit tests (15 substrate + 14 comments + 15 review covering
  merged-feed shape, openCount, selection-driven activeId, decide
  routing for all four kinds, next/previous wrap-around behavior,
  empty-feed null returns, scrollTo entity-type routing, recording
  flip)
- super-editor: 11931 / 13 skipped / 0 fail
- `pnpm --filter superdoc build` clean β€” `dist/superdoc/src/ui.d.ts`
  re-exports the new `ReviewHandle`, `ReviewItem`, `ReviewSlice` types.

Out of scope (separate tickets):

- True document-order merge (depends on `TrackChangeInfo.target?:
  TextTarget`)
- Address fidelity for non-body tracked-change scrollTo (SD-2750)
- Independent `trackChanges.setRecording` primitive (SD-2667/S4)
- Refactor `examples/headless/dropin-assessment` SuperDocAdapter to
  consume `ui.review` instead of the manual merge β€” separate PR
  after the stack lands
caio-pizzol added a commit that referenced this pull request Apr 29, 2026
…D-2791)

Adds `ui.review` to `createSuperDocUI`: a single subscription +
actions surface for Word/Google-Docs-style review sidebars that merge
comments and tracked changes into one chronological feed.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Snapshot β€” items + openCount + activeId
ui.review.subscribe(({ snapshot }) => {
  // snapshot.items: ReviewItem[]   (discriminated by `kind: 'comment' | 'change'`)
  // snapshot.openCount: number     (open comments + every tracked change)
  // snapshot.activeId: string|null (selection-driven, plus next/previous)
});

// Decide actions (route through editor.doc.trackChanges.decide)
ui.review.accept(changeId);
ui.review.reject(changeId);
ui.review.acceptAll();   // decide({ scope: 'all' })
ui.review.rejectAll();

// Navigation (UI-only, advances activeId in document order, wraps)
ui.review.next();        // null active β†’ first item; last β†’ wraps to first
ui.review.previous();    // null active β†’ last item; first β†’ wraps to last
ui.review.scrollTo(id);  // ranges.scrollIntoView; sets activeId

// Recording (temporary documentMode flip until SD-2667/S4)
ui.review.setRecording(true);   // 'suggesting' (recording on)
ui.review.setRecording(false);  // 'editing'
```

Architecture mirrors SD-2790's comments domain:

- Tracked-changes list cached at controller level alongside the
  comments cache; refreshes on the same `commentsUpdate` /
  `commentsLoaded` events the existing wrappers fire (track-changes
  events ride that channel today). `list({ in: 'all' })` is requested
  so non-body stories (header / footer / footnote / endnote) are
  included in the merged feed.
- All decide mutations route through `editor.doc.trackChanges.decide`
  (the Document API contract); `ui.review` is a UI-facing adapter,
  not a parallel mutation contract.
- Refresh + notify after every own mutation β€” same posture as
  ui.comments after the SD-2790 review fix.
- Empty-cache fallback on list() throw (cross-document leak
  prevention β€” same posture as ui.comments).

Document-order ranking note: cross-list interleaving between comments
and tracked changes is *not* fully resolved because public
`TrackChangeInfo` lacks a positional `target` field today (separate
ticket from SD-2667). The initial implementation interleaves
comments first (in `comments.list()` order) then tracked changes (in
`list()` order); migration-guide consumers get a stable iteration
order and dense `documentOrder` ranks for next/previous navigation.
When `TrackChangeInfo.target` lands, the merge sort gets refined
transparently.

`ui.review.setRecording(enabled)` is the temporary path until
SD-2667/S4 splits recording from view mode; today it flips
`superdoc.config.documentMode` between 'suggesting' and 'editing'
via `superdoc.setDocumentMode?.()`. Once an independent
`trackChanges.setRecording` primitive ships, the implementation
swaps internally without API churn.

Stacks on PR #2982 (SD-2790 comments domain). Independent of
PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids).

Verified:
- 44 unit tests (15 substrate + 14 comments + 15 review covering
  merged-feed shape, openCount, selection-driven activeId, decide
  routing for all four kinds, next/previous wrap-around behavior,
  empty-feed null returns, scrollTo entity-type routing, recording
  flip)
- super-editor: 11931 / 13 skipped / 0 fail
- `pnpm --filter superdoc build` clean β€” `dist/superdoc/src/ui.d.ts`
  re-exports the new `ReviewHandle`, `ReviewItem`, `ReviewSlice` types.

Out of scope (separate tickets):

- True document-order merge (depends on `TrackChangeInfo.target?:
  TextTarget`)
- Address fidelity for non-body tracked-change scrollTo (SD-2750)
- Independent `trackChanges.setRecording` primitive (SD-2667/S4)
- Refactor `examples/headless/dropin-assessment` SuperDocAdapter to
  consume `ui.review` instead of the manual merge β€” separate PR
  after the stack lands
caio-pizzol added a commit that referenced this pull request Apr 29, 2026
…D-2791) (#2983)

* feat(superdoc/ui): review domain (comments + tracked changes feed) (SD-2791)

Adds `ui.review` to `createSuperDocUI`: a single subscription +
actions surface for Word/Google-Docs-style review sidebars that merge
comments and tracked changes into one chronological feed.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Snapshot β€” items + openCount + activeId
ui.review.subscribe(({ snapshot }) => {
  // snapshot.items: ReviewItem[]   (discriminated by `kind: 'comment' | 'change'`)
  // snapshot.openCount: number     (open comments + every tracked change)
  // snapshot.activeId: string|null (selection-driven, plus next/previous)
});

// Decide actions (route through editor.doc.trackChanges.decide)
ui.review.accept(changeId);
ui.review.reject(changeId);
ui.review.acceptAll();   // decide({ scope: 'all' })
ui.review.rejectAll();

// Navigation (UI-only, advances activeId in document order, wraps)
ui.review.next();        // null active β†’ first item; last β†’ wraps to first
ui.review.previous();    // null active β†’ last item; first β†’ wraps to last
ui.review.scrollTo(id);  // ranges.scrollIntoView; sets activeId

// Recording (temporary documentMode flip until SD-2667/S4)
ui.review.setRecording(true);   // 'suggesting' (recording on)
ui.review.setRecording(false);  // 'editing'
```

Architecture mirrors SD-2790's comments domain:

- Tracked-changes list cached at controller level alongside the
  comments cache; refreshes on the same `commentsUpdate` /
  `commentsLoaded` events the existing wrappers fire (track-changes
  events ride that channel today). `list({ in: 'all' })` is requested
  so non-body stories (header / footer / footnote / endnote) are
  included in the merged feed.
- All decide mutations route through `editor.doc.trackChanges.decide`
  (the Document API contract); `ui.review` is a UI-facing adapter,
  not a parallel mutation contract.
- Refresh + notify after every own mutation β€” same posture as
  ui.comments after the SD-2790 review fix.
- Empty-cache fallback on list() throw (cross-document leak
  prevention β€” same posture as ui.comments).

Document-order ranking note: cross-list interleaving between comments
and tracked changes is *not* fully resolved because public
`TrackChangeInfo` lacks a positional `target` field today (separate
ticket from SD-2667). The initial implementation interleaves
comments first (in `comments.list()` order) then tracked changes (in
`list()` order); migration-guide consumers get a stable iteration
order and dense `documentOrder` ranks for next/previous navigation.
When `TrackChangeInfo.target` lands, the merge sort gets refined
transparently.

`ui.review.setRecording(enabled)` is the temporary path until
SD-2667/S4 splits recording from view mode; today it flips
`superdoc.config.documentMode` between 'suggesting' and 'editing'
via `superdoc.setDocumentMode?.()`. Once an independent
`trackChanges.setRecording` primitive ships, the implementation
swaps internally without API churn.

Stacks on PR #2982 (SD-2790 comments domain). Independent of
PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids).

Verified:
- 44 unit tests (15 substrate + 14 comments + 15 review covering
  merged-feed shape, openCount, selection-driven activeId, decide
  routing for all four kinds, next/previous wrap-around behavior,
  empty-feed null returns, scrollTo entity-type routing, recording
  flip)
- super-editor: 11931 / 13 skipped / 0 fail
- `pnpm --filter superdoc build` clean β€” `dist/superdoc/src/ui.d.ts`
  re-exports the new `ReviewHandle`, `ReviewItem`, `ReviewSlice` types.

Out of scope (separate tickets):

- True document-order merge (depends on `TrackChangeInfo.target?:
  TextTarget`)
- Address fidelity for non-body tracked-change scrollTo (SD-2750)
- Independent `trackChanges.setRecording` primitive (SD-2667/S4)
- Refactor `examples/headless/dropin-assessment` SuperDocAdapter to
  consume `ui.review` instead of the manual merge β€” separate PR
  after the stack lands

* fix(superdoc/ui): review domain edge cases (PR #2983 review)

Four fixes addressing review feedback:

1. Preserve explicit navigation over current selection. Tracks
   lastSelectionDrivenId across computeState calls so that when the
   user calls next()/previous() while the cursor is still on the
   formerly-active item, the next recompute does not snap activeReviewId
   back to the unchanged selection-driven id.

2. Refresh tracked-change cache on external updates. Renames
   COMMENTS_REFRESH_EVENTS to LIST_REFRESH_EVENTS and includes
   trackedChangesUpdate, so a collaborator's accept/reject (or any
   external mutation) refreshes trackChangesListCache without waiting
   for an unrelated comments event.

3. Include story when deciding non-body changes. accept(id)/reject(id)
   look up the change in the cached feed and forward
   address.story to trackChanges.decide so header/footer/footnote
   targets resolve correctly instead of failing target-not-found
   under the body-default lookup.

4. Reuse review items between unchanged snapshots. Memoizes the
   merged feed by source-cache references + activeReviewId so the
   review slice keeps identity stability across typing/selection
   bursts. Subscribers stop re-firing on the editing hot path.

Also restores the JSDoc opener on SuperDocUI.toolbar that was
clipped during a prior rebase, and drops an unused CommentsSlice
type import.

* fix(superdoc/ui): use comments.list discovery id (PR #2983 bot P1)

`comments.list()` returns `DiscoveryItem<CommentDomain>` whose
canonical identifier lives on `id` (set by the adapter from the
underlying commentId). The legacy `commentId` field only exists on
`CommentInfo` (`comments.get`). Reading `comment.commentId` here
emitted `undefined` for every comment row in the merged review feed,
breaking active-id matching and `next/previous/scrollTo` whenever
comments were present.

Updates the review-domain stub to match the production discovery
shape (no `commentId` on items) and pins the regression with a test
that asserts non-empty string ids and end-to-end navigation.
caio-pizzol added a commit that referenced this pull request Apr 29, 2026
…#2980)

Adds `ui.toolbar` (aggregate) and `ui.commands.<id>` (per-command) on
top of the SD-2794 selector substrate. The toolbar surface is the
first domain to land on `superdoc/ui`; it makes "bring your own
toolbar" a single mental model that consumers wire to React, Vue, or
vanilla DOM.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Aggregate (HeadlessToolbarController-shaped)
ui.toolbar.subscribe(({ snapshot }) => render(snapshot));
ui.toolbar.execute('bold');

// Per-command observable (CKEditor-style fine-grained binding)
ui.commands.bold.observe(({ active, disabled }) => boldBtn.set(active));
ui.commands.bold.execute();

ui.commands['font-size'].observe(({ value }) => fontInput.set(value));
ui.commands['font-size'].execute('14pt');
```

Implementation:

- Internal `createHeadlessToolbar({ superdoc })` is instantiated once
  per `createSuperDocUI` call and feeds `state.toolbar`. `ui.toolbar`
  delegates `getSnapshot` / `execute` to that controller and routes
  `subscribe` through the substrate so subscribers ride the same
  microtask-coalesced burst pattern as `ui.select` consumers.
- `ui.commands` is a Proxy returning per-id handles, cached by id so
  reference identity is stable (matters for React `useMemo` deps and
  for consumers comparing handles). Each handle's `observe` is
  internally `ui.select((s) => s.toolbar.commands[id], shallowEqual)`,
  so a button bound to one command does not re-render when an
  unrelated command's state changes.
- Unknown command ids fall back to `{ active: false, disabled: true,
  value: undefined }` rather than throwing, so consumer code paths
  remain forgiving while custom-command registration (FRICTION S3)
  is filed as follow-up scope.
- Built-in `SuperToolbar.vue` and `superdoc/headless-toolbar` are
  unchanged here; both will migrate to consume `ui.toolbar` in
  follow-up tickets so this PR stays small and review-friendly.

Why these three shapes coexist (per SD-2667 research):

- Aggregate (`ui.toolbar.subscribe`): matches today's
  `HeadlessToolbarController` shape; lets external consumers using
  `superdoc/headless-toolbar` migrate without rewriting render code.
- Per-command (`ui.commands.<id>.observe`): CKEditor 5 pattern. A 50-
  button toolbar with per-command observables only re-renders the
  button whose state changed, not the whole bar.
- Selector substrate (`ui.select`): the underlying primitive. Custom
  toolbars with cross-command derived state (e.g. "is any heading
  active?") consume that.

Stub-bug found in the SD-2794 test fixture: `fireEditor` iterated a
live Set while the internal headless-toolbar rebound listeners on
every change, picking up the new handler mid-loop and recursing. Fix
snapshots the handler list before iterating, matching how real editor
event buses behave.

Verified: 19 unit tests (11 selector substrate + 8 toolbar/commands).
Full super-editor suite: 11906/13 skipped/0 fail. `pnpm --filter
superdoc build` clean.

Out of scope (filed under SD-2796 as follow-ups inside SD-2667):

- `ui.commands.register({ id, execute, getState })` (closes FRICTION
  S3, the closed toolbar registry).
- Refactor `superdoc/headless-toolbar` into a shim around
  `createSuperDocUI({ superdoc }).toolbar`.
- Refactor `SuperToolbar.vue` to consume `ui.toolbar` (visual /
  behavior parity validation).
caio-pizzol added a commit that referenced this pull request Apr 29, 2026
…2982)

* feat(superdoc/ui): comments domain (subscribe + actions) (SD-2790)

Adds `ui.comments` to `createSuperDocUI`: a single subscription +
actions surface for custom comments sidebars built on top of SuperDoc.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Snapshot
ui.comments.subscribe(({ snapshot }) => {
  // snapshot.items: CommentInfo[]
  // snapshot.activeIds: string[]   (mirrors selection.current().activeCommentIds)
  // snapshot.total:  number
});

// Actions β€” every mutation routes through editor.doc.* (Document API)
ui.comments.createFromSelection({ text });
ui.comments.resolve(commentId);
ui.comments.reopen(commentId);   // routes to comments.patch({ status: 'active' })
ui.comments.delete(commentId);
ui.comments.scrollTo(commentId); // ranges.scrollIntoView({ kind: 'entity', ... })
```

Architecture:

- Subscribe runs through the SD-2794 selector substrate
  (`shallowEqual`-deduped). Comments-list is cached at controller
  level and refreshed on `commentsUpdate` / `commentsLoaded` editor
  events ahead of `scheduleNotify` so `state.comments.items` is fresh
  when subscribers see the next snapshot. `activeIds` reads from
  `selection.current()` per `computeState()` call (cheap; one walk
  the resolver already does for `activeMarks`).
- `activeIds` falls back to `[]` when `selection.current()` predates
  SD-2792 (no `activeCommentIds` field). When SD-2792 lands, the
  fallback becomes never-used; nothing in this PR breaks if SD-2792
  is reverted.
- All execution paths route through `editor.doc.comments.*` and
  `editor.doc.ranges.scrollIntoView`. `ui.comments` is a UI-facing
  adapter, NOT a parallel mutation contract β€” `ui.comments.resolve(id)`
  and `editor.doc.comments.patch({ id, status: 'resolved' })` produce
  the same document mutation by construction.
- `ui.comments.reopen(id)` routes to `comments.patch({ status:
  'active' })`. Today doc-API validation rejects 'active' until
  SD-2789 ships the lifecycle inverse β€” that surfaces an
  INVALID_INPUT receipt rather than a silent no-op, which is the
  correct visible behavior for a not-yet-shipped operation.

Stacks on PR #2979 (SD-2794 skeleton). Unrelated to PR #2980 (SD-2796
toolbar) and PR #2981 (SD-2792 active ids); rebases when those merge.

Verified: 26 unit tests (15 substrate + 11 comments). Full
super-editor: 11913 / 13 skipped / 0 fail. `pnpm --filter superdoc
build` clean β€” `dist/superdoc/src/ui.d.ts` re-exports the new
`CommentsHandle` and `CommentsSlice` types.

Out of scope (separate tickets):

- Threaded reply UX
- `ui.comments.reopen` body β€” depends on SD-2789 (today routes to a
  doc-API path that throws INVALID_INPUT)
- Refactoring `dropin-assessment` SuperDocAdapter to consume
  `ui.comments` instead of `superdoc.on('commentsUpdate')` β€” separate
  PR once stack lands

* fix(superdoc/ui): clear comments cache when list() refresh fails

Addresses PR #2982 review (P1). When `editor.doc.comments.list()`
threw mid-refresh, the previous code preserved the prior cache so a
"transient" failure wouldn't blank the UI. Real failure mode that
matters more: during a document / editor swap, the new editor can
throw transiently while initializing, and keeping the prior value
silently leaks the old document's comments into the new editor's
snapshot until the next successful refresh.

Reset to `EMPTY_COMMENTS_LIST` on failure. Briefly rendering an empty
list is a much better failure mode than rendering the wrong
document's comments.

Regression test: a `list()` that throws on `commentsUpdate` clears
the cache so the next snapshot reports `total: 0` / `items: []`.

* fix(superdoc/ui): refresh on own mutations + stable activeIds + barrel exports

Addresses three review findings on PR #2982:

P1: own-mutation refresh. Each ui.comments.* mutation now calls
refreshAndNotify() after the doc-API call returns. The underlying
wrappers don't emit a single canonical event for every mutation
(some go through transaction only, some emit commentsUpdate ahead
of the entity-store finishing) β€” relying on those events alone
left subscribers seeing stale items/total until some later event
fired. Refreshing here makes the post-mutation state visible
synchronously to the next snapshot.

P2-3: activeIds reference stability. Pre-SD-2792 selection results
have no activeCommentIds field; the previous fallback `?? []`
allocated a fresh array per computeState() call, defeating
shallowEqual on the comments snapshot β€” every selection event
re-fired ui.comments.subscribe even when the slice was unchanged.
Use a frozen module-scope sentinel (`EMPTY_ACTIVE_IDS`) so the
array reference is stable.

P2-1: barrel exports. The public `superdoc/ui` shim re-exports
`CommentsHandle` and `CommentsSlice` from `@superdoc/super-editor`,
but the super-editor `src/index.ts` UI types block didn't include
them, so TypeScript consumers got "no exported member" on import.
Added both to the barrel.

P2-2 (clear comments cache on list refresh errors) was already
addressed in 12155be from the previous review round.

Tests: regression for own-mutation refresh (createFromSelection /
resolve / delete each produce a fresh snapshot synchronously) and
for empty-activeIds reference stability across snapshots. 29 UI
tests; super-editor 11916 / 13 skipped / 0 fail.
caio-pizzol added a commit that referenced this pull request Apr 29, 2026
…D-2791) (#2983)

* feat(superdoc/ui): review domain (comments + tracked changes feed) (SD-2791)

Adds `ui.review` to `createSuperDocUI`: a single subscription +
actions surface for Word/Google-Docs-style review sidebars that merge
comments and tracked changes into one chronological feed.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Snapshot β€” items + openCount + activeId
ui.review.subscribe(({ snapshot }) => {
  // snapshot.items: ReviewItem[]   (discriminated by `kind: 'comment' | 'change'`)
  // snapshot.openCount: number     (open comments + every tracked change)
  // snapshot.activeId: string|null (selection-driven, plus next/previous)
});

// Decide actions (route through editor.doc.trackChanges.decide)
ui.review.accept(changeId);
ui.review.reject(changeId);
ui.review.acceptAll();   // decide({ scope: 'all' })
ui.review.rejectAll();

// Navigation (UI-only, advances activeId in document order, wraps)
ui.review.next();        // null active β†’ first item; last β†’ wraps to first
ui.review.previous();    // null active β†’ last item; first β†’ wraps to last
ui.review.scrollTo(id);  // ranges.scrollIntoView; sets activeId

// Recording (temporary documentMode flip until SD-2667/S4)
ui.review.setRecording(true);   // 'suggesting' (recording on)
ui.review.setRecording(false);  // 'editing'
```

Architecture mirrors SD-2790's comments domain:

- Tracked-changes list cached at controller level alongside the
  comments cache; refreshes on the same `commentsUpdate` /
  `commentsLoaded` events the existing wrappers fire (track-changes
  events ride that channel today). `list({ in: 'all' })` is requested
  so non-body stories (header / footer / footnote / endnote) are
  included in the merged feed.
- All decide mutations route through `editor.doc.trackChanges.decide`
  (the Document API contract); `ui.review` is a UI-facing adapter,
  not a parallel mutation contract.
- Refresh + notify after every own mutation β€” same posture as
  ui.comments after the SD-2790 review fix.
- Empty-cache fallback on list() throw (cross-document leak
  prevention β€” same posture as ui.comments).

Document-order ranking note: cross-list interleaving between comments
and tracked changes is *not* fully resolved because public
`TrackChangeInfo` lacks a positional `target` field today (separate
ticket from SD-2667). The initial implementation interleaves
comments first (in `comments.list()` order) then tracked changes (in
`list()` order); migration-guide consumers get a stable iteration
order and dense `documentOrder` ranks for next/previous navigation.
When `TrackChangeInfo.target` lands, the merge sort gets refined
transparently.

`ui.review.setRecording(enabled)` is the temporary path until
SD-2667/S4 splits recording from view mode; today it flips
`superdoc.config.documentMode` between 'suggesting' and 'editing'
via `superdoc.setDocumentMode?.()`. Once an independent
`trackChanges.setRecording` primitive ships, the implementation
swaps internally without API churn.

Stacks on PR #2982 (SD-2790 comments domain). Independent of
PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids).

Verified:
- 44 unit tests (15 substrate + 14 comments + 15 review covering
  merged-feed shape, openCount, selection-driven activeId, decide
  routing for all four kinds, next/previous wrap-around behavior,
  empty-feed null returns, scrollTo entity-type routing, recording
  flip)
- super-editor: 11931 / 13 skipped / 0 fail
- `pnpm --filter superdoc build` clean β€” `dist/superdoc/src/ui.d.ts`
  re-exports the new `ReviewHandle`, `ReviewItem`, `ReviewSlice` types.

Out of scope (separate tickets):

- True document-order merge (depends on `TrackChangeInfo.target?:
  TextTarget`)
- Address fidelity for non-body tracked-change scrollTo (SD-2750)
- Independent `trackChanges.setRecording` primitive (SD-2667/S4)
- Refactor `examples/headless/dropin-assessment` SuperDocAdapter to
  consume `ui.review` instead of the manual merge β€” separate PR
  after the stack lands

* fix(superdoc/ui): review domain edge cases (PR #2983 review)

Four fixes addressing review feedback:

1. Preserve explicit navigation over current selection. Tracks
   lastSelectionDrivenId across computeState calls so that when the
   user calls next()/previous() while the cursor is still on the
   formerly-active item, the next recompute does not snap activeReviewId
   back to the unchanged selection-driven id.

2. Refresh tracked-change cache on external updates. Renames
   COMMENTS_REFRESH_EVENTS to LIST_REFRESH_EVENTS and includes
   trackedChangesUpdate, so a collaborator's accept/reject (or any
   external mutation) refreshes trackChangesListCache without waiting
   for an unrelated comments event.

3. Include story when deciding non-body changes. accept(id)/reject(id)
   look up the change in the cached feed and forward
   address.story to trackChanges.decide so header/footer/footnote
   targets resolve correctly instead of failing target-not-found
   under the body-default lookup.

4. Reuse review items between unchanged snapshots. Memoizes the
   merged feed by source-cache references + activeReviewId so the
   review slice keeps identity stability across typing/selection
   bursts. Subscribers stop re-firing on the editing hot path.

Also restores the JSDoc opener on SuperDocUI.toolbar that was
clipped during a prior rebase, and drops an unused CommentsSlice
type import.

* fix(superdoc/ui): use comments.list discovery id (PR #2983 bot P1)

`comments.list()` returns `DiscoveryItem<CommentDomain>` whose
canonical identifier lives on `id` (set by the adapter from the
underlying commentId). The legacy `commentId` field only exists on
`CommentInfo` (`comments.get`). Reading `comment.commentId` here
emitted `undefined` for every comment row in the merged review feed,
breaking active-id matching and `next/previous/scrollTo` whenever
comments were present.

Updates the review-domain stub to match the production discovery
shape (no `commentId` on items) and pins the regression with a test
that asserts non-empty string ids and end-to-end navigation.
caio-pizzol added a commit that referenced this pull request Apr 29, 2026
…-2794) (#2979)

* feat(superdoc/ui): createSuperDocUI skeleton + selector substrate (SD-2794)

Foundational ticket for the `superdoc/ui` package. Sibling tickets layer
domain namespaces (toolbar, commands, comments, review, viewport,
selection) on top of `ui.select`.

The architectural counterpart to the Document API:

- `editor.doc.*`: stateless contract, server + client, request/response
- `createSuperDocUI({ superdoc })`: browser-only state controller

Why this exists:

- Subscriptions, viewport geometry, and per-command observables don't
  fit on a request/response contract. The closed PR #2978 surfaced this
  by forcing a `META_MEMBER_PATHS` exemption on the doc-api.
- `superdoc/headless-toolbar` already shipped a working controller
  pattern; this package is the same shape but extends to comments,
  tracked changes, viewport, and selection.

API:

```ts
import { createSuperDocUI, shallowEqual } from 'superdoc/ui';

const ui = createSuperDocUI({ superdoc });

const sub = ui.select(
  (state) => ({ mode: state.documentMode, empty: state.selection.empty }),
  shallowEqual,
);
const off = sub.subscribe((slice) => render(slice));

ui.destroy();
```

Selector substrate is the canonical observation primitive everything
else is built on. Domain namespaces, per-command observables, and React/
Vue adapters are wrappers over `ui.select`. Default equality is
`Object.is`; `shallowEqual` is exported for object slices.

Internals:

- One unified state model (selection slice + documentMode for now).
  Sibling tickets extend the shape via TypeScript module augmentation.
- Source events normalized: `transaction`, `selectionUpdate`,
  `commentsUpdate`, `commentsLoaded`, `comment-positions`,
  `trackedChangesUpdate` from the editor; `editorCreate`,
  `document-mode-change`, `zoomChange` from SuperDoc. Consumers never
  see editor-internal vocabulary.
- Bursts coalesced to one snapshot rebuild per microtask so multi-step
  transactions and DOCX reload don't storm subscribers.
- Listener errors are caught so one buggy subscriber can't wedge the
  editor's event loop.
- `editorCreate` re-attaches editor listeners when activeEditor swaps.
- `destroy()` tears down all source listeners.

Package layout mirrors `superdoc/headless-toolbar`:

- Source: `packages/super-editor/src/ui/`
- Public sub-entry: `superdoc/ui` via re-export shim at
  `packages/superdoc/src/ui.js`
- Sub-paths reserved for `superdoc/ui/react` and `superdoc/ui/vue`
  (filed separately).

Verified:

- 11 unit tests cover initial-emit, dedup-by-equality, microtask
  coalescing, multi-subscriber lifecycle, destroy teardown, editor swap,
  listener-error isolation
- super-editor: 11898/13 skipped/0 fail
- superdoc package builds: `dist/ui.es.js` + `dist/superdoc/src/ui.d.ts`
  emit; `import { createSuperDocUI } from 'superdoc/ui'` resolves at
  runtime and type-check

Out of scope (sibling tickets under SD-2667):

- ui.toolbar / ui.commands.* (SD-2796)
- ui.comments (SD-2790)
- ui.review (SD-2791)
- ui.viewport (SD-2793)
- React + Vue adapters
- Refactoring SuperToolbar.vue / CommentsLayer to consume superdoc/ui
- Migrating selection.onChange off doc-api (SD-2795)

* fix(superdoc/ui): route selection through PresentationEditor + expose public types

Addresses two review findings on PR #2979:

1. **Selection followed `superdoc.activeEditor` instead of the routed
   editor.** When the user is editing a header / footer / footnote /
   endnote inside a paginated SuperDoc, `superdoc.activeEditor` stays
   on the body editor while `PresentationEditor.getActiveEditor()`
   routes to the story editor. Reading selection from
   `superdoc.activeEditor.doc` left `state.selection` on the body
   selection, and the controller never subscribed to the routed
   editor's events β€” so any UI built on
   `ui.select((s) => s.selection, ...)` was wrong outside the body.

   Fix: reuse the existing `resolveToolbarSources` helper from
   `headless-toolbar` so the UI controller, the toolbar registry, and
   any future domain agree on which editor is active. Read selection
   through that routed editor in `computeState()`. Subscribe to the
   PresentationEditor's `headerFooterEditingContext`,
   `headerFooterUpdate`, `headerFooterTransaction`,
   `activeSurfaceChange`, and `historyStateChange` events; on
   `activeSurfaceChange` re-attach editor listeners to the new routed
   editor and notify subscribers. Falls back cleanly to
   `superdoc.activeEditor` when no PresentationEditor is mounted (e.g.
   server-side stubs in tests).

2. **`superdoc/ui` public sub-entry didn't re-export types.** The
   shim at `packages/superdoc/src/ui.js` only re-exported values, so
   the generated `dist/superdoc/src/ui.d.ts` didn't carry the
   `SuperDocUI`, `SuperDocUIState`, `SuperDocUIOptions`, etc. types.
   `import type { SuperDocUIState } from 'superdoc/ui'` failed for
   consumers building typed wrappers.

   Fix: add a sibling `packages/superdoc/src/ui.d.ts` that re-exports
   types alongside values, mirroring the existing
   `headless-toolbar.d.ts` pattern.

Test stub also got the snapshot-on-iterate fix (mirrors the SD-2796
PR's stub fix): when handlers re-attach to the same Set during
iteration (now possible via presentation re-routing), the test bus
must iterate a frozen list to avoid recursion.

Verified: 12 unit tests (added presentation-routing test). Full
super-editor suite: 11899 / 13 skipped / 0 fail. `pnpm --filter
superdoc build` clean β€” `dist/superdoc/src/ui.d.ts` now re-exports
the type set, `import type { SuperDocUIState } from 'superdoc/ui'`
resolves.

* fix(superdoc/ui): refcount selector listener so unsubscribed selectors don't leak

Addresses Codex P1 finding on PR #2979.

Before: `ui.select(selector).subscribe(cb)` permanently registered an
`onStateChange` closure in the controller's `stateChangeListeners`,
even after the consumer's unsubscribe ran. Long-lived sessions with
React/Vue components that mount and unmount accumulated dead closures
that still recomputed on every editor event β€” unbounded memory growth
and O(N) extra work per event.

Fix: refcount the controller-level listener.

- First `subscribe()` on a slice attaches `onStateChange` to
  `stateChangeListeners` and refreshes `last` so the initial emit
  isn't stale (state may have evolved between `select()` and
  `subscribe()`).
- Last unsubscribe detaches `onStateChange`. Subsequent editor events
  do not invoke this slice's selector.
- `get()` recomputes when no subscribers are attached so untracked
  snapshots stay accurate.

Regression tests:

- 100 select+subscribe+unsubscribe cycles followed by one editor
  event must invoke the selector zero times (no stale closures).
- A slice with two subscribers; first unsubscribe keeps the listener
  active, second detaches it.
- `get()` returns fresh state after upstream changes when no
  subscribers are attached.

* feat(superdoc/ui): toolbar domain + per-command observables (SD-2796) (#2980)

Adds `ui.toolbar` (aggregate) and `ui.commands.<id>` (per-command) on
top of the SD-2794 selector substrate. The toolbar surface is the
first domain to land on `superdoc/ui`; it makes "bring your own
toolbar" a single mental model that consumers wire to React, Vue, or
vanilla DOM.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Aggregate (HeadlessToolbarController-shaped)
ui.toolbar.subscribe(({ snapshot }) => render(snapshot));
ui.toolbar.execute('bold');

// Per-command observable (CKEditor-style fine-grained binding)
ui.commands.bold.observe(({ active, disabled }) => boldBtn.set(active));
ui.commands.bold.execute();

ui.commands['font-size'].observe(({ value }) => fontInput.set(value));
ui.commands['font-size'].execute('14pt');
```

Implementation:

- Internal `createHeadlessToolbar({ superdoc })` is instantiated once
  per `createSuperDocUI` call and feeds `state.toolbar`. `ui.toolbar`
  delegates `getSnapshot` / `execute` to that controller and routes
  `subscribe` through the substrate so subscribers ride the same
  microtask-coalesced burst pattern as `ui.select` consumers.
- `ui.commands` is a Proxy returning per-id handles, cached by id so
  reference identity is stable (matters for React `useMemo` deps and
  for consumers comparing handles). Each handle's `observe` is
  internally `ui.select((s) => s.toolbar.commands[id], shallowEqual)`,
  so a button bound to one command does not re-render when an
  unrelated command's state changes.
- Unknown command ids fall back to `{ active: false, disabled: true,
  value: undefined }` rather than throwing, so consumer code paths
  remain forgiving while custom-command registration (FRICTION S3)
  is filed as follow-up scope.
- Built-in `SuperToolbar.vue` and `superdoc/headless-toolbar` are
  unchanged here; both will migrate to consume `ui.toolbar` in
  follow-up tickets so this PR stays small and review-friendly.

Why these three shapes coexist (per SD-2667 research):

- Aggregate (`ui.toolbar.subscribe`): matches today's
  `HeadlessToolbarController` shape; lets external consumers using
  `superdoc/headless-toolbar` migrate without rewriting render code.
- Per-command (`ui.commands.<id>.observe`): CKEditor 5 pattern. A 50-
  button toolbar with per-command observables only re-renders the
  button whose state changed, not the whole bar.
- Selector substrate (`ui.select`): the underlying primitive. Custom
  toolbars with cross-command derived state (e.g. "is any heading
  active?") consume that.

Stub-bug found in the SD-2794 test fixture: `fireEditor` iterated a
live Set while the internal headless-toolbar rebound listeners on
every change, picking up the new handler mid-loop and recursing. Fix
snapshots the handler list before iterating, matching how real editor
event buses behave.

Verified: 19 unit tests (11 selector substrate + 8 toolbar/commands).
Full super-editor suite: 11906/13 skipped/0 fail. `pnpm --filter
superdoc build` clean.

Out of scope (filed under SD-2796 as follow-ups inside SD-2667):

- `ui.commands.register({ id, execute, getState })` (closes FRICTION
  S3, the closed toolbar registry).
- Refactor `superdoc/headless-toolbar` into a shim around
  `createSuperDocUI({ superdoc }).toolbar`.
- Refactor `SuperToolbar.vue` to consume `ui.toolbar` (visual /
  behavior parity validation).

* feat(superdoc/ui): comments domain (subscribe + actions) (SD-2790) (#2982)

* feat(superdoc/ui): comments domain (subscribe + actions) (SD-2790)

Adds `ui.comments` to `createSuperDocUI`: a single subscription +
actions surface for custom comments sidebars built on top of SuperDoc.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Snapshot
ui.comments.subscribe(({ snapshot }) => {
  // snapshot.items: CommentInfo[]
  // snapshot.activeIds: string[]   (mirrors selection.current().activeCommentIds)
  // snapshot.total:  number
});

// Actions β€” every mutation routes through editor.doc.* (Document API)
ui.comments.createFromSelection({ text });
ui.comments.resolve(commentId);
ui.comments.reopen(commentId);   // routes to comments.patch({ status: 'active' })
ui.comments.delete(commentId);
ui.comments.scrollTo(commentId); // ranges.scrollIntoView({ kind: 'entity', ... })
```

Architecture:

- Subscribe runs through the SD-2794 selector substrate
  (`shallowEqual`-deduped). Comments-list is cached at controller
  level and refreshed on `commentsUpdate` / `commentsLoaded` editor
  events ahead of `scheduleNotify` so `state.comments.items` is fresh
  when subscribers see the next snapshot. `activeIds` reads from
  `selection.current()` per `computeState()` call (cheap; one walk
  the resolver already does for `activeMarks`).
- `activeIds` falls back to `[]` when `selection.current()` predates
  SD-2792 (no `activeCommentIds` field). When SD-2792 lands, the
  fallback becomes never-used; nothing in this PR breaks if SD-2792
  is reverted.
- All execution paths route through `editor.doc.comments.*` and
  `editor.doc.ranges.scrollIntoView`. `ui.comments` is a UI-facing
  adapter, NOT a parallel mutation contract β€” `ui.comments.resolve(id)`
  and `editor.doc.comments.patch({ id, status: 'resolved' })` produce
  the same document mutation by construction.
- `ui.comments.reopen(id)` routes to `comments.patch({ status:
  'active' })`. Today doc-API validation rejects 'active' until
  SD-2789 ships the lifecycle inverse β€” that surfaces an
  INVALID_INPUT receipt rather than a silent no-op, which is the
  correct visible behavior for a not-yet-shipped operation.

Stacks on PR #2979 (SD-2794 skeleton). Unrelated to PR #2980 (SD-2796
toolbar) and PR #2981 (SD-2792 active ids); rebases when those merge.

Verified: 26 unit tests (15 substrate + 11 comments). Full
super-editor: 11913 / 13 skipped / 0 fail. `pnpm --filter superdoc
build` clean β€” `dist/superdoc/src/ui.d.ts` re-exports the new
`CommentsHandle` and `CommentsSlice` types.

Out of scope (separate tickets):

- Threaded reply UX
- `ui.comments.reopen` body β€” depends on SD-2789 (today routes to a
  doc-API path that throws INVALID_INPUT)
- Refactoring `dropin-assessment` SuperDocAdapter to consume
  `ui.comments` instead of `superdoc.on('commentsUpdate')` β€” separate
  PR once stack lands

* fix(superdoc/ui): clear comments cache when list() refresh fails

Addresses PR #2982 review (P1). When `editor.doc.comments.list()`
threw mid-refresh, the previous code preserved the prior cache so a
"transient" failure wouldn't blank the UI. Real failure mode that
matters more: during a document / editor swap, the new editor can
throw transiently while initializing, and keeping the prior value
silently leaks the old document's comments into the new editor's
snapshot until the next successful refresh.

Reset to `EMPTY_COMMENTS_LIST` on failure. Briefly rendering an empty
list is a much better failure mode than rendering the wrong
document's comments.

Regression test: a `list()` that throws on `commentsUpdate` clears
the cache so the next snapshot reports `total: 0` / `items: []`.

* fix(superdoc/ui): refresh on own mutations + stable activeIds + barrel exports

Addresses three review findings on PR #2982:

P1: own-mutation refresh. Each ui.comments.* mutation now calls
refreshAndNotify() after the doc-API call returns. The underlying
wrappers don't emit a single canonical event for every mutation
(some go through transaction only, some emit commentsUpdate ahead
of the entity-store finishing) β€” relying on those events alone
left subscribers seeing stale items/total until some later event
fired. Refreshing here makes the post-mutation state visible
synchronously to the next snapshot.

P2-3: activeIds reference stability. Pre-SD-2792 selection results
have no activeCommentIds field; the previous fallback `?? []`
allocated a fresh array per computeState() call, defeating
shallowEqual on the comments snapshot β€” every selection event
re-fired ui.comments.subscribe even when the slice was unchanged.
Use a frozen module-scope sentinel (`EMPTY_ACTIVE_IDS`) so the
array reference is stable.

P2-1: barrel exports. The public `superdoc/ui` shim re-exports
`CommentsHandle` and `CommentsSlice` from `@superdoc/super-editor`,
but the super-editor `src/index.ts` UI types block didn't include
them, so TypeScript consumers got "no exported member" on import.
Added both to the barrel.

P2-2 (clear comments cache on list refresh errors) was already
addressed in 12155be from the previous review round.

Tests: regression for own-mutation refresh (createFromSelection /
resolve / delete each produce a fresh snapshot synchronously) and
for empty-activeIds reference stability across snapshots. 29 UI
tests; super-editor 11916 / 13 skipped / 0 fail.

* feat(superdoc/ui): review domain (comments + tracked changes feed) (SD-2791) (#2983)

* feat(superdoc/ui): review domain (comments + tracked changes feed) (SD-2791)

Adds `ui.review` to `createSuperDocUI`: a single subscription +
actions surface for Word/Google-Docs-style review sidebars that merge
comments and tracked changes into one chronological feed.

```ts
import { createSuperDocUI } from 'superdoc/ui';
const ui = createSuperDocUI({ superdoc });

// Snapshot β€” items + openCount + activeId
ui.review.subscribe(({ snapshot }) => {
  // snapshot.items: ReviewItem[]   (discriminated by `kind: 'comment' | 'change'`)
  // snapshot.openCount: number     (open comments + every tracked change)
  // snapshot.activeId: string|null (selection-driven, plus next/previous)
});

// Decide actions (route through editor.doc.trackChanges.decide)
ui.review.accept(changeId);
ui.review.reject(changeId);
ui.review.acceptAll();   // decide({ scope: 'all' })
ui.review.rejectAll();

// Navigation (UI-only, advances activeId in document order, wraps)
ui.review.next();        // null active β†’ first item; last β†’ wraps to first
ui.review.previous();    // null active β†’ last item; first β†’ wraps to last
ui.review.scrollTo(id);  // ranges.scrollIntoView; sets activeId

// Recording (temporary documentMode flip until SD-2667/S4)
ui.review.setRecording(true);   // 'suggesting' (recording on)
ui.review.setRecording(false);  // 'editing'
```

Architecture mirrors SD-2790's comments domain:

- Tracked-changes list cached at controller level alongside the
  comments cache; refreshes on the same `commentsUpdate` /
  `commentsLoaded` events the existing wrappers fire (track-changes
  events ride that channel today). `list({ in: 'all' })` is requested
  so non-body stories (header / footer / footnote / endnote) are
  included in the merged feed.
- All decide mutations route through `editor.doc.trackChanges.decide`
  (the Document API contract); `ui.review` is a UI-facing adapter,
  not a parallel mutation contract.
- Refresh + notify after every own mutation β€” same posture as
  ui.comments after the SD-2790 review fix.
- Empty-cache fallback on list() throw (cross-document leak
  prevention β€” same posture as ui.comments).

Document-order ranking note: cross-list interleaving between comments
and tracked changes is *not* fully resolved because public
`TrackChangeInfo` lacks a positional `target` field today (separate
ticket from SD-2667). The initial implementation interleaves
comments first (in `comments.list()` order) then tracked changes (in
`list()` order); migration-guide consumers get a stable iteration
order and dense `documentOrder` ranks for next/previous navigation.
When `TrackChangeInfo.target` lands, the merge sort gets refined
transparently.

`ui.review.setRecording(enabled)` is the temporary path until
SD-2667/S4 splits recording from view mode; today it flips
`superdoc.config.documentMode` between 'suggesting' and 'editing'
via `superdoc.setDocumentMode?.()`. Once an independent
`trackChanges.setRecording` primitive ships, the implementation
swaps internally without API churn.

Stacks on PR #2982 (SD-2790 comments domain). Independent of
PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids).

Verified:
- 44 unit tests (15 substrate + 14 comments + 15 review covering
  merged-feed shape, openCount, selection-driven activeId, decide
  routing for all four kinds, next/previous wrap-around behavior,
  empty-feed null returns, scrollTo entity-type routing, recording
  flip)
- super-editor: 11931 / 13 skipped / 0 fail
- `pnpm --filter superdoc build` clean β€” `dist/superdoc/src/ui.d.ts`
  re-exports the new `ReviewHandle`, `ReviewItem`, `ReviewSlice` types.

Out of scope (separate tickets):

- True document-order merge (depends on `TrackChangeInfo.target?:
  TextTarget`)
- Address fidelity for non-body tracked-change scrollTo (SD-2750)
- Independent `trackChanges.setRecording` primitive (SD-2667/S4)
- Refactor `examples/headless/dropin-assessment` SuperDocAdapter to
  consume `ui.review` instead of the manual merge β€” separate PR
  after the stack lands

* fix(superdoc/ui): review domain edge cases (PR #2983 review)

Four fixes addressing review feedback:

1. Preserve explicit navigation over current selection. Tracks
   lastSelectionDrivenId across computeState calls so that when the
   user calls next()/previous() while the cursor is still on the
   formerly-active item, the next recompute does not snap activeReviewId
   back to the unchanged selection-driven id.

2. Refresh tracked-change cache on external updates. Renames
   COMMENTS_REFRESH_EVENTS to LIST_REFRESH_EVENTS and includes
   trackedChangesUpdate, so a collaborator's accept/reject (or any
   external mutation) refreshes trackChangesListCache without waiting
   for an unrelated comments event.

3. Include story when deciding non-body changes. accept(id)/reject(id)
   look up the change in the cached feed and forward
   address.story to trackChanges.decide so header/footer/footnote
   targets resolve correctly instead of failing target-not-found
   under the body-default lookup.

4. Reuse review items between unchanged snapshots. Memoizes the
   merged feed by source-cache references + activeReviewId so the
   review slice keeps identity stability across typing/selection
   bursts. Subscribers stop re-firing on the editing hot path.

Also restores the JSDoc opener on SuperDocUI.toolbar that was
clipped during a prior rebase, and drops an unused CommentsSlice
type import.

* fix(superdoc/ui): use comments.list discovery id (PR #2983 bot P1)

`comments.list()` returns `DiscoveryItem<CommentDomain>` whose
canonical identifier lives on `id` (set by the adapter from the
underlying commentId). The legacy `commentId` field only exists on
`CommentInfo` (`comments.get`). Reading `comment.commentId` here
emitted `undefined` for every comment row in the merged review feed,
breaking active-id matching and `next/previous/scrollTo` whenever
comments were present.

Updates the review-domain stub to match the production discovery
shape (no `commentId` on items) and pins the regression with a test
that asserts non-empty string ids and end-to-end navigation.

* feat(superdoc/ui): viewport domain (rect + scrollIntoView) (SD-2793) (#2984)

* feat(superdoc/ui): viewport domain (rect + scrollIntoView) (SD-2793)

Adds `ui.viewport.getRect({ target })` for sticky-card / floating-
toolbar geometry and `ui.viewport.scrollIntoView(input)` as the
UI-friendly companion. Browser-only by definition; no Document API
contract changes.

Surface

  ui.viewport.getRect({ target: EntityAddress | TextAddress | TextTarget })
    -> { success: true, rect, rects, pageIndex }
     | { success: false, reason: 'not-ready' | 'invalid-target' | 'unresolved' | 'not-mounted' }

  ui.viewport.scrollIntoView(input) -> Promise<ScrollIntoViewOutput>

`rect` is the primary anchor (first painted occurrence) so consumers
can place a card without reasoning about multi-line / multi-page
layouts; `rects` carries the full set in document order for
underline / highlight overlays.

Boundary

The DOM lookup lives in `PresentationEditor.getEntityRects` (new
public method) backed by a pure helper module
`presentation-editor/dom/EntityRectFinder.ts`. The UI controller
only sees plain value `ViewportRect`s. No DOM elements, PM
positions, or painter selectors leak through `superdoc/ui`.

Comment lookup parses `data-comment-ids="c1,c2,c3"` by splitting on
comma and matching tokens by exact equality. CSS attribute
selectors (`[...~="c1"]`) split on whitespace, not comma, and
substring selectors (`[...*="c1"]`) would partial-match `c12`.
Tracked-change lookup reuses the existing private helper which
escapes ids for selector interpolation.

Reasons

  - not-ready: editor / presentation editor not mounted yet
  - invalid-target: caller-shape error (missing kind, empty id, etc.)
  - unresolved: reserved for the text-anchored paths
  - not-mounted: valid target, currently virtualized / offscreen
                 (caller can scrollIntoView first then retry)

Deferred

  - TextAddress / TextTarget paths land in a follow-up. The type
    union accepts them today so call sites are forward-compatible,
    but those branches return `invalid-target` until the story-aware
    text resolver lands. Driven by the same concern as the existing
    `editor.doc.ranges.scrollIntoView` text path: must route through
    the active routed editor (header / footer / note vs body), not
    silently read body coords.
  - examples/headless/dropin-assessment refactor lands separately.

Tests

  - EntityRectFinder unit tests: comma-overlap (c1 must not match
    c12), whitespace tolerance, story-key filtering (body /
    non-body), missing-host / empty-id guards, NaN-rect rejection,
    pageIndex resolution from `.superdoc-page` wrapper.
  - viewport.test: success/failure shapes, story passthrough,
    not-mounted vs not-ready vs invalid-target, scrollIntoView
    pass-through, JSON-serializable rect output.

* fix(superdoc/ui): strict story filter + entity-type validation (PR #2984 review)

Two review fixes for `ui.viewport.getRect`:

1. Strict story filter for tracked-change rects.
   The navigation helper `#findRenderedTrackedChangeElements` falls
   back to all same-id matches when an exact story match doesn't
   satisfy a heuristic β€” correct for "scroll to this change", but
   wrong for the viewport read path: a sticky card asked to anchor
   a header/footer change must not silently anchor to a body copy
   of the same id. Adds `findRenderedTrackedChangeElementsStrict`
   in the EntityRectFinder helper module and routes
   `PresentationEditor.getEntityRects` through it. Empty result
   surfaces as `not-mounted` so the consumer can pre-mount via
   `viewport.scrollIntoView` and retry.

2. Reject unsupported entity types up front in `ui.viewport.getRect`.
   A typo or unsupported address (`bookmark`, `field`, etc.) used
   to fall through to `getEntityRects`, return `[]`, and surface
   as `not-mounted` β€” misleading consumers into retry / scroll
   loops for shapes the controller doesn't handle. Now returns
   `invalid-target` immediately, matching the `ViewportRectResult`
   contract.

Tests

   - `findRenderedTrackedChangeElementsStrict`: exact-story match,
     no cross-story fallback when requested story is empty,
     no-storyKey returns all copies, CSS-special id escape.
   - `viewport.getRect`: bogus `entityType` returns `invalid-target`
     and never consults the engine.

* chore(document-api): remove UI-shaped surfaces (SD-2795) (#2988)

* chore(document-api): remove UI-shaped surfaces (SD-2795)

`editor.doc.*` is the request/response Document API contract. UI-shaped
surfaces (subscriptions, viewport scroll, geometry, focus) belong on
the browser-only `superdoc/ui` controller. This drops the two members
that were already on the contract-parity exemption list:

  - `editor.doc.selection.onChange` β€” push subscription. Replacement
    is `createSuperDocUI({ superdoc }).select(s => s.selection, ...)`
    on the controller's selector substrate.
  - `editor.doc.ranges.scrollIntoView` β€” viewport scroll, browser-only.
    Replacement is `ui.viewport.scrollIntoView(input)` (also called by
    `ui.comments.scrollTo` and `ui.review.scrollTo`).

Both shipped only in `v1.30.0-next.*` / `v1.28.0-next.*` pre-release
tags β€” no stable release contains them β€” so the cut is clean and
needs no deprecation cycle.

Engine wiring

  - `SelectionAdapter` is now `current()` only.
  - `SelectionApi` is now `current()` only.
  - `RangesApi` / `RangesAdapter` are `resolve()` only.
  - `executeScrollIntoView`, `RangeScrollAdapter`, and
    `SelectionChangeListener` are deleted.
  - `assembleDocumentApiAdapters` no longer wires
    `selection.onChange` or `ranges.scrollIntoView`.
  - `subscribeToSelection` removed from `selection-info-resolver`;
    the pure read path `resolveCurrentSelectionInfo` stays.

Relocation

  - Scroll resolution moves to `packages/super-editor/src/ui/scroll-into-view.ts`.
    `ui.viewport.scrollIntoView`, `ui.comments.scrollTo`, and
    `ui.review.scrollTo` call it directly through the routed
    presentation editor.
  - `ScrollIntoViewInput` / `ScrollIntoViewOutput` value types stay
    in doc-api β€” they describe the request shape `superdoc/ui`
    consumers marshal across all three handles.

Parity check

  `META_MEMBER_PATHS` in `check-contract-parity.ts` now contains only
  the dispatcher (`invoke`) and reference aliases. Verified locally:
  "contract parity check passed (389 operations, 389 API members)".

Tests

  - 11954 super-editor pass; 70 ui-domain pass.
  - 1381 doc-api pass.
  - Stubs in `comments.test.ts`, `review.test.ts`, `viewport.test.ts`
    updated to wire `presentationEditor.navigateTo` instead of the
    removed `editor.doc.ranges.scrollIntoView`.
  - `consumer-typecheck` smoke test for scroll moved to the
    `ViewportHandle` shape.

Generated artifacts

  Regenerated via `pnpm run generate:all`. SDK tool catalogs / contract
  manifest pick up the smaller surface automatically β€” no operations
  added or removed (neither member was an OPERATION_DEFINITIONS entry).

* fix(superdoc/ui): narrow scroll target via discriminated kind (PR #2988 review)

`scroll-into-view.ts` checked `kind === 'entity'` through a cast,
which left `input.target` widened as `TextAddress | TextTarget |
EntityAddress` inside the entity branch β€” `presentation.navigateTo`
expects `NavigableAddress`, so `tsc` flagged TS2345.

Switch to a regular discriminated-union check on `target.kind`. All
three target shapes carry a `kind` field, so the equality check
narrows directly: `EntityAddress` inside the block, `TextAddress |
TextTarget` after the early return. Drops the now-unused
`TextAddress` / `TextTarget` named imports.

* feat(superdoc/ui): rich state.selection slice + activeCommentIds/activeChangeIds resolver (SD-2801, SD-2792) (#2990)

* feat(document-api): selection.current() exposes activeCommentIds and activeChangeIds (SD-2792)

Adds two read-only id arrays to `SelectionInfo`:

- `activeCommentIds: string[]` β€” `commentMark.commentId`s overlapping
  the selection (or under the caret when empty)
- `activeChangeIds: string[]` β€” `trackInsert` / `trackDelete` /
  `trackFormat` mark ids overlapping the selection

Union semantics (NOT intersection): an id is included when *any*
character in the range carries the mark. `activeMarks` keeps its
intersection semantics; the two answer different questions and have
different defaults.

Why:

Custom sidebars need to answer "is there a comment / tracked change
under the cursor?" to render a floating "comment here" hint, highlight
the active sidebar card as the user moves the caret, disable a "new
comment" button when the selection already covers an existing comment,
and drive next/previous review navigation. Today consumers either:

- Call `comments.list()` and overlap-filter on every keystroke (full
  read per cursor move), or
- Reach into the rendered DOM via `document.querySelectorAll(
  '[data-comment-id]')` (the dropin-assessment lab does this β€” a
  DOM-shape coupling no migration guide should teach).

Both are escape hatches. The selection resolver (SD-2668) already
walks the selection range; collecting these ids is one extra pass on
the same nodes.

Implementation:

- `collectActiveEntityIds` walks the selection in the same shape as
  `collectActiveMarks` (caret-only branch for empty selections plus
  stored marks; nodesBetween for ranges). Bounded allocation, runs in
  O(text nodes) for both branches.
- Mark name constants are local to the resolver rather than imported
  from the comment / track-changes extensions. The resolver lives one
  package up the dependency graph; pulling in the PM plugins would
  bloat the import path for no gain. The constant set is small and
  changes when the schema does.
- `selectionInfoKey` (transaction dedupe cache) now includes the new
  fields so a comment/change id change between transactions still
  emits to subscribers.

Schema + generated reference docs updated; both fields are required
and serialize as empty arrays when no entity marks are active.

Verified: 23 resolver tests (5 new for entity-id collection covering
union semantics, both kinds, mixed marks, JSON round-trip). Document-
API: 1395 / 0 fail. Super-editor: 11893 / 13 skipped / 0 fail.
Contract parity: 389/389. `pnpm run generate:all` clean.

* feat(superdoc/ui): rich state.selection slice (SD-2801)

Widen `state.selection` from `{ empty, quotedText }` to mirror the
full `SelectionInfo` returned by `editor.doc.selection.current()`:
adds `target`, `activeMarks`, `activeCommentIds`, `activeChangeIds`.

A single `ui.select(s => s.selection, shallowEqual).subscribe(cb)`
now gives consumers everything a floating bubble menu / format
toolbar / mention popover / "comment here" hint needs, instead of
having to call `editor.doc.selection.current()` synchronously inside
every callback.

Memoization

  Slice identity stays stable across recomputes when the projected
  shape hasn't changed. The memo key folds in empty + target (deep)
  + activeMarks + activeCommentIds + activeChangeIds + quotedText so
  a typing-only transaction (which leaves the projection unchanged
  but allocates fresh arrays inside the resolver) keeps the slice
  identity stable and lets `shallowEqual` short-circuit subscribers
  on the editing hot path. Same posture as the comments / review
  slice memos.

Resilience

  Falls back to safe defaults (`target: null`, `[]` for the id
  arrays, `''` for `quotedText`) when `selection.current` is missing
  fields β€” keeps backwards-compat with legacy / partial resolvers.

Tests

  - Slice mirrors full SelectionInfo when resolver populates every field.
  - Slice identity stable across two transactions that don't change
    the projection (memoization on hot path).
  - Slice identity changes when activeMarks change (caret enters bold).
  - Falls back to safe defaults for legacy resolvers.

Generated artifacts refreshed via `pnpm run generate:all`.

* fix(superdoc/ui): tracked-changes event + host routing + story scroll (PR #2979 review)

Four review fixes against the stacked superdoc/ui surface:

1. tracked-changes-changed (P1). The cache listener was wired to
   `trackedChangesUpdate`, an event no source actually emits β€” the
   tracked-change index broadcasts `tracked-changes-changed`. Wire
   that name in `EDITOR_EVENTS` and `LIST_REFRESH_EVENTS` so normal
   editing / collaboration / external accept/reject mutations
   refresh `ui.review` instead of leaving subscribers stale until
   one of the controller's own action methods fires.

2. Story-aware scrollTo (P2). `ui.review.scrollTo(id)` and
   `ui.comments.scrollTo(commentId)` looked up the entity but dropped
   `address.story` from the EntityAddress they passed to
   `presentation.navigateTo`. Without it, navigateTo defaults to
   body and either fails with target-not-found or anchors to a
   same-id body change. Added `lookupItemStory(id)` paralleling
   `buildChangeDecideTarget` and forwarded story when present.

3. Host-routed review decisions (P2). `ui.review.accept` /
   `reject` / `acceptAll` / `rejectAll` resolved the editor through
   `resolveRoutedEditor`, which returns the routed header / footer /
   note editor when focus is in a child story. Decisions are
   document-wide; routing through a child editor scopes them to
   that child story instead. Added `resolveHostEditor` (always
   `superdoc.activeEditor`) and routed `requireDocTrackChanges` and
   `runScrollIntoView` through it. The change's own `address.story`
   in the decide / scroll target tells the adapter which story to
   operate against.

4. Discriminate text targets by segments-array content, not just
   shape. `'segments' in target` would mis-classify a hybrid payload
   carrying an empty `segments[]`. Switch to
   `Array.isArray(target.segments) && target.segments.length > 0`,
   matching the fix in PR #2941 (commit af50f50).

Tests

- Updated the regression for cache refresh to fire the correct
  `tracked-changes-changed` event.
- Added `scrollTo` regressions: story preserved for header changes,
  omitted for body changes.
- Added host-routing regressions: `accept(id)` and `acceptAll()` go
  through the host editor's `decide` even when toolbar routing
  returns a child story editor.
- Updated review test fixtures to plant a child editor stub for the
  routing tests.

* fix(superdoc/ui): resolver covers inline atoms + legacy comment ids (PR #2990 review)

* fix(superdoc/ui): tsc -b clean + codecov d.ts ignore + consumer-typecheck smoke (PR #2979 review) (#2994)

* test(behavior): superdoc/ui activeCommentIds + viewport.getRect specs (PR #2979)

Two end-to-end Playwright specs covering the new browser-only
`superdoc/ui` surface introduced in this stack. Cross-checked
against chromium, firefox, and webkit (18/18).

selection-active-comment-ids.spec.ts (SD-2792)

  - Caret inside a comment span surfaces the commentId in
    `selection.current().activeCommentIds`.
  - Caret outside any comment span returns an empty array.
  - Moving the caret between two distinct comment spans switches
    the active id (proves the resolver re-walks marks per
    transaction, not just initial selection).

viewport-get-rect.spec.ts (SD-2793)

  - `ui.viewport.getRect({ target: EntityAddress })` returns rects
    that match the painted comment-highlight element's
    `getBoundingClientRect()` within Β±1px. Catches drift between
    the layout-engine output and the rect resolver, which jsdom
    unit tests can't reach.
  - Unknown / unmounted entity id returns
    `{ success: false, reason: 'not-mounted' }`.
  - Unsupported `entityType` returns
    `{ success: false, reason: 'invalid-target' }`.

Harness

  Extended `tests/behavior/harness/main.ts` to expose the
  `superdoc/ui` controller via `window.__bootSuperDocUI()`. Tests
  call it lazily so the controller is only constructed when
  needed β€” no edge effects on tests that don't exercise
  `createSuperDocUI`.

Helpers

  Added `reopenComment(page, { commentId })` to
  `tests/behavior/helpers/document-api.ts` as a sibling of
  `resolveComment`. The reopen behavior spec itself lands
  alongside SD-2789 (PR #2987) β€” that lifecycle isn't on this
  stack base yet.

* fix(superdoc/ui): viewport.getRect routes through host PresentationEditor (PR #2979 review)

* feat(superdoc/ui): expose toolbar/commands types via superdoc/ui (PR #2979 review)

* feat(superdoc/ui): ui.selection handle + narrow viewport target type (PR #2979 review)

* feat(superdoc/ui): drop ui.review.setRecording β€” name lied about behavior (PR #2979 review)

* chore(super-editor): add test:ui / test:ui:watch scripts (PR #2979 review)
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.

1 participant