Przeglądaj źródła

Merge pull request #10981 from growilabs/imprv/collab-editor-awareness

feat(editor): Collaborative editor awareness with rich cursors and off-screen indicators
mergify[bot] 1 dzień temu
rodzic
commit
603633f20f
40 zmienionych plików z 4355 dodań i 80 usunięć
  1. 815 0
      .kiro/specs/collaborative-editor-awareness/design.md
  2. 107 0
      .kiro/specs/collaborative-editor-awareness/requirements.md
  3. 218 0
      .kiro/specs/collaborative-editor-awareness/research.md
  4. 23 0
      .kiro/specs/collaborative-editor-awareness/spec.json
  5. 144 0
      .kiro/specs/collaborative-editor-awareness/tasks.md
  6. 3 1
      .kiro/specs/collaborative-editor/design.md
  7. 4 2
      .kiro/specs/collaborative-editor/requirements.md
  8. 5 0
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.module.scss
  9. 259 0
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.spec.tsx
  10. 36 13
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx
  11. 8 1
      apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx
  12. 3 0
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  13. 2 2
      apps/app/src/server/service/yjs/y-websocket-server.d.ts
  14. 25 0
      apps/app/src/states/ui/editor/scroll-to-remote-cursor.ts
  15. 5 1
      packages/editor/src/@types/scss.d.ts
  16. 5 0
      packages/editor/src/client/components/CodeMirrorEditorMain.tsx
  17. 1 0
      packages/editor/src/client/services-internal/extensions/index.ts
  18. 54 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/activity-tracker.ts
  19. 4 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/index.ts
  20. 294 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/off-screen-indicator.spec.ts
  21. 108 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/off-screen-indicator.ts
  22. 194 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/theme.ts
  23. 221 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/widget.spec.ts
  24. 122 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/widget.ts
  25. 42 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/index.ts
  26. 55 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/local-cursor.ts
  27. 598 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/plugin.integ.ts
  28. 276 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/plugin.ts
  29. 95 0
      packages/editor/src/client/services-internal/extensions/y-rich-cursors/viewport-classification.ts
  30. 149 0
      packages/editor/src/client/stores/use-collaborative-editor-mode-scroll.spec.ts
  31. 142 0
      packages/editor/src/client/stores/use-collaborative-editor-mode.spec.ts
  32. 117 15
      packages/editor/src/client/stores/use-collaborative-editor-mode.ts
  33. 1 0
      packages/editor/tsconfig.json
  34. 12 0
      packages/editor/vitest.config.ts
  35. 6 0
      packages/ui/src/@types/scss.d.ts
  36. 136 0
      packages/ui/src/components/UserPicture.spec.tsx
  37. 48 45
      packages/ui/src/components/UserPicture.tsx
  38. 3 0
      packages/ui/test/setup.ts
  39. 1 0
      packages/ui/tsconfig.json
  40. 14 0
      packages/ui/vitest.config.ts

+ 815 - 0
.kiro/specs/collaborative-editor-awareness/design.md

@@ -0,0 +1,815 @@
+# Design Document: collaborative-editor-awareness
+
+## Overview
+
+**Purpose**: This feature fixes intermittent disappearance of the `EditingUserList` component, upgrades in-editor cursors to display a user's name and avatar, adds off-screen cursor indicators with click-to-scroll navigation, and surfaces username tooltips in `EditingUserList`.
+
+**Users**: All GROWI users who use real-time collaborative page editing. They will see stable editing-user indicators, rich avatar-bearing cursor flags, off-screen indicators they can click to jump to a co-editor's position, and username tooltips on hover in the editing user list.
+
+**Impact**: Modifies `use-collaborative-editor-mode` in `@growi/editor`, replaces the default `yRemoteSelections` cursor plugin with `yRichCursors`, adds off-screen click-to-scroll via a mutable ref pattern, and enhances `EditingUserList` with color-matched borders, click-to-scroll, and username tooltips.
+
+### Goals
+
+- Eliminate `EditingUserList` disappearance caused by `undefined` entries from uninitialized awareness states
+- Remove incorrect direct mutation of Yjs-managed `awareness.getStates()` map
+- Render remote cursors with display name and profile image avatar
+- Read user data exclusively from `state.editors` (GROWI's canonical awareness field), eliminating the current `state.user` mismatch
+- Enable click-to-scroll on both `EditingUserList` avatars and off-screen cursor indicators
+- Display username tooltips on `EditingUserList` avatar hover without reintroducing the HOC Fragment layout issue
+
+### Non-Goals
+
+- Server-side awareness bridging (covered in `collaborative-editor` spec)
+- Upgrading `y-codemirror.next` or `yjs`
+- Cursor rendering for the local user's own cursor
+
+## Architecture
+
+### Existing Architecture Analysis
+
+The current flow has two defects:
+
+1. **`emitEditorList` in `use-collaborative-editor-mode`**: maps `awareness.getStates().values()` to `value.editors`, producing `undefined` for any client whose awareness state has not yet included an `editors` field. The `Array.isArray` guard is always true and does not filter. `EditingUserList` then receives a list containing `undefined`, leading to a React render error that wipes the component.
+
+2. **Cursor field mismatch**: `yCollab(activeText, provider.awareness, { undoManager })` adds `yRemoteSelections`, which reads `state.user.name` and `state.user.color`. GROWI sets `state.editors` (not `state.user`). The result is that all cursors render as "Anonymous" with a default blue color. This is also fixed by the new design.
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph packages_editor
+        COLLAB[use-collaborative-editor-mode]
+        RICH[yRichCursors ViewPlugin]
+        YCOLLAB[yCollab - null awareness]
+        SCROLLREF[scrollCallbackRef - MutableRef]
+    end
+
+    subgraph y_codemirror_next
+        YSYNC[ySync - text sync]
+        YUNDO[yUndoManager - undo]
+    end
+
+    subgraph Yjs_Awareness
+        AWR[provider.awareness]
+    end
+
+    subgraph apps_app
+        CM[CodeMirrorEditorMain]
+        EUL[EditingUserList]
+        ATOM[editingClientsAtom - Jotai]
+        ATOM2[scrollToRemoteCursorAtom - Jotai]
+    end
+
+    CM --> COLLAB
+    COLLAB -->|null awareness| YCOLLAB
+    YCOLLAB --> YSYNC
+    YCOLLAB --> YUNDO
+    COLLAB -->|awareness + scrollCallbackRef| RICH
+    RICH -->|reads state.editors| AWR
+    RICH -->|sets state.cursor| AWR
+    RICH -->|viewport comparison| RICH
+    RICH -->|indicator click| SCROLLREF
+    COLLAB -->|filtered clientList| ATOM
+    ATOM --> EUL
+    COLLAB -->|scrollFn written to ref| SCROLLREF
+    COLLAB -->|onScrollToRemoteCursorReady| ATOM2
+    ATOM2 -->|onUserClick| EUL
+```
+
+**Key architectural properties**:
+- `yCollab` is called with `null` awareness to suppress the built-in `yRemoteSelections` plugin; text-sync (`ySync`) and undo (`yUndoManager`) are not affected
+- `yRichCursors` is added as a separate extension alongside `yCollab`'s output; it owns all awareness-cursor interaction, including in-viewport widget rendering and off-screen indicators
+- `state.editors` remains the single source of truth for user identity data
+- `state.cursor` (anchor/head relative positions) continues to be used for cursor position broadcasting, consistent with `y-codemirror.next` convention
+- Off-screen indicators are managed within the same `yRichCursors` ViewPlugin — it compares each remote cursor's absolute position against `view.visibleRanges` (the actually visible content range, excluding CodeMirror's pre-render buffer) to decide between widget decoration (in-view) and DOM overlay (off-screen)
+- **`scrollCallbackRef`** is a `{ current: ((clientId: number) => void) | null }` mutable object created once alongside the `yRichCursors` extension. Because the scroll function is created in a separate `useEffect` from the extension instantiation, passing it as a plain value would require recreating the extension on every update. The mutable ref allows `yRichCursors` to hold a stable reference to the container while the hook silently updates `.current` when the scroll function is registered or cleared.
+
+**Dual-path scroll delivery — why both `scrollCallbackRef` and `onScrollToRemoteCursorReady` coexist**:
+
+The scroll-to-remote-cursor function has two independent consumers that live in fundamentally different runtime contexts:
+
+| Consumer | Context | Why this delivery mechanism |
+|----------|---------|----------------------------|
+| Off-screen indicator (DOM click) | CodeMirror `ViewPlugin` — vanilla JS, not a React component | Cannot call React hooks (`useAtomValue`) to read a Jotai atom. Needs a plain mutable ref whose `.current` is read at click time. |
+| `EditingUserList` avatar click | React component in `apps/app` | Needs a React-compatible state update (Jotai atom) so that `EditorNavbar` re-renders when the scroll function becomes available. A mutable ref change does not trigger re-render. |
+
+Consolidating into a single mechanism is not feasible:
+- **Ref-only**: React components that read `useRef` do not re-render when `.current` changes; `EditorNavbar` would receive `null` on initial render and never update.
+- **Atom-only**: `yRichCursors` is a CodeMirror `ViewPlugin` class (not a React component) and cannot call `useAtomValue`. Importing the atom directly from `apps/app` into `packages/editor` would violate the monorepo dependency direction (lower package must not depend on higher).
+- **Event-emitter**: Considered as an alternative to the callback prop chain. A typed event emitter (e.g., `mitt`) would replace the two callback props (`onEditorsUpdated`, `onScrollToRemoteCursorReady`) with a single event bus prop. However, with only two events, the abstraction cost outweighs the benefit: event emitters introduce implicit coupling (string-keyed subscriptions are harder to trace and not caught by the compiler if one side is renamed), require manual subscribe/unsubscribe lifecycle management (risk of stale handler leaks), and add an external dependency — all for marginal reduction in prop drilling (2 → 1).
+
+The `onScrollToRemoteCursorReady` callback follows the same pattern as the existing `onEditorsUpdated` callback, which also bridges `packages/editor` → `apps/app` across the package boundary via props.
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Editor extensions | `y-codemirror.next@0.3.5` | `yCollab` for text-sync and undo; `yRemoteSelectionsTheme` for base caret CSS | No version change; `yRemoteSelections` no longer used |
+| Cursor rendering | CodeMirror `ViewPlugin` + `WidgetType` (`@codemirror/view`) | DOM-based cursor widget with avatar `<img>` | No new dependency |
+| Awareness | `y-websocket` `awareness` object | State read (`getStates`) and write (`setLocalStateField`) | `Awareness` type derived via `WebsocketProvider['awareness']` — `y-protocols` is not a direct dependency |
+
+## System Flows
+
+### Click-to-Scroll Flow — EditingUserList Avatar (Requirements 6.1–6.5)
+
+```mermaid
+sequenceDiagram
+    participant EUL as EditingUserList
+    participant ATOM2 as scrollToRemoteCursorAtom
+    participant HOOK as use-collaborative-editor-mode
+    participant AW as provider.awareness
+    participant CM as CodeMirror EditorView
+
+    EUL->>ATOM2: onUserClick(clientId)
+    ATOM2->>HOOK: scrollFn(clientId)
+    HOOK->>AW: getStates().get(clientId)
+    AW-->>HOOK: AwarenessState { cursor.head }
+    Note over HOOK: cursor.head == null → return (no-op, req 6.3)
+    HOOK->>HOOK: createAbsolutePositionFromRelativePosition(head, activeDoc)
+    HOOK->>CM: view.dispatch(EditorView.scrollIntoView(pos.index, { y: 'center' }))
+```
+
+**Key design decisions**:
+- `scrollFn` closes over `codeMirrorEditor` (accessed lazily via `codeMirrorEditor?.view` at call time) so late-mounted editors are handled correctly.
+- `activeDoc` (Y.Doc) is captured in the same effect that creates `scrollFn`; the function is invalidated and recreated whenever `activeDoc` or `provider` changes.
+- If `cursor.head` is absent (user connected but not focused), the click is silently ignored per requirement 6.3.
+
+### Click-to-Scroll Flow — Off-Screen Indicator (Requirements 6.6–6.7)
+
+```mermaid
+sequenceDiagram
+    participant IND as Off-Screen Indicator DOM
+    participant REF as scrollCallbackRef
+    participant HOOK as use-collaborative-editor-mode
+    participant AW as provider.awareness
+    participant CM as CodeMirror EditorView
+
+    Note over REF: Ref created alongside yRichCursors extension
+    HOOK->>REF: scrollCallbackRef.current = scrollFn (on setup)
+    IND->>REF: click handler calls scrollCallbackRef.current(clientId)
+    REF->>HOOK: scrollFn(clientId)
+    HOOK->>AW: getStates().get(clientId)
+    AW-->>HOOK: AwarenessState { cursor.head }
+    Note over HOOK: cursor.head == null → return (no-op, req 6.3)
+    HOOK->>HOOK: createAbsolutePositionFromRelativePosition(head, activeDoc)
+    HOOK->>CM: view.dispatch(EditorView.scrollIntoView(pos.index, { y: 'center' }))
+```
+
+**Key design decisions for off-screen click**:
+- `scrollCallbackRef` is a plain object `{ current: Fn | null }` created with `useRef` in `use-collaborative-editor-mode` and passed to `yRichCursors(awareness, { onClickIndicator: scrollCallbackRef })`. This is the standard React mutable-ref pattern but without the React import constraint (the `packages/editor` package uses it as a plain typed object).
+- The extension is created once; the ref's `.current` value is updated silently by the hook's scroll-function `useEffect`. This avoids recreating CodeMirror extensions on every provider change.
+- `createOffScreenIndicator` receives `clientId` and `onClick` callback, attaching a `click` event listener that calls `onClick(clientId)`. The indicator element has `cursor: pointer` via the theme CSS or inline style.
+
+### Awareness Update → EditingUserList
+
+```mermaid
+sequenceDiagram
+    participant AW as provider.awareness
+    participant HOOK as use-collaborative-editor-mode
+    participant ATOM as editingClientsAtom
+    participant EUL as EditingUserList
+
+    AW->>HOOK: awareness.on('update', handler)
+    HOOK->>HOOK: filter: state.editors != null
+    HOOK->>ATOM: onEditorsUpdated(filteredList)
+    ATOM->>EUL: re-render with valid EditingClient[]
+```
+
+The filter (`value.editors != null`) ensures `EditingUserList` never receives `undefined` entries. The `.delete()` call on `getStates()` is removed; Yjs clears stale entries before emitting `update`.
+
+### Cursor Render Cycle
+
+```mermaid
+sequenceDiagram
+    participant CM as CodeMirror EditorView
+    participant RC as yRichCursors Plugin
+    participant AW as provider.awareness
+
+    CM->>RC: update(ViewUpdate)
+    RC->>AW: setLocalStateField('cursor', {anchor, head})
+    Note over AW,RC: awareness fires 'change' — but changeListener<br/>ignores events where only the local client changed
+    AW-->>RC: awareness.on('change') for REMOTE client
+    RC->>CM: dispatch with yRichCursorsAnnotation
+    CM->>RC: update(ViewUpdate) — triggered by annotation
+    RC->>RC: rebuild decorations from state.editors + state.cursor
+```
+
+**Annotation-driven update strategy**: The awareness `change` listener does not call `view.dispatch()` unconditionally — doing so would crash with "Calls to EditorView.update are not allowed while an update is in progress" because `setLocalStateField` in the `update()` method itself triggers an awareness `change` event synchronously. Instead, the listener filters by `clientID`: it dispatches (with a `yRichCursorsAnnotation`) only when at least one **remote** client's state has changed. Local-only awareness changes (from the cursor broadcast in the same `update()` cycle) are silently ignored, and the decoration set is rebuilt in the next `update()` call naturally.
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Key Interfaces |
+|-------------|---------|------------|----------------|
+| 1.1 | Filter undefined awareness entries | `use-collaborative-editor-mode` | `emitEditorList` filter |
+| 1.2 | Remove `getStates().delete()` mutation | `use-collaborative-editor-mode` | `updateAwarenessHandler` |
+| 1.3 | EditingUserList remains stable | `use-collaborative-editor-mode` → `editingClientsAtom` | `onEditorsUpdated` callback |
+| 1.4 | Skip entries without `editors` field | `use-collaborative-editor-mode` | `emitEditorList` filter |
+| 2.1 | Broadcast user presence via awareness | `use-collaborative-editor-mode` | `awareness.setLocalStateField('editors', ...)` |
+| 2.2–2.3 | Socket.IO awareness events (server) | Out of scope — `collaborative-editor` spec | — |
+| 2.4 | Display active editors | `EditingUserList` (unchanged) | — |
+| 3.1 | Avatar overlay below caret (no block space) | `yRichCursors` | `RichCaretWidget.toDOM()` — `position: absolute` overlay |
+| 3.2 | Avatar size (`AVATAR_SIZE` in `theme.ts`) | `yRichCursors` | `RichCaretWidget.toDOM()` — CSS sizing via shared token |
+| 3.3 | Name label visible on hover only | `yRichCursors` | CSS `:hover` on `.cm-yRichCursorFlag` |
+| 3.4 | Avatar image with initials fallback | `yRichCursors` | `RichCaretWidget.toDOM()` — `<img>` onerror → initials |
+| 3.5 | Cursor caret color, fallback background, and avatar border from `state.editors.color` | `yRichCursors` | `RichCaretWidget` constructor + `borderColor` inline style |
+| 3.6 | Custom cursor via replacement plugin | `yRichCursors` replaces `yRemoteSelections` | `yCollab(activeText, null, { undoManager })` |
+| 3.7 | Cursor updates on awareness change | `yRichCursors` awareness change listener | `awareness.on('change', ...)` |
+| 3.8 | Default semi-transparent avatar | `yRichCursors` | CSS `opacity` on `.cm-yRichCursorFlag` |
+| 3.9 | Full opacity on hover | `yRichCursors` | CSS `:hover` rule |
+| 3.10 | Full opacity during active editing (3s) | `yRichCursors` | `lastActivityMap` + `.cm-yRichCursorActive` class + `setTimeout` |
+| 4.1 | Off-screen indicator at top edge with `arrow_drop_up` above avatar | `yRichCursors` | `topContainer` + Material Symbol icon |
+| 4.2 | Off-screen indicator at bottom edge with `arrow_drop_down` below avatar | `yRichCursors` | `bottomContainer` + Material Symbol icon |
+| 4.3 | No indicator when cursor is in viewport | `yRichCursors` | multi-mode classification in `update()` (rangedMode / coords mode) |
+| 4.4 | Same avatar/color as in-editor widget | `yRichCursors` | shared `state.editors` data |
+| 4.5 | Indicators positioned at cursor's column | `yRichCursors` | `requestMeasure` → `coordsAtPos` → `left: Xpx; transform: translateX(-50%)` |
+| 4.6 | Transition on scroll (indicator ↔ widget) | `yRichCursors` | classification re-run on every `update()` |
+| 4.7 | Overlay positioning (no layout impact) | `yRichCursors` | `position: absolute` on `view.dom` |
+| 4.8 | Indicator X position derived from cursor column | `yRichCursors` | `view.coordsAtPos` (measure phase) or char-width fallback |
+| 4.9 | Arrow always fully opaque in cursor color; avatar fades when idle | `yRichCursors` | `opacity: 1` on `.cm-offScreenArrow`; `opacity: IDLE_OPACITY` on avatar/initials |
+| 5.1 | Avatar border color = `editingClient.color` (replaces fixed `border-info`) | `EditingUserList` | Wrapper `<span>` with `style={{ border: '2px solid {color}', borderRadius: '50%' }}` |
+| 5.2 | Border weight equivalent to existing border | `EditingUserList` | 2 px solid, same as Bootstrap `border` baseline |
+| 5.3 | Color-matched border in overflow popover | `EditingUserList` | Replace `UserPictureList` with inline rendering sharing the same wrapper pattern |
+| 6.1 | Click avatar → editor scrolls to that user's cursor | `EditingUserList` + `use-collaborative-editor-mode` | `onUserClick(clientId)` → `scrollFn` → `view.dispatch(scrollIntoView)` |
+| 6.2 | Scroll centers cursor vertically | `use-collaborative-editor-mode` | `EditorView.scrollIntoView(pos, { y: 'center' })` |
+| 6.3 | No-op when cursor absent from awareness | `use-collaborative-editor-mode` | Guard: `cursor?.head == null → return` |
+| 6.4 | `cursor: pointer` on each avatar | `EditingUserList` | CSS `cursor: pointer` on the clickable wrapper element |
+| 6.5 | Overflow popover avatars also support click-to-scroll | `EditingUserList` | Inline rendering in popover body shares same `onUserClick` prop |
+| 6.6 | Click off-screen indicator → scroll to remote cursor | `yRichCursors` + `use-collaborative-editor-mode` | `scrollCallbackRef.current(clientId)` → same `scrollFn` path as 6.1–6.3 |
+| 6.7 | `cursor: pointer` on each off-screen indicator | `yRichCursors` | `cursor: pointer` via theme or inline style in `createOffScreenIndicator` |
+| 7.1 | Tooltip shows display name on avatar hover in EditingUserList | `UserPicture` (refactored) | Built-in tooltip renders `@username` + display name via portal child |
+| 7.2 | Tooltip on both direct and overflow popover avatars | `EditingUserList` | `noTooltip` removed from `UserPicture`; tooltip renders automatically for all avatars |
+| 7.3 | Tooltip coexists with color-matched border and click-to-scroll | `UserPicture` (refactored) | Tooltip is a portal child of the root `<span>`; no Fragment siblings to disturb flex layout |
+| 7.4 | Tooltip mechanism does not use `UserPicture` HOC | `UserPicture` (refactored) | `withTooltip` HOC eliminated; tooltip inlined in `UserPicture` render function |
+| 7.5 | Tooltip appears with hover-intent delay; disappears on pointer leave | `UserPicture` (refactored) | `UncontrolledTooltip` with `delay={0}` and `fade={false}` (existing behavior preserved) |
+
+## Components and Interfaces
+
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0) | Contracts |
+|-----------|--------------|--------|--------------|----------------------|-----------|
+| `use-collaborative-editor-mode` | packages/editor — Hook | Fix awareness filter bug; compose extensions with rich cursor; expose scroll-to-remote-cursor callback; own `scrollCallbackRef` lifecycle | 1.1–1.4, 2.1, 2.4, 6.1–6.3, 6.6 | `yCollab` (P0), `yRichCursors` (P0) | State |
+| `yRichCursors` | packages/editor — Extension | Custom ViewPlugin: broadcasts local cursor position, renders in-viewport cursors with overlay avatar+hover name+activity opacity, renders clickable off-screen indicators at editor edges | 3.1–3.10, 4.1–4.9, 6.6, 6.7 | `@codemirror/view` (P0), `y-websocket awareness` (P0) | Service |
+| `CodeMirrorEditorMain` | packages/editor — Component | Bridge: passes `onScrollToRemoteCursorReady` prop from apps/app into `useCollaborativeEditorMode` | 6.1 | `useCollaborativeEditorMode` (P0) | State |
+| `scrollToRemoteCursorAtom` | apps/app — Jotai atom | Stores the scroll callback registered by `useCollaborativeEditorMode`; read by EditorNavbar | 6.1 | `jotai` (P0) | State |
+| `UserPicture` | packages/ui — Component | Refactored: eliminates `withTooltip` HOC; renders tooltip as portal child of root `<span>` instead of Fragment sibling | 7.1, 7.3–7.5 | `UncontrolledTooltip` (P1) | View |
+| `EditingUserList` | apps/app — Component | Renders active editor avatars with color-matched borders, click-to-scroll; tooltips via native `UserPicture` (no `noTooltip`) | 5.1–5.3, 6.1, 6.4–6.5, 7.2 | `EditingClient[]` (P0) | View |
+
+### packages/editor — Hook
+
+#### `use-collaborative-editor-mode` (modified)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Orchestrates WebSocket provider, awareness, and CodeMirror extension lifecycle for collaborative editing |
+| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1, 2.4, 6.1–6.3, 6.6 |
+
+**Responsibilities & Constraints**
+- Filters `undefined` awareness entries before calling `onEditorsUpdated`
+- Does not mutate `awareness.getStates()` directly
+- Composes `yCollab(null)` + `yRichCursors(awareness, { onClickIndicator: scrollCallbackRef })` to achieve text-sync, undo, rich cursor rendering, and off-screen indicator click handling
+- Creates and registers a `scrollFn` callback (requirement 6) that resolves a remote user's cursor position and dispatches a CodeMirror scroll effect
+- Owns the `scrollCallbackRef` lifecycle: writes `scrollFn` to `scrollCallbackRef.current` when the scroll function is ready; writes `null` on cleanup
+
+**Dependencies**
+- Outbound: `yCollab` from `y-codemirror.next` — text-sync and undo (P0)
+- Outbound: `yRichCursors` — rich cursor rendering (P0)
+- Outbound: `provider.awareness` — read states, set local state (P0)
+- Outbound: `EditorView.scrollIntoView` — scroll dispatch (P0)
+
+**Contracts**: State [x]
+
+##### State Management
+
+- **Bug fix — `emitEditorList`**:
+  ```
+  Before: Array.from(getStates().values(), v => v.editors)   // contains undefined
+  After:  Array.from(getStates().values())
+            .map(v => v.editors)
+            .filter((v): v is EditingClient => v != null)
+  ```
+- **Bug fix — `updateAwarenessHandler`**: Remove `awareness.getStates().delete(clientId)` for all `update.removed` entries; Yjs removes them before emitting the event.
+- **Extension composition change**:
+  ```
+  Before: yCollab(activeText, provider.awareness, { undoManager })
+  After:  [
+            yCollab(activeText, null, { undoManager }),
+            yRichCursors(provider.awareness),
+          ]
+  ```
+  Note: `yCollab` already includes `yUndoManagerKeymap` in its return array, so it must NOT be added separately to avoid keymap duplication. Verify during implementation by inspecting the return value of `yCollab`.
+
+**Implementation Notes**
+- Integration: `yCollab` with `null` awareness suppresses `yRemoteSelections` and `yRemoteSelectionsTheme`. Text-sync (`ySync`) and undo (`yUndoManager`) are not affected by the null awareness value.
+- Risks: If `y-codemirror.next` is upgraded, re-verify that passing `null` awareness still suppresses only the cursor plugins.
+
+##### Configuration Type Extension (Requirements 6, 6.6)
+
+```typescript
+type Configuration = {
+  user?: IUserHasId;
+  pageId?: string;
+  reviewMode?: boolean;
+  onEditorsUpdated?: (clientList: EditingClient[]) => void;
+  // called with the scroll function when provider+ydoc are ready; null on cleanup
+  onScrollToRemoteCursorReady?: (fn: ((clientId: number) => void) | null) => void;
+};
+```
+
+**`scrollCallbackRef` pattern** (new for req 6.6):
+
+```typescript
+// Defined inside useCollaborativeEditorMode, created once per hook mount
+const scrollCallbackRef: { current: ((clientId: number) => void) | null } = useRef(null);
+
+// Extension creation effect (depends on provider, activeDoc, codeMirrorEditor)
+// scrollCallbackRef is captured by reference — stable across provider changes
+yRichCursors(provider.awareness, { onClickIndicator: scrollCallbackRef })
+
+// Scroll function registration effect (same dependencies)
+scrollCallbackRef.current = scrollFn;   // updated silently — no extension recreation
+onScrollToRemoteCursorReady?.(scrollFn);
+
+// Cleanup
+scrollCallbackRef.current = null;
+onScrollToRemoteCursorReady?.(null);
+```
+
+The `scrollFn` is shared by both paths (avatar click via atom, indicator click via ref). Its logic:
+
+```
+scrollFn(clientId: number):
+  1. view = codeMirrorEditor?.view          → undefined → return (editor not mounted)
+  2. rawState = awareness.getStates().get(clientId) as AwarenessState | undefined
+  3. cursor?.head == null                   → return (req 6.3: no-op)
+  4. absPos = Y.createAbsolutePositionFromRelativePosition(cursor.head, activeDoc)
+  5. absPos == null                         → return
+  6. view.dispatch({ effects: EditorView.scrollIntoView(absPos.index, { y: 'center' }) })
+```
+
+---
+
+### packages/editor — Extension
+
+#### `yRichCursors` (new)
+
+| Field | Detail |
+|-------|--------|
+| Intent | CodeMirror ViewPlugin — broadcasts local cursor position, renders in-viewport cursors with overlay avatar and hover-revealed name, renders clickable off-screen indicators pinned to editor edges for cursors outside the viewport |
+| Requirements | 3.1–3.10, 4.1–4.9, 6.6, 6.7 |
+
+**Responsibilities & Constraints**
+- On each `ViewUpdate`: derives local cursor anchor/head → converts to Yjs relative positions → calls `awareness.setLocalStateField('cursor', { anchor, head })` (matches `state.cursor` convention from `y-codemirror.next`)
+- On awareness `change` event: rebuilds decoration set reading `state.editors` (color, name, imageUrlCached) and `state.cursor` (anchor, head) for each remote client
+- Does NOT render a cursor for the local client (`clientid === awareness.doc.clientID`)
+- Selection highlight (background color from `state.editors.colorLight`) is rendered alongside the caret widget
+
+**Dependencies**
+- External: `@codemirror/view` `ViewPlugin`, `WidgetType`, `Decoration`, `EditorView` (P0)
+- External: `@codemirror/state` `RangeSet`, `Annotation` (P0) — `Annotation.define<number[]>()` used for `yRichCursorsAnnotation`
+- External: `yjs` `createRelativePositionFromTypeIndex`, `createAbsolutePositionFromRelativePosition` (P0)
+- External: `y-codemirror.next` `ySyncFacet` (to access `ytext` for position conversion) (P0)
+- External: `y-websocket` — `Awareness` type derived via `WebsocketProvider['awareness']` (not `y-protocols/awareness`, which is not a direct dependency) (P0)
+- Inbound: `provider.awareness` passed as parameter (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+/** Mutable ref container for the scroll-to-remote-cursor function. */
+type ScrollCallbackRef = { current: ((clientId: number) => void) | null };
+
+/** Options for the yRichCursors extension. */
+type YRichCursorsOptions = {
+  /**
+   * Mutable ref holding the scroll-to-remote-cursor callback.
+   * When set, off-screen indicator clicks invoke ref.current(clientId).
+   * Null or unset means clicks are no-ops.
+   */
+  onClickIndicator?: ScrollCallbackRef;
+};
+
+/**
+ * Creates a CodeMirror Extension that renders remote user cursors with
+ * name labels and avatar images, reading user data from state.editors.
+ * Also broadcasts the local user's cursor position via state.cursor.
+ * Renders clickable off-screen indicators for cursors outside the viewport.
+ */
+export function yRichCursors(awareness: Awareness, options?: YRichCursorsOptions): Extension;
+```
+
+Preconditions:
+- `awareness` is an active `y-websocket` Awareness instance
+- `ySyncFacet` is installed by a preceding `yCollab` call so that `ytext` can be resolved for position conversion
+- If `options.onClickIndicator` is provided, `onClickIndicator.current` must be set before any indicator click occurs (typically set synchronously by `use-collaborative-editor-mode` in the scroll-function registration effect)
+
+Postconditions:
+- Remote cursors within the visible viewport are rendered as `cm-yRichCaret` widget decorations at each remote client's head position
+- Remote cursors outside the visible viewport are rendered as off-screen indicator overlays pinned to the top or bottom edge of `view.dom`; each indicator responds to click events by invoking `options.onClickIndicator?.current(clientId)` 
+- Local cursor position is broadcast to awareness as `state.cursor.{ anchor, head }` on each focus-selection change
+
+Invariants:
+- Local client's own cursor is never rendered
+- Cursor decorations are rebuilt when awareness `change` fires for **remote** clients (dispatched via `yRichCursorsAnnotation`); local-only changes are ignored to prevent recursive `dispatch` during an in-progress update
+- `state.cursor` field is written exclusively by `yRichCursors`; no other plugin or code path may call `awareness.setLocalStateField('cursor', ...)` to avoid data races
+- Off-screen indicator click is a no-op when `options.onClickIndicator` is undefined or `.current` is null
+
+##### Widget DOM Structure
+
+```
+<span class="cm-yRichCaret" style="border-color: {color}">
+  ⁠ <!-- Word Joiner (\u2060): inherits line font-size so caret height follows headers -->
+  <span class="cm-yRichCursorFlag [cm-yRichCursorActive]">
+    <img class="cm-yRichCursorAvatar" style="border-color: {color}" />
+      OR  <span class="cm-yRichCursorInitials" style="background-color: {color}; border-color: {color}" />
+    <span class="cm-yRichCursorInfo" style="background-color: {color}">{name}</span>
+  </span>
+</span>
+```
+
+**CSS strategy**: Applied via `EditorView.baseTheme` in `theme.ts`, exported alongside the ViewPlugin.
+
+Key design decisions:
+- **Caret**: Both-side 1px borders with negative margins (zero layout width). Modeled after `yRemoteSelectionsTheme` in `y-codemirror.next`.
+- **Overlay flag**: `position: absolute; top: 100%` below the caret. Always hoverable (no `pointer-events: none`), so the avatar is a direct hover target.
+- **Name label**: Positioned at `left: 0; z-index: -1` (behind the avatar). Left border-radius matches the avatar circle, creating a tab shape that flows from the avatar. Left padding clears the avatar width. Shown on `.cm-yRichCursorFlag:hover`.
+- **Opacity**: `cm-yRichCursorFlag` carries `opacity: IDLE_OPACITY` and transitions to `opacity: 1` on hover or `.cm-yRichCursorActive` (3-second activity window).
+- **Avatar border**: `1.5px solid` border in the cursor's `color` with `box-sizing: border-box` so the 20×20 outer size is preserved. Applied via inline `style.borderColor` in `toDOM()` / `createInitialsElement()`.
+- **Design tokens**: `AVATAR_SIZE = '20px'` and `IDLE_OPACITY = '0.6'` are defined at the top of `theme.ts` and shared across all cursor/off-screen styles.
+
+**Design decision — CSS-only, no React**: The overlay, sizing, and hover behavior are achievable with `position: absolute` and `:hover`. `document.createElement` in `toDOM()` avoids React's async rendering overhead and context isolation.
+
+**Activity tracking** (JavaScript, within `YRichCursorsPluginValue`):
+- `lastActivityMap: Map<number, number>` — `clientId` → timestamp of last awareness change
+- `activeTimers: Map<number, ReturnType<typeof setTimeout>>` — per-client 3-second inactivity timers
+- On awareness `change` for remote clients: update timestamp, reset timer. Timer expiry dispatches with `yRichCursorsAnnotation` to trigger decoration rebuild.
+- `isActive` is passed to both `RichCaretWidget` and off-screen indicators. `eq()` includes `isActive` so state transitions trigger widget re-creation (at most twice per user per 3-second cycle).
+
+`RichCaretWidget` (extends `WidgetType`):
+- Constructor: `RichCaretWidgetOptions` object (`color`, `name`, `imageUrlCached`, `isActive`)
+- `toDOM()`: creates the DOM tree above; `onerror` on `<img>` replaces with initials fallback
+- `eq(other)`: true when all option fields match
+- `estimatedHeight`: `-1` (inline widget), `ignoreEvent()`: `true`
+
+Selection highlight: `Decoration.mark` on selected range with `background-color: {colorLight}`.
+
+##### Off-Screen Cursor Indicators
+
+When a remote cursor's absolute position falls outside the actually visible viewport, the ViewPlugin renders an off-screen indicator instead of a widget decoration.
+
+**Viewport classification — multi-mode strategy**: Because `view.visibleRanges` and `view.viewport` are equal in GROWI's page-scroll editor setup (the editor expands to full content height; the browser page handles scrolling), a single character-position comparison is insufficient. The plugin uses three modes, chosen once per `update()` call:
+
+| Mode | Condition | Method |
+|------|-----------|--------|
+| **rangedMode** | `visibleRanges` is a non-empty, non-trivial sub-range of `viewport` (internal-scroll editor, or jsdom tests with styled heights) | Compare `headIndex` against `visibleRanges[0].from` / `visibleRanges[last].to` |
+| **coords mode** | `visibleRanges == viewport` AND `scrollDOM.getBoundingClientRect().height > 0` (GROWI's page-scroll production setup) | `lineBlockAt(headIndex)` + `scrollDOMRect.top` vs `window.innerHeight` |
+| **degenerate** | `scrollRect.height == 0` (jsdom with 0-height container) | No off-screen classification; every cursor gets a widget decoration |
+
+`view.lineBlockAt()` reads stored height-map data (safe to call in `update()`). `scrollDOM.getBoundingClientRect()` is a raw DOM call, not restricted by CodeMirror's "Reading the editor layout isn't allowed during an update" guard.
+
+**DOM management**: The ViewPlugin creates two persistent container elements (`topContainer`, `bottomContainer`) and appends them to `view.dom` in the `constructor`. They are removed in `destroy()`. The containers are always present in the DOM but empty when no off-screen cursors exist in that direction.
+
+```
+view.dom (position: relative — already set by CodeMirror)
+├── .cm-scroller (managed by CM)
+│   └── .cm-content ...
+├── .cm-offScreenTop    ← topContainer (absolute, top: 0, height: AVATAR_SIZE + 14px)
+│   ├── .cm-offScreenIndicator  style="left: {colX}px; transform: translateX(-50%)"
+│   │   ├── .cm-offScreenArrow (material-symbols-outlined) — "arrow_drop_up"
+│   │   └── .cm-offScreenAvatar / .cm-offScreenInitials
+│   └── .cm-offScreenIndicator  (another user, different column)
+└── .cm-offScreenBottom ← bottomContainer (absolute, bottom: 0, height: AVATAR_SIZE + 14px)
+    └── .cm-offScreenIndicator  style="left: {colX}px; transform: translateX(-50%)"
+        ├── .cm-offScreenAvatar / .cm-offScreenInitials
+        └── .cm-offScreenArrow (material-symbols-outlined) — "arrow_drop_down"
+```
+
+**`OffScreenIndicatorOptions` type extension** (req 6.6, 6.7):
+
+```typescript
+export type OffScreenIndicatorOptions = {
+  direction: 'above' | 'below';
+  clientId: number;                          // NEW: identifies user for click handler
+  color: string;
+  name: string;
+  imageUrlCached: string | undefined;
+  isActive: boolean;
+  onClick?: (clientId: number) => void;      // NEW: invoked on indicator click
+};
+```
+
+`createOffScreenIndicator` attaches a `click` event listener on the root `<span>` element that calls `onClick(clientId)`. The indicator root element also receives `style.cursor = 'pointer'` when `onClick` is provided, satisfying req 6.7.
+
+**Indicator DOM structure** (built by `createOffScreenIndicator()` in `off-screen-indicator.ts`):
+- **above**: `[arrow_drop_up icon][avatar or initials]` stacked vertically (flex-column)
+- **below**: `[avatar or initials][arrow_drop_down icon]` stacked vertically (flex-column)
+- Arrow element: `<span class="material-symbols-outlined cm-offScreenArrow" style="color: {color}">arrow_drop_up</span>` — font loaded via `var(--grw-font-family-material-symbols-outlined)` (Next.js-registered Material Symbols Outlined)
+- Avatar: same `borderColor`, `AVATAR_SIZE`, and onerror→initials fallback as in-editor widget
+- Opacity: arrow always `opacity: 1`; avatar/initials use `IDLE_OPACITY` → `1` via `.cm-yRichCursorActive` on the indicator
+
+**Horizontal positioning** (deferred to measure phase):
+After `replaceChildren()`, the plugin calls `view.requestMeasure()`:
+- **read phase**: for each indicator, call `view.coordsAtPos(headIndex, 1)` to get screen X. If null (virtualized position), fall back to `contentDOM.getBoundingClientRect().left + col * view.defaultCharacterWidth`.
+- **write phase**: set `indicator.style.left = Xpx` and `indicator.style.transform = 'translateX(-50%)'` to center the indicator on the cursor column.
+
+**Update cycle**:
+1. Classify all remote cursors (mode-dependent: rangedMode/coords/degenerate)
+2. Build `aboveIndicators: {el, headIndex}[]` and `belowIndicators: {el, headIndex}[]`
+3. `topContainer.replaceChildren(...aboveIndicators.map(i => i.el))`; same for bottom
+4. If any indicators exist, call `view.requestMeasure()` to set horizontal positions
+5. Cursors that lack `state.cursor` or `state.editors` are excluded from both in-view and off-screen rendering
+
+**Implementation Notes**
+- Integration: file location `packages/editor/src/client/services-internal/extensions/y-rich-cursors/` (directory — split into `index.ts`, `plugin.ts`, `widget.ts`, `off-screen-indicator.ts`, `theme.ts`); exported from `packages/editor/src/client/services-internal/extensions/index.ts` and consumed directly in `use-collaborative-editor-mode.ts`
+- Validation: `imageUrlCached` is optional; if undefined or empty, the `<img>` element is skipped and only initials are shown
+- Risks: `ySyncFacet` must be present in the editor state when the plugin initializes; guaranteed since `yCollab` (which installs `ySyncFacet`) is added before `yRichCursors` in the extension array
+
+---
+
+### apps/app — Jotai Atom
+
+#### `scrollToRemoteCursorAtom` (new)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Stores the scroll-to-remote-cursor callback registered by `useCollaborativeEditorMode`; consumed by `EditorNavbar` → `EditingUserList` |
+| Requirements | 6.1 |
+
+**File**: `apps/app/src/states/ui/editor/scroll-to-remote-cursor.ts`
+
+```typescript
+const scrollToRemoteCursorAtom = atom<((clientId: number) => void) | null>(null);
+
+/** Read the scroll callback (null when collaboration is not active) */
+export const useScrollToRemoteCursor = (): ((clientId: number) => void) | null =>
+  useAtomValue(scrollToRemoteCursorAtom);
+
+/** Register or clear the scroll callback.
+ *  Wraps the raw setAtom call to prevent Jotai from treating a function
+ *  value as an updater.  Jotai's `setAtom(fn)` signature interprets `fn`
+ *  as `(prev) => next`; passing `setAtom(() => fn)` forces it to store
+ *  the function value itself instead of invoking it. */
+export const useSetScrollToRemoteCursor = (): ((
+  fn: ((clientId: number) => void) | null,
+) => void) => {
+  const setAtom = useSetAtom(scrollToRemoteCursorAtom);
+  return useCallback(
+    (fn: ((clientId: number) => void) | null) => {
+      setAtom(() => fn);
+    },
+    [setAtom],
+  );
+};
+```
+
+**Lifecycle**: set when `useCollaborativeEditorMode`'s extension effect runs, cleared on effect cleanup.
+
+---
+
+### packages/ui — Component
+
+#### `UserPicture` (refactored)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Eliminate the `withTooltip` HOC that returns a React Fragment, replacing it with inline tooltip rendering as a portal child of the root `<span>` |
+| Requirements | 7.1, 7.3–7.5 |
+
+**Problem**: The current `withTooltip` HOC wraps `UserPictureRootWithoutLink`/`UserPictureRootWithLink` and returns a Fragment (`<> <span ref={ref}><img/></span> <UncontrolledTooltip target={ref}/> </>`). When the HOC-wrapped `UserPicture` is placed inside a flex container (e.g., the `<button>` in `EditingUserList`), the Fragment's two React children can cause unpredictable flex layout behavior.
+
+**Refactoring approach**: Eliminate the `withTooltip` HOC entirely. Render the tooltip inline within `UserPicture`'s render function as a child of the root `<span>`:
+
+```typescript
+export const UserPicture = memo((userProps: Props): JSX.Element => {
+  const { user, size, noLink, noTooltip, className: additionalClassName } = userProps;
+  // ... existing field extraction (username, displayName, src, className) ...
+
+  const showTooltip = !noTooltip && hasName(user);
+  const rootRef = useRef<HTMLSpanElement>(null);
+
+  const tooltipClassName = `${moduleTooltipClass} user-picture-tooltip-${size ?? 'md'}`;
+
+  const children = (
+    <>
+      {imgElement}
+      {showTooltip && (
+        <UncontrolledTooltip
+          placement="bottom"
+          target={rootRef}
+          popperClassName={tooltipClassName}
+          delay={0}
+          fade={false}
+        >
+          {username ? <>{`@${username}`}<br /></> : null}
+          {displayName}
+        </UncontrolledTooltip>
+      )}
+    </>
+  );
+
+  if (username == null || noLink) {
+    return (
+      <UserPictureRootWithoutLink ref={rootRef} displayName={displayName} size={size}>
+        {children}
+      </UserPictureRootWithoutLink>
+    );
+  }
+
+  return (
+    <UserPictureRootWithLink ref={rootRef} displayName={displayName} size={size} username={username}>
+      {children}
+    </UserPictureRootWithLink>
+  );
+});
+```
+
+**Why this works**: `UncontrolledTooltip` (reactstrap) uses `ReactDOM.createPortal` to render tooltip markup into `document.body`. When placed as a child of the root `<span>`, it occupies no space in the parent's DOM — only the `<img>` is a visible child. The root element is always a single `<span>`, regardless of whether the tooltip is shown. This eliminates the Fragment-induced flex layout issue.
+
+**Key changes**:
+- `withTooltip` HOC function: **deleted**
+- `useRef` for tooltip targeting: moved from HOC into `UserPicture` render body (always called unconditionally — satisfies React hooks rules)
+- `rootRef` is passed to `UserPictureRootWithoutLink`/`UserPictureRootWithLink` via `forwardRef` (they already support it)
+- Tooltip content, `popperClassName`, `delay`, `fade` values: preserved from the existing HOC
+- **`next/dynamic()` import: preserved** — the module-level `const UncontrolledTooltip = dynamic(() => import('reactstrap')..., { ssr: false })` is unchanged. This maintains the code-split boundary: reactstrap is loaded as a separate chunk only when the tooltip is actually rendered. Consumers passing `noTooltip` never trigger the chunk load.
+- `UserPicture.module.scss`: **unchanged** (tooltip margin classes still referenced via `popperClassName`)
+- `noTooltip` prop: **preserved** for consumers that intentionally suppress tooltips (e.g., sidebar dropdowns, inline notifications)
+
+**Impact on existing consumers**: The public API (`Props`) is unchanged. The only observable difference is that the returned React element is always a single-root `<span>` (no Fragment), which is layout-safe in all container types (flex, grid, inline). Existing `noTooltip` usages continue to work.
+
+---
+
+### apps/app — Component
+
+#### `EditingUserList` (modified)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Displays active editor avatars with color-matched borders, click-to-scroll, and username tooltips on hover |
+| Requirements | 5.1–5.3, 6.1, 6.4–6.5, 7.1–7.5 |
+
+**Props change**:
+
+```typescript
+type Props = {
+  clientList: EditingClient[];
+  onUserClick?: (clientId: number) => void;  // NEW: scroll-to-cursor callback
+};
+```
+
+**Color-matched border (req 5.1–5.3)**:
+
+`UserPicture` does not accept a `style` prop (the prop is applied to the `<img>` tag, not the root element). A wrapper `<span>` with an inline border style is used instead:
+
+```
+<span
+  style={{ border: `2px solid ${editingClient.color}`, borderRadius: '50%', display: 'inline-block' }}
+>
+  <UserPicture user={editingClient} noLink />
+</span>
+```
+
+The `border border-info` className is removed from `UserPicture`.
+
+**Click-to-scroll (req 6.1, 6.4)**:
+
+Each avatar wrapper is made interactive:
+
+```
+<button
+  type="button"
+  style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
+  onClick={() => onUserClick?.(editingClient.clientId)}
+>
+  <span style={{ border: `2px solid ${editingClient.color}`, borderRadius: '50%', display: 'inline-block' }}>
+    <UserPicture user={editingClient} noLink />
+  </span>
+</button>
+```
+
+**Username tooltip (req 7.1–7.5)**:
+
+Tooltips are provided by `UserPicture` natively, after the HOC refactoring (see `UserPicture` component section below). The `noTooltip` prop is **removed** from all `UserPicture` usages in `EditingUserList`. Because the refactored `UserPicture` renders the tooltip as a portal child of its root `<span>` (not a Fragment sibling), the tooltip coexists cleanly with the flex `<button>` wrapper.
+
+```
+// AvatarWrapper renders (simplified — no external tooltip needed):
+<button
+  type="button"
+  data-testid={`avatar-wrapper-${client.clientId}`}
+  className={`${avatarWrapperClass} d-inline-flex ...`}
+  style={{ border: `2px solid ${client.color}` }}
+  onClick={() => onUserClick?.(client.clientId)}
+>
+  <UserPicture user={client} noLink />
+</button>
+```
+
+**Key decisions**:
+- The same `AvatarWrapper` sub-component is reused for both the first-4 direct avatars and the overflow popover avatars, so the tooltip applies uniformly (req 7.2).
+- No `id` attribute generation or external `UncontrolledTooltip` needed — the tooltip is fully encapsulated within `UserPicture`.
+
+**Overflow popover (req 5.3, 6.5, 7.2)**:
+
+`UserPictureList` (a generic legacy class component that does not accept `onUserClick` or color props) is replaced by inline rendering within `EditingUserList`, using the same `AvatarWrapper` sub-component for `remainingUsers`. This gives overflow avatars the same color border, click-to-scroll, and tooltip behavior.
+
+**`EditorNavbar` wiring**:
+
+```typescript
+// EditorNavbar.tsx
+const EditingUsers = (): JSX.Element => {
+  const editingClients = useEditingClients();
+  const scrollToRemoteCursor = useScrollToRemoteCursor();
+  return (
+    <EditingUserList
+      clientList={editingClients}
+      onUserClick={scrollToRemoteCursor ?? undefined}
+    />
+  );
+};
+```
+
+**`PageEditor` wiring**:
+
+```typescript
+// PageEditor.tsx — existing hook setup
+const setScrollToRemoteCursor = useSetScrollToRemoteCursor();
+// ...
+<CodeMirrorEditorMain
+  onScrollToRemoteCursorReady={setScrollToRemoteCursor}
+  // ...existing props
+/>
+```
+
+## Data Models
+
+### Domain Model
+
+No new persistent data. The awareness state already carries all required fields via the `EditingClient` interface in `state.editors`.
+
+```typescript
+// Existing — no changes
+type EditingClient = Pick<IUser, 'name'> &
+  Partial<Pick<IUser, 'username' | 'imageUrlCached'>> & {
+    clientId: number;
+    userId?: string;
+    color: string;       // cursor caret and flag background color
+    colorLight: string;  // selection range highlight color
+  };
+```
+
+The `state.cursor` awareness field follows the existing `y-codemirror.next` convention:
+```typescript
+type CursorState = {
+  anchor: RelativePosition; // Y.RelativePosition JSON
+  head: RelativePosition;
+};
+```
+
+## Error Handling
+
+| Error Type | Scenario | Response |
+|------------|----------|----------|
+| Missing `editors` field | Client connects but has not set awareness yet | Filtered out in `emitEditorList`; not rendered in `EditingUserList` |
+| Avatar image load failure | `imageUrlCached` URL returns 4xx/5xx | `<img>` `onerror` replaces element with initials `<span>` (colored circle with user initials) |
+| `state.cursor` absent | Remote client connected but editor not focused | Cursor widget not rendered for that client (no `cursor.anchor` → skip) |
+| `ySyncFacet` not installed | `yRichCursors` initialized before `yCollab` | Position conversion returns `null`; cursor is skipped for that update cycle. Extension array order in `use-collaborative-editor-mode` guarantees correct sequencing. |
+| Off-screen container detached | `view.dom` removed from DOM before `destroy()` | `destroy()` calls `remove()` on both containers; if already detached, `remove()` is a no-op |
+| Viewport not yet initialized | First `update()` before CM calculates viewport | `view.viewport` always has valid `from`/`to` from initialization; safe to compare |
+| Click-to-scroll: view not mounted | `scrollFn` called before CodeMirror mounts | `codeMirrorEditor?.view == null` guard returns early; no crash |
+| Click-to-scroll: cursor absent | Clicked user has no `cursor.head` in awareness | Guard `cursor?.head == null → return`; no-op per req 6.3 |
+| Click-to-scroll: position unresolvable | `createAbsolutePositionFromRelativePosition` returns `null` (stale document state) | Guard `absPos == null → return`; no crash |
+
+## Testing Strategy
+
+Test files are co-located with source in `y-rich-cursors/`:
+- **Unit**: `widget.spec.ts` (DOM structure, eq, fallback), `off-screen-indicator.spec.ts` (indicator DOM, direction, fallback)
+- **Integration**: `plugin.integ.ts` (awareness filter, cursor broadcast, viewport classification, activity timers)
+- **E2E** (Playwright, deferred): hover behavior, off-screen scroll transitions, pointer-events pass-through
+
+### Additional Tests for Requirements 5, 6, 6.6–6.7, and 7
+
+- **Unit — `UserPicture.spec.tsx`** (new or extended in `packages/ui`):
+  - Without `noTooltip`: renders a single root `<span>` (no Fragment) containing `<img>` and portal tooltip (req 7.4)
+  - With `noTooltip`: renders a single root `<span>` containing only `<img>` (existing behavior preserved)
+  - Tooltip content includes `@username` and display name when both available (req 7.1)
+  - Root element is flex-layout-safe (single child, not Fragment) (req 7.3)
+
+- **Unit — `EditingUserList.spec.tsx`** (new or extended):
+  - Renders a colored border wrapper matching `editingClient.color` (req 5.1)
+  - Does not render `border-info` class (req 5.1)
+  - Calls `onUserClick(clientId)` when avatar is clicked (req 6.1, 6.4)
+  - Overflow popover avatars also call `onUserClick` (req 6.5)
+  - `UserPicture` rendered without `noTooltip` (tooltip delegated to `UserPicture`) (req 7.2)
+
+- **Integration — `use-collaborative-editor-mode` scroll test** (extended):
+  - `onScrollToRemoteCursorReady` is called with a function when provider is set up
+  - `scrollFn(clientId)` dispatches `scrollIntoView` to the view when cursor is available (req 6.1–6.2)
+  - `scrollFn(clientId)` is a no-op when cursor is absent (req 6.3)
+  - `scrollCallbackRef.current` is set when the scroll function is registered; cleared on cleanup (req 6.6)
+
+- **Unit — `off-screen-indicator.spec.ts`** (extended):
+  - Indicator root element has `cursor: pointer` when `onClick` is provided (req 6.7)
+  - Clicking the indicator element invokes `onClick(clientId)` (req 6.6)
+  - No click handler or `cursor: pointer` when `onClick` is not provided (boundary test)
+
+- **Integration — `plugin.integ.ts`** (extended):
+  - When `onClickIndicator` ref is set and an off-screen indicator is clicked, `ref.current` is invoked with the correct `clientId` (req 6.6)
+  - When `onClickIndicator.current` is null, clicking an off-screen indicator does not throw (req 6.6, no-op guard)

+ 107 - 0
.kiro/specs/collaborative-editor-awareness/requirements.md

@@ -0,0 +1,107 @@
+# Requirements Document
+
+## Project Description (Input)
+collaborative-editor-awareness
+
+## Introduction
+
+GROWI's collaborative editor uses Yjs awareness protocol to track which users are currently editing a page and where their cursors are positioned. This awareness information is surfaced in two places: the `EditingUserList` component in the editor navbar (showing active user avatars), and the in-editor cursor decorations rendered by `y-codemirror.next`.
+
+**Scope**: Client-side awareness state management, `EditingUserList` display stability (bug fix), and rich cursor rendering (username + avatar) in the CodeMirror editor.
+
+**Out of Scope**: Server-side awareness bridging to Socket.IO (covered in `collaborative-editor` spec), WebSocket transport, MongoDB persistence, or authentication.
+
+**Inherited from**: `collaborative-editor` — Requirement 5 (Awareness and Presence Tracking). That spec now delegates awareness display behavior to this specification.
+
+## Requirements
+
+### Requirement 1: Awareness State Stability
+
+**Objective:** As a wiki user viewing the collaborative editor, I want the editing user list to remain visible and accurate at all times while other users are connected, so that I can reliably see who is co-editing with me.
+
+#### Acceptance Criteria
+
+1. The Collaborative Editor Client shall filter out any awareness state entries that do not contain a valid `editors` field before passing the client list to `EditingUserList`, so that `undefined` values never appear in the rendered list.
+2. The Collaborative Editor Client shall not manually mutate `awareness.getStates()` (e.g., call `.delete()` on removed client IDs), as the Yjs awareness system already removes stale entries before firing the `update` event.
+3. While a user is connected and at least one other user is in the same editing session, the EditingUserList shall remain visible and not disappear due to transient undefined values or internal map mutations.
+4. If an awareness state entry is received without an `editors` field (e.g., from a client that has not yet broadcast its presence), the Collaborative Editor Client shall silently skip that entry rather than propagating an undefined value.
+
+### Requirement 2: Awareness Presence Tracking (Inherited)
+
+**Objective:** As a wiki user, I want to see which other users are currently editing the same page, so that I can coordinate edits and avoid conflicts.
+
+#### Acceptance Criteria
+
+1. While a user is editing a page, the Collaborative Editor Client shall broadcast the user's presence information (name, username, avatar URL, cursor color) via the Yjs awareness protocol using the `editors` field on the local awareness state.
+2. When a user connects or disconnects from a collaborative editing session, the Yjs Service shall emit awareness state size updates to the page's Socket.IO room (`page:{pageId}`) via `YjsAwarenessStateSizeUpdated`.
+3. When the last user disconnects from a document, the Yjs Service shall emit a draft status notification (`YjsHasYdocsNewerThanLatestRevisionUpdated`) to the page's Socket.IO room.
+4. The Collaborative Editor Client shall display the list of active editors based on awareness state updates received from the Yjs WebSocket provider.
+
+### Requirement 3: Rich Cursor Display (Overlay Avatar)
+
+**Objective:** As a wiki user editing collaboratively, I want to see other users' cursors with their profile image as an overlay, so that I can easily identify who is editing where in the document without the cursor widget disrupting the text layout.
+
+#### Acceptance Criteria
+
+1. While multiple users are editing the same page, the Collaborative Editor Client shall render each remote user's cursor with a profile image (avatar) positioned directly below the caret line, as an overlay that does not consume block space in the editor content flow.
+2. The avatar overlay size shall be 20×20 CSS pixels (circular), smaller than `EditingUserList` to minimize interference with editor content.
+3. While hovering over the avatar overlay, the Collaborative Editor Client shall display the user's display name in a tooltip-like label adjacent to the avatar. When not hovered, the name label shall be hidden.
+4. When `imageUrlCached` is available in the remote user's awareness state, the avatar shall display that image. If `imageUrlCached` is unavailable or fails to load, the avatar shall fall back to the user's initials rendered in a colored circle.
+5. The cursor caret color, avatar fallback background color, and avatar border color shall all match the `color` value from the user's awareness state, consistent with the color shown in `EditingUserList`. A colored circular border (matching `color`) shall be applied to both avatar images and initials circles to visually associate the avatar with the cursor.
+6. The Collaborative Editor Client shall suppress the default cursor plugin by passing `null` as the awareness argument to `yCollab` (from `y-codemirror.next`), and use the separate `yRichCursors` extension for cursor rendering.
+7. When a user's awareness state changes (e.g., cursor moves), the Collaborative Editor Client shall re-render that user's cursor with up-to-date information without re-mounting the entire cursor set.
+8. The avatar overlay shall be rendered at reduced opacity (semi-transparent) by default to minimize visual distraction.
+9. While the user hovers over the avatar overlay or cursor caret, the avatar shall be displayed at full opacity (1.0).
+10. When a remote user is actively editing (awareness cursor state has changed within the last 3 seconds), their avatar shall be displayed at full opacity (1.0). After 3 seconds of inactivity (no cursor/awareness change), the avatar shall return to the reduced opacity state.
+
+### Requirement 4: Off-Screen Cursor Indicators
+
+**Objective:** As a wiki user editing collaboratively, I want to know when other users are editing parts of the document that are not currently visible in my viewport, so that I am aware of all editing activity even outside my scroll position.
+
+#### Acceptance Criteria
+
+1. When a remote user's cursor is positioned above the current visible viewport, the Collaborative Editor Client shall display that user's avatar icon pinned to the top edge of the editor. A `arrow_drop_up` Material Symbol icon (in the cursor's color) shall be stacked above the avatar to indicate the user is editing above the visible area.
+2. When a remote user's cursor is positioned below the current visible viewport, the Collaborative Editor Client shall display that user's avatar icon pinned to the bottom edge of the editor. A `arrow_drop_down` Material Symbol icon (in the cursor's color) shall be stacked below the avatar to indicate the user is editing below the visible area.
+3. When a remote user's cursor is within the visible viewport, no off-screen indicator shall be shown for that user (the in-editor cursor widget from Requirement 3 is shown instead).
+4. The off-screen indicator shall use the same avatar image (or initials fallback) and color as the in-editor cursor widget, maintaining visual consistency.
+5. When multiple remote users are off-screen in the same direction (above or below), each indicator shall be independently positioned horizontally to reflect its remote cursor's column position in the document. Indicators at different columns appear at different horizontal positions within the container.
+6. When the user scrolls and a previously off-screen cursor enters the viewport, the off-screen indicator for that user shall be removed and the in-editor cursor widget shall appear instead. Conversely, when a previously visible cursor leaves the viewport due to scrolling, an off-screen indicator shall appear.
+7. The off-screen indicators shall be rendered as overlays (absolute positioning within the editor container) and shall not affect the editor's scroll height or content layout.
+8. The horizontal position of each off-screen indicator shall reflect the remote cursor's column position in the document. The `left` CSS value of the indicator shall be derived from the cursor's screen X coordinate (via `view.coordsAtPos` in the measure phase) or, for virtualized positions, approximated using character-width estimation. The indicator shall be centered on the cursor column (`transform: translateX(-50%)`).
+9. The direction arrow icon of the off-screen indicator shall always be rendered at full opacity (1.0) in the cursor's color, regardless of the idle/active state. Only the avatar image or initials element shall fade to reduced opacity (`IDLE_OPACITY`) when the user is idle, and return to full opacity when active.
+
+### Requirement 5: Color-Matched User Avatars in EditingUserList
+
+**Objective:** As a wiki user editing collaboratively, I want the avatar border in `EditingUserList` to use each user's cursor color, so that I can visually associate an avatar in the list with that user's cursor in the editor.
+
+#### Acceptance Criteria
+
+1. Each `UserPicture` in `EditingUserList` shall display a border whose color equals `editingClient.color` from the user's `EditingClient` data, replacing the current fixed `border-info` color.
+2. The border width and visual weight shall be equivalent to the existing border appearance (1–2 px solid ring).
+3. The same color-matched border shall be applied to avatars in the overflow popover (the remaining users shown via the `+N` button).
+
+### Requirement 6: Scroll to Remote Cursor on Avatar Click
+
+**Objective:** As a wiki user editing collaboratively, I want to click a user's avatar in `EditingUserList` to jump to that user's cursor position in the editor, so that I can quickly navigate to where they are editing.
+
+#### Acceptance Criteria
+
+1. Clicking an avatar in `EditingUserList` shall scroll the editor viewport so that the clicked user's remote cursor becomes visible.
+2. The editor shall scroll to center the cursor vertically (`y: 'center'`).
+3. If the clicked user has no active cursor position in the awareness state, the click shall have no effect (no error or crash).
+4. Each avatar shall display a `cursor: pointer` affordance to indicate it is clickable.
+5. Both the first-4 avatars displayed directly in the navbar and the avatars in the overflow popover shall support click-to-scroll.
+6. When an off-screen indicator (pinned to the top or bottom edge of the editor) is clicked, the Collaborative Editor Client shall scroll the editor viewport so that the corresponding remote user's cursor is centered vertically in the visible area, applying the same scroll behavior as Criteria 1–3 above.
+7. Each off-screen indicator shall display a `cursor: pointer` affordance to indicate it is clickable.
+
+### Requirement 7: Username Tooltip in EditingUserList
+
+**Objective:** As a wiki user editing collaboratively, I want to see a tooltip with the co-editor's username when hovering over their avatar in `EditingUserList`, so that I can identify each co-editor by name even when the avatar image is not recognizable.
+
+#### Acceptance Criteria
+
+1. While hovering over any avatar in `EditingUserList`, the Collaborative Editor Client shall display a tooltip containing the user's display name.
+2. The tooltip shall be applied to both the first-4 directly visible avatars and the avatars inside the overflow popover.
+3. The tooltip shall coexist with the color-matched border (Requirement 5) and the click-to-scroll behavior (Requirement 6) without visual or functional conflict.
+4. If the `UserPicture` component's `noTooltip` flag or a higher-order component (HoC) prevents native tooltip rendering, the tooltip mechanism shall be refactored — for example by embedding tooltip functionality directly in the component or by adopting a tooltip primitive (e.g., `UncontrolledTooltip`, `Tooltip` from `reactstrap`) — so that username display is not suppressed.
+5. The tooltip shall appear with standard UI delay (hover intent) and disappear when the pointer leaves the avatar area.

+ 218 - 0
.kiro/specs/collaborative-editor-awareness/research.md

@@ -0,0 +1,218 @@
+# Research & Design Decisions
+
+---
+**Purpose**: Capture discovery findings, architectural investigations, and rationale that inform the technical design.
+
+---
+
+## Summary
+
+- **Feature**: `collaborative-editor-awareness`
+- **Discovery Scope**: Extension (existing collaborative editor system); Phase 2 adds Requirements 5 & 6 (color-matched avatars + click-to-scroll)
+- **Key Findings** (original):
+  - `y-codemirror.next@0.3.5` reads `state.user` for cursor info, but GROWI sets `state.editors` — causing all cursors to render as "Anonymous" with default blue color today
+  - `yCollab` in v0.3.5 does NOT support a `cursorBuilder` option; the cursor DOM is hardcoded in `YRemoteCaretWidget`
+  - `awareness.getStates().delete(clientId)` in the current `updateAwarenessHandler` is an incorrect direct mutation of Yjs-managed internal state; Yjs removes stale entries before emitting `update`
+- **Key Findings** (Phase 2):
+  - `UserPicture` (`@growi/ui`) does not accept a `style` prop; dynamic border colors require a wrapper element approach
+  - `packages/editor` cannot import from `apps/app`; callback props (`onScrollToRemoteCursorReady`) are used to cross the package boundary
+  - `EditorView.scrollIntoView(pos, { y: 'center' })` (CodeMirror built-in) is sufficient for the scroll-to-cursor feature; no new dependencies required
+
+## Research Log
+
+### y-codemirror.next@0.3.5 Cursor API Analysis
+
+- **Context**: Requirement 3.5 proposed a `cursorBuilder` option for `yCollab`. Does the installed version support it?
+- **Sources Consulted**: Package source at `node_modules/.pnpm/y-codemirror.next@0.3.5_.../src/index.js` and `y-remote-selections.js`
+- **Findings**:
+  - `yCollab` signature: `(ytext, awareness, { undoManager }) => Extension[]`; no `cursorBuilder` parameter
+  - Cursor rendering is entirely inside `YRemoteCaretWidget.toDOM()` — hardcoded name-only label
+  - Public exports include `ySync`, `ySyncFacet`, `YSyncConfig`, `yRemoteSelections`, `yRemoteSelectionsTheme`, `yUndoManagerKeymap`. NOT exported: `yUndoManager`, `yUndoManagerFacet`, `YUndoManagerConfig`
+  - `y-remote-selections.js` reads `state.user.color` and `state.user.name`, but GROWI awareness sets `state.editors`
+- **Implications**: Requirement 3.5 cannot be fulfilled via `yCollab` option. Must replace `yRemoteSelections` with a custom ViewPlugin. Since `yUndoManager`/`yUndoManagerFacet`/`YUndoManagerConfig` are not in the public API, `yCollab` must still be used for undo; awareness must be suppressed at call site.
+
+### Awareness Field Mismatch (state.user vs state.editors)
+
+- **Context**: Why do cursors show "Anonymous" despite the provider being set up with user data?
+- **Findings**:
+  - GROWI sets: `awareness.setLocalStateField('editors', { name, color, imageUrlCached, ... })`
+  - `y-remote-selections.js` reads: `const { color, name } = state.user || {}`
+  - Result: `state.user` is always undefined → name = "Anonymous", color = default `#30bced`
+- **Implications**: Cursor name/color are currently broken. Fix requires either (a) also setting `state.user`, or (b) replacing the cursor plugin. Since we are building a rich cursor plugin anyway, the clean fix is (b).
+
+### EditingUserList Disappearance Bug Root Cause
+
+- **Context**: `EditingUserList` intermittently disappears when users are actively editing.
+- **Findings** (from `use-collaborative-editor-mode.ts` source):
+  1. `Array.from(awareness.getStates().values(), v => v.editors)` produces `undefined` for clients whose awareness state has not yet included an `editors` field
+  2. `Array.isArray(clientList)` is always `true` — the guard never filters undefined values
+  3. `EditingUserList` maps `editingClient.clientId` which throws/renders `undefined` element → React key error or render bail-out, causing the list to disappear
+  4. `awareness.getStates().delete(clientId)` for removed clients is redundant and incorrect: the Yjs awareness protocol removes stale entries from the `Map` before emitting the `update` event. This mutation may cause stale data re-entry or missed subsequent updates
+- **Implications**: Filter undefined entries and remove the `.delete()` call; no other changes to awareness-update logic required.
+
+### yCollab with null awareness
+
+- **Context**: Can we suppress `yRemoteSelections` without losing text-sync or undo functionality?
+- **Findings**:
+  - `ySync` (`YSyncPluginValue`) reads only `conf.ytext` — does not touch `conf.awareness`
+  - `yUndoManager` reads only `conf.undoManager` (via `yUndoManagerFacet`) and `conf.ytext` (via `ySyncFacet`) — does not touch awareness
+  - `yCollab` skips `yRemoteSelections` and `yRemoteSelectionsTheme` when `awareness` is falsy: `if (awareness) { plugins.push(yRemoteSelectionsTheme, yRemoteSelections) }`
+  - Calling `yCollab(activeText, null, { undoManager })` therefore produces only: `[ySyncFacet.of(ySyncConfig), ySync, yUndoManagerFacet.of(...), yUndoManager, EditorView.domEventHandlers]`
+- **Implications**: Safe to pass `null` as awareness to `yCollab` to suppress the default cursor plugin, then add `yRichCursors(provider.awareness)` separately.
+
+### Local Cursor Broadcasting Responsibility
+
+- **Context**: `yRemoteSelections` (`YRemoteSelectionsPluginValue.update()`) broadcasts the local cursor position via `awareness.setLocalStateField('cursor', { anchor, head })`. If we remove `yRemoteSelections`, who does this?
+- **Findings**:
+  - The broadcast is implemented entirely in `y-remote-selections.js` — not in `ySync`
+  - Our custom `yRichCursors` ViewPlugin must include equivalent broadcast logic: on each `view.update`, derive anchor/head from `update.state.selection.main`, convert to Yjs relative positions, and call `awareness.setLocalStateField('cursor', ...)`
+  - Cursor position uses the existing `state.cursor` field convention (unchanged)
+- **Implications**: `yRichCursors` is a full replacement for `yRemoteSelections`, not just an additive decoration layer.
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations |
+|--------|-------------|-----------|---------------------|
+| A: Set `state.user` alongside `state.editors` | Keep existing `yRemoteSelections`; set both awareness fields | Minimal code change | No avatar support; maintains the two-field redundancy; cursor info is the name only |
+| B: Custom ViewPlugin (replace `yRemoteSelections`) | `yCollab(null)` + `yRichCursors(awareness)` | Full avatar+name rendering; single source of truth in `state.editors`; clean separation | Must re-implement cursor broadcast logic (~30 lines of `y-remote-selections.js`) |
+| C: Fork `y-codemirror.next` | Patch `YRemoteCaretWidget` to accept avatar | Full control | Maintenance burden; diverges from upstream; breaks on package upgrades |
+
+**Selected: Option B** — replaces `yRemoteSelections` entirely with a purpose-built `yRichCursors` ViewPlugin.
+
+## Design Decisions
+
+### Decision: yCollab with null awareness + custom yRichCursors
+
+- **Context**: `yCollab` has no `cursorBuilder` hook; `yUndoManager` is not publicly exported; default cursor reads wrong awareness field
+- **Alternatives Considered**:
+  1. Set `state.user` — minimal change but no avatar, still redundant field
+  2. Fork library — too brittle
+- **Selected Approach**: `yCollab(activeText, null, { undoManager })` to get text-sync and undo without default cursor, plus a custom `yRichCursors(awareness)` ViewPlugin for rich cursor rendering
+- **Rationale**: Reads directly from `state.editors` (GROWI's canonical field), supports avatar, eliminates `state.user` redundancy, requires ~60 lines of new code
+- **Trade-offs**: Must maintain the cursor-broadcast logic in `yRichCursors`; if `y-codemirror.next` updates its broadcast logic we won't get those changes automatically
+- **Follow-up**: When upgrading to `y-codemirror.next >= 1.x` or `y-websocket v3`, re-evaluate if a native `cursorBuilder` API becomes available
+
+### Decision: Avatar rendered as plain DOM `<img>` in WidgetType.toDOM()
+
+- **Context**: CodeMirror cursor widgets are DOM-based (not React); `UserPicture` is a React component and cannot be used directly
+- **Selected**: Construct DOM directly using `document.createElement` in `toDOM()`: `<img>` tag for avatar with `onerror` fallback to initials
+- **Rationale**: CodeMirror `WidgetType.toDOM()` returns an `HTMLElement`; React components cannot be server-rendered in this context
+- **Trade-offs**: Slightly duplicates `UserPicture` avatar rendering; acceptable as cursor widget is presentation-only
+
+## Risks & Mitigations
+
+- `yRichCursors` broadcasts cursor positions via `awareness.setLocalStateField('cursor', ...)` on every `update` call — same as the original `yRemoteSelections`. Throttle is not needed because Yjs awareness batches broadcasts internally.
+- Avatar `<img>` may fail to load (404, CORS) — mitigate with `onerror` handler that replaces the `<img>` with initials fallback span.
+- `awareness.getStates().delete()` removal: confirm Yjs v13 awareness `update` event fires after removing the client from the internal map (verified in Yjs source: removal happens before the event).
+- **Recursive dispatch crash** (discovered during implementation): `setLocalStateField('cursor', ...)` inside the `update()` method fires an awareness `change` event **synchronously**. If the `change` listener calls `view.dispatch()` unconditionally, CodeMirror throws "Calls to EditorView.update are not allowed while an update is in progress". Mitigated by filtering the `change` listener to dispatch only when at least one **remote** client is in the changed set (`clients.findIndex(id => id !== awareness.doc.clientID) >= 0`). This matches the same pattern used by `y-remote-selections.js` in `y-codemirror.next`.
+- **`y-protocols` not a direct dependency**: `y-protocols/awareness` exports the `Awareness` class, but neither `@growi/editor` nor `apps/app` list `y-protocols` as a direct dependency. `import type { Awareness } from 'y-protocols/awareness'` fails under strict pnpm resolution. Mitigated by deriving the type from the existing `y-websocket` dependency: `type Awareness = WebsocketProvider['awareness']`.
+- **`view.viewport` vs `view.visibleRanges`** (discovered during validation): CodeMirror's `view.viewport` returns the **rendered** content range, which includes a pre-render buffer beyond the visible area for smooth scrolling. Using it for off-screen classification causes cursors in the buffer zone to be treated as in-viewport, resulting in invisible widget decorations instead of off-screen indicators. Must use `view.visibleRanges` (the ranges actually visible to the user) for accurate classification. Precedent: `setDataLine.ts` in the same package already uses `view.visibleRanges`.
+
+## Implementation Discoveries
+
+### Multi-Mode Viewport Classification
+
+- **Context**: Off-screen cursor classification using `view.visibleRanges` worked in tests (jsdom with fixed-height containers) but failed in GROWI production.
+- **Finding**: In GROWI's page-scroll editor setup, CodeMirror's `view.visibleRanges` and `view.viewport` return the **same** range (the full document), because the editor expands to content height and scrolling is handled by the browser page — not CodeMirror's own scroller. Character-position comparison is therefore useless for off-screen detection.
+- **Solution**: Three-mode classification strategy in `plugin.ts`:
+  1. **rangedMode** (`visibleRanges < viewport`): internal-scroll editor (jsdom tests, fixed-height editors) — use character-position boundaries from `visibleRanges`
+  2. **coords mode** (`visibleRanges == viewport`, `scrollDOM.height > 0`): page-scroll editor (GROWI production) — use `view.lineBlockAt(pos)` + `scrollDOM.getBoundingClientRect()` to compute screen Y coordinates
+  3. **degenerate** (`scrollDOM.height == 0`): jsdom with 0-height container — skip classification, all cursors get widget decorations
+- **Constraint**: `view.coordsAtPos()` calls `readMeasured()` internally, which throws "Reading the editor layout isn't allowed during an update". Must use `view.lineBlockAt()` (reads stored height map, safe during update) + raw `getBoundingClientRect()` (not CodeMirror-restricted) instead.
+
+### Material Symbols Font Loading
+
+- **Context**: Off-screen indicator arrow (`arrow_drop_up`/`arrow_drop_down`) rendered as literal text instead of icon.
+- **Finding**: GROWI loads Material Symbols Outlined via Next.js `next/font` in `use-material-symbols-outlined.tsx`. Next.js registers the font with a **hashed family name** (e.g., `__MaterialSymbolsOutlined_xxxxx`), stored in the CSS variable `--grw-font-family-material-symbols-outlined`. Hardcoding `font-family: 'Material Symbols Outlined'` in CodeMirror's `baseTheme` causes a mismatch — the browser cannot find the font.
+- **Solution**: Use `fontFamily: 'var(--grw-font-family-material-symbols-outlined)'` in `theme.ts` so the hashed name is resolved at runtime.
+
+### Parent Container `overflow-y: hidden` Limitation
+
+- **Context**: Off-screen indicator arrow tip was clipped when positioned a few pixels beyond the editor border.
+- **Finding**: `.page-editor-editor-container` inherits `overflow-y: hidden` from `.flex-expand-vert` within the `.flex-expand-vh-100` context (`packages/core-styles/scss/helpers/_flex-expand.scss` + `apps/app/src/styles/scss/layout/_editor.scss`). This clips any content extending beyond `.cm-editor`'s border box. `.cm-editor` itself has no overflow restriction.
+- **Implication**: Off-screen indicators must stay within `.cm-editor`'s border box. Arrow icons use `clip-path` and negative margins to visually align with the border without extending past it.
+
+### Horizontal Positioning via `requestMeasure`
+
+- **Context**: Off-screen indicators should reflect the remote cursor's column position horizontally.
+- **Finding**: `view.coordsAtPos()` cannot be called during `update()` (throws "Reading the editor layout" error). Horizontal positioning must be deferred.
+- **Solution**: After `replaceChildren()`, call `view.requestMeasure()` to schedule a read phase (`coordsAtPos` → screen X) and write phase (`style.left` + `transform: translateX(-50%)`). For virtualized positions (outside viewport), fall back to `contentDOM.getBoundingClientRect().left + col * view.defaultCharacterWidth`.
+
+### Phase 2 — Color-Matched Avatars & Click-to-Scroll
+
+#### UserPicture Style API Analysis
+
+- **Context**: Requirement 5.1 requires setting the border color of `UserPicture` avatars dynamically per user.
+- **Findings**: `UserPicture.tsx` in `packages/ui/src/components/UserPicture.tsx` accepts only `{ user, size, noLink, noTooltip, className }`. The `className` is applied to the `<img>` element (not the root `<span>`). There is no `style` prop forwarded to either element.
+- **Implications**: Cannot set `borderColor` via `UserPicture`'s own props. Must wrap in a parent element with an inline `border` style. The `border border-info` className on `UserPicture` is removed; the wrapper element provides the colored border.
+
+#### Cross-Package Callback Pattern
+
+- **Context**: `use-collaborative-editor-mode` (in `packages/editor`) needs to provide a scroll function to `EditingUserList` (in `apps/app`). Direct import from `apps/app` → `packages/editor` is the existing direction; reverse import is prohibited.
+- **Findings**: The existing `onEditorsUpdated` callback in `Configuration` follows exactly this pattern: `packages/editor` calls a callback provided by `apps/app`. The same pattern is appropriate for `onScrollToRemoteCursorReady`.
+- **Implications**: No new dependency or architectural mechanism needed; extend `Configuration` type with the new callback.
+
+#### CodeMirror Scroll API
+
+- **Context**: How to programmatically scroll the editor to a specific character position.
+- **Findings**: `EditorView.scrollIntoView(pos: number, options?: { y?: 'nearest' | 'start' | 'end' | 'center' })` is the standard CodeMirror API. Dispatching `{ effects: EditorView.scrollIntoView(pos, { y: 'center' }) }` scrolls the editor so the position is vertically centered. No additional plugins or dependencies required.
+- **Implications**: Scroll is a one-liner dispatch; no new package dependencies. The position is resolved from `Y.createAbsolutePositionFromRelativePosition(cursor.head, ydoc)` which is already used in `plugin.ts`.
+
+#### Jotai Function Setter Pitfall
+
+- **Context**: `scrollToRemoteCursorAtom` stores a `(clientId: number) => void` function. `useSetAtom` returns a setter that is passed as the `onScrollToRemoteCursorReady` callback.
+- **Finding**: Jotai's atom setter interprets any **function argument** as an **updater function**: `setAtom(fn)` is treated as `setAtom(prev => fn(prev))`, not `setAtom(fn_as_value)`. When `onScrollToRemoteCursorReady(scrollFn)` was called, Jotai invoked `scrollFn(null)` (current atom value) as if it were an updater, then stored `scrollFn`'s return value (`undefined`) in the atom — the scroll function was never stored.
+- **Symptom**: `[scrollToRemoteCursor] called with clientId: null` appeared in logs immediately after "scroll function registered", and the atom value flipped to `undefined`.
+- **Solution**: Wrap the function value in `useSetScrollToRemoteCursor`:
+  ```typescript
+  setAtom(() => fn);  // updater that returns the function value
+  ```
+  This pattern must be applied to any Jotai atom that stores a function value.
+- **Implication**: When designing Jotai atoms that store callbacks or any function-typed value, the setter must always use the `() => value` wrapper form. Document this in code review checklists for Jotai usage.
+
+#### AvatarWrapper Styling — UserPicture Tooltip Fragment Issue
+
+- **Context**: Wrapping `UserPicture` in a `<button>` for click handling caused visual misalignment and layout instability.
+- **Finding**: When `noTooltip` is not set, `UserPicture` uses a `withTooltip` HOC that returns a React **Fragment** (`<span><img/></span> + <UncontrolledTooltip/>`). As flex children of the `<button>`, the Fragment's two children introduced unpredictable layout. Additionally, the `<span>` as an inline element contributed ghost space from `line-height`, making the circular border appear offset.
+- **Solution**:
+  - Pass `noTooltip` to `UserPicture` to get a predictable single-child render (`<span><img/></span>`)
+  - Use Bootstrap utilities for layout: `d-inline-flex align-items-center justify-content-center p-0 bg-transparent rounded-circle`
+  - Add `line-height: 0` to `.avatar-wrapper` in the CSS module to eliminate inline ghost space
+  - Keep only the dynamic border color as inline style: `border: 2px solid ${color}`
+
+#### Smooth Scroll via scrollDOM Style
+
+- **Context**: Click-to-scroll should animate smoothly rather than jump instantly.
+- **Finding**: `EditorView.scrollIntoView` dispatches a CodeMirror state effect that CodeMirror resolves by scrolling `view.scrollDOM`. Setting `view.scrollDOM.style.scrollBehavior = 'smooth'` before the dispatch causes the browser to animate the scroll. Restoring the value after ~500 ms (typical animation window) avoids affecting other programmatic scrolls.
+- **Constraint**: This approach works when `view.scrollDOM` is the actual scrolling element. In GROWI's page-scroll setup, the effective scrolling element may be a parent container; if smooth scrolling does not animate as expected, the `scrollBehavior` may need to be set on the parent scroll container instead.
+
+### Phase 3 — Off-Screen Indicator Click & Username Tooltip
+
+#### scrollCallbackRef Pattern — Why Not Pass scrollFn Directly to yRichCursors
+
+- **Context**: Req 6.6 requires off-screen indicators to invoke the same `scrollFn` used by `EditingUserList`. The natural approach would be `yRichCursors(awareness, { onClickIndicator: scrollFn })`, but this fails because `yRichCursors` and `scrollFn` are created in two separate `useEffect` calls with slightly different dependency sets.
+- **Finding**: If `scrollFn` is passed as a plain value, every time the scroll function is recreated (on provider/activeDoc/codeMirrorEditor change), the extension array must also be recreated — causing a full CodeMirror extension reload. This is expensive and unnecessary.
+- **Solution**: Pass a mutable ref `scrollCallbackRef = useRef(null)` to `yRichCursors`. The plugin captures the ref object (stable reference across re-renders). The scroll-function registration effect updates `.current` silently without touching the extension.
+- **Implication**: This is the standard React pattern for exposing a stable callback to an imperative API. The `ScrollCallbackRef` type (`{ current: Fn | null }`) is defined in `packages/editor` without importing React, making it usable in the non-React CodeMirror extension context.
+
+#### UserPicture Tooltip — withTooltip HOC Elimination (Design Review Outcome)
+
+- **Context**: Req 7 requires username tooltips in `EditingUserList`. The `UserPicture` component's `withTooltip` HOC returns a React Fragment (`<span><img/></span> + <UncontrolledTooltip/>`), which caused layout instability when used inside a flex `<button>` (Phase 2 finding). The initial approach (Phase 2) was to use `noTooltip` + external `UncontrolledTooltip` at the wrapper level, but design review identified this as a workaround that would need to be repeated by every consumer facing the same Fragment/flex issue.
+- **Root cause analysis**: The `withTooltip` HOC returns a Fragment because `UncontrolledTooltip` is placed as a **sibling** of the wrapped component. While `UncontrolledTooltip` uses `ReactDOM.createPortal` (tooltip content renders to `document.body`), the Fragment still produces two React children at the parent level, which can destabilize flex layout.
+- **Key insight**: Since `UncontrolledTooltip` is a portal, it can be placed as a **child** of the root `<span>` instead of a sibling. As a portal child, it occupies no DOM space in the parent — only the `<img>` is a visible child. The root element becomes a single `<span>` with predictable layout behavior in any container type.
+- **Solution**: Eliminate the `withTooltip` HOC. Move tooltip rendering inline into `UserPicture`'s render function:
+  1. Create `rootRef = useRef<HTMLSpanElement>(null)` unconditionally (hooks rules compliant)
+  2. Pass `rootRef` to `UserPictureRootWithoutLink`/`UserPictureRootWithLink` via `forwardRef` (they already support it)
+  3. Conditionally render `UncontrolledTooltip` as a child of the root element alongside `imgElement`
+  4. Delete the `withTooltip` HOC function
+- **Impact verification**: `withTooltip` is not exported — it's only used internally in `UserPicture.tsx`. The public API (`Props`: `user, size, noLink, noTooltip, className`) is unchanged. All existing consumers (30+ usages across `apps/app`) are unaffected.
+- **`noTooltip` usages** (16 call sites): Consumers that pass `noTooltip` (sidebar dropdowns, inline notifications, comment editors, conflict modals) continue to suppress tooltips. `EditingUserList` is the only consumer that **removes** `noTooltip` to gain the tooltip.
+- **Implication**: `EditingUserList` no longer needs external tooltip code (`UncontrolledTooltip`, `id` generation, `clientId`-based targeting). The `AvatarWrapper` sub-component is simplified to just a `<button>` wrapping `<UserPicture>` with color border.
+
+## References
+
+- y-codemirror.next v0.3.5 source: `node_modules/.pnpm/y-codemirror.next@0.3.5_.../src/`
+- Yjs awareness protocol: https://docs.yjs.dev/api/about-awareness
+- CodeMirror WidgetType: https://codemirror.net/docs/ref/#view.WidgetType
+- CodeMirror EditorView.lineBlockAt: https://codemirror.net/docs/ref/#view.EditorView.lineBlockAt
+- CodeMirror EditorView.scrollIntoView: https://codemirror.net/docs/ref/#view.EditorView^scrollIntoView

+ 23 - 0
.kiro/specs/collaborative-editor-awareness/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "collaborative-editor-awareness",
+  "created_at": "2026-04-07T00:00:00.000Z",
+  "updated_at": "2026-04-16T09:00:00.000Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true,
+  "cleanup_completed": false
+}

+ 144 - 0
.kiro/specs/collaborative-editor-awareness/tasks.md

@@ -0,0 +1,144 @@
+# Implementation Plan
+
+- [x] 1. Stabilize the Editing User List
+- [x] 1.1 Fix awareness state filter (undefined → skip) — _Req 1.1, 1.4_
+- [x] 1.2 Remove direct mutation of Yjs-managed awareness map — _Req 1.2_
+
+- [x] 2. Build the Rich Cursor Extension (Initial)
+- [x] 2.1 (P) Cursor widget DOM: name label, avatar image, initials fallback — _Req 3.4, 3.5_
+- [x] 2.2 (P) Broadcast local cursor position to awareness — _Req 3.6, 3.7_
+- [x] 2.3 (P) Render remote cursor decorations from awareness — _Req 3.6, 3.7_
+
+- [x] 3. Integrate Rich Cursor Extension into Editor Configuration — _Req 1.3, 2.4, 3.6_
+
+- [x] 4. Unit Tests for Core Behaviors (Initial)
+- [x] 4.1 (P) Awareness state filtering and mutation-free disconnect — _Req 1.1, 1.2, 1.4_
+- [x] 4.2 (P) Cursor widget construction, equality, avatar fallback — _Req 3.4, 3.5_
+
+- [x] 5. Integration Tests for Multi-Client Collaborative Scenarios
+- [x] 5.1 Awareness update flow to EditingUserList — _Req 1.3, 2.1, 2.4_
+- [x] 5.2 Cursor position broadcasting and remote rendering — _Req 3.6, 3.7_
+
+- [x] 6. Add baseTheme with Overlay Positioning, Hover, and Opacity Rules
+- [x] 6.1 (P) Cursor overlay CSS rules — _Req 3.1, 3.2, 3.3, 3.8, 3.9_
+- [x] 6.2 (P) Off-screen container and indicator styles — _Req 4.5, 4.7_
+
+- [x] 7. Rework RichCaretWidget for Overlay Avatar with Activity State
+- [x] 7.1 Widget DOM: overlay flag, avatar/initials, hover name label, isActive — _Req 3.1–3.5, 3.10_
+- [x] 7.2 Activity tracking with per-client timers (3s inactivity) — _Req 3.10_
+
+- [x] 8. Build Off-Screen Cursor Indicators
+- [x] 8.1 Persistent off-screen containers on editor DOM — _Req 4.7_
+- [x] 8.2 Classify cursors by visible range, render indicators — _Req 4.1–4.6_
+
+- [x] 9. Unit Tests for Updated Widget and Off-Screen Indicators
+- [x] 9.1 (P) Widget DOM structure, sizing, isActive, borderColor — _Req 3.1–3.5, 3.10_
+- [x] 9.2 (P) Off-screen indicator DOM, Material Symbols arrow, avatar fallback — _Req 4.1, 4.2, 4.4, 4.9_
+
+- [x] 10. Integration Tests for Viewport Classification and Activity Tracking
+- [x] 10.1 Off-screen exclusion from widget decorations — _Req 4.3, 4.6_
+- [x] 10.2 Activity tracking timer lifecycle (fake timers) — _Req 3.10_
+
+- [x] 12. Fix Off-Screen Visibility Classification
+- [x] 12.1 Multi-mode classification: rangedMode / coordsMode / degenerate — _Req 4.1–4.3, 4.6_
+- [x] 12.2 Integration test for render-buffer cursor → off-screen indicator — _Req 4.3, 4.6_
+
+- [ ]\* 11. E2E Tests for Hover, Opacity, and Off-Screen Transitions (deferred)
+- [ ]\* 11.1 (P) Hover behavior on cursor overlay flag — _Req 3.3, 3.9_
+- [ ]\* 11.2 (P) Off-screen indicator visibility on scroll — _Req 4.1–4.3, 4.6_
+
+---
+
+## Phase 2: Color-Matched Avatars & Click-to-Scroll (Requirements 5–6)
+
+- [x] 13. Scroll callback infrastructure
+- [x] 13.1 (P) Create a Jotai atom for storing the scroll-to-remote-cursor callback
+  - Define an atom that holds either a scroll function accepting a client ID or null
+  - Export a reader hook and a setter hook, following the same pattern as the existing editing-clients atom
+  - _Requirements: 6.1_
+
+- [x] 13.2 (P) Extend the collaborative editor mode hook to create and register a scroll-to-remote-cursor function
+  - Add a new callback option to the hook's configuration that receives the scroll function when the provider and document are ready, and null on cleanup
+  - The scroll function reads the target user's cursor position from awareness, resolves the Yjs relative position to an absolute document index, and dispatches a vertically centered scroll command to the CodeMirror editor view
+  - Guard against missing cursor data and unmounted editor view by returning silently
+  - _Requirements: 6.1, 6.2, 6.3_
+
+- [x] 14. (P) Update EditingUserList with color-matched borders and click-to-scroll
+  - Replace the fixed blue border on each avatar with a wrapper element whose border color matches the user's cursor color from the awareness state
+  - Accept a new click callback prop and wrap each avatar in a clickable element with pointer cursor styling
+  - Replace the generic UserPictureList component in the overflow popover with inline rendering so that color-matched borders and click handling apply to all avatars consistently
+  - _Requirements: 5.1, 5.2, 5.3, 6.4, 6.5_
+
+- [x] 15. Connect all components end-to-end
+  - Bridge the scroll-ready callback through the main editor component's props into the collaborative editor mode hook
+  - Wire the page editor to store the received scroll callback in the Jotai atom
+  - Wire the editor navbar to read the atom and pass the scroll function to the editing user list as the click callback
+  - Verify that clicking an avatar scrolls the editor to that user's remote cursor position; verify no-op for users without a cursor
+  - _Requirements: 6.1, 6.5_
+
+- [x]\* 16. Test coverage for color-matched borders and click-to-scroll
+- [x]\* 16.1 (P) Unit tests for EditingUserList rendering and click behavior
+  - Verify that each avatar renders a colored border matching the user's cursor color instead of the fixed blue
+  - Verify that clicking an avatar invokes the callback with the correct client ID
+  - Verify that overflow popover avatars also invoke the callback on click
+  - _Requirements: 5.1, 6.4, 6.5_
+
+- [x]\* 16.2 (P) Integration test for the scroll function in the collaborative editor mode hook
+  - Verify that the configuration callback receives a function when the provider is set up
+  - Verify that calling the scroll function with a valid remote client ID dispatches a centered scroll effect to the editor view
+  - Verify that calling the scroll function for a client without a cursor position is a silent no-op
+  - _Requirements: 6.1, 6.2, 6.3_
+
+---
+
+## Phase 3: Off-Screen Indicator Click & Username Tooltip (Requirements 6.6–6.7, 7)
+
+- [x] 17. Add click-to-scroll to off-screen cursor indicators
+- [x] 17.1 Extend the off-screen indicator to accept and fire a click callback
+  - Add a user identifier and an optional click callback to the indicator creation options
+  - Attach a click event listener on the indicator's root element that invokes the callback with the user identifier
+  - Apply pointer cursor styling when a click handler is provided; omit it when not
+  - _Requirements: 6.6, 6.7_
+
+- [x] 17.2 Wire the scroll function to off-screen indicators via a mutable ref in the editor mode hook
+  - Accept a mutable ref option in the rich cursor extension factory for the indicator click callback
+  - When building off-screen indicators, pass the ref's current value as the click handler for each indicator
+  - In the collaborative editor mode hook, create the mutable ref alongside the extension, write the existing scroll function to it when the provider is ready, and clear it on cleanup
+  - _Requirements: 6.6_
+
+- [x] 18. (P) Refactor the UserPicture component to eliminate the tooltip higher-order component
+  - Remove the higher-order component that wraps the root element and tooltip in a React Fragment
+  - Render the tooltip directly within the component's render body as a child of the root element, conditionally based on the noTooltip flag
+  - Preserve the dynamic import for the tooltip component so that consumers who suppress tooltips never trigger the tooltip chunk load
+  - Move the ref for tooltip targeting into the component body (unconditional call) and pass it to the root element via its existing forwardRef support
+  - The root element is always a single span, making it safe inside flex containers
+  - _Requirements: 7.1, 7.3, 7.4, 7.5_
+
+- [x] 19. Enable the native UserPicture tooltip in EditingUserList
+  - Remove the tooltip-suppression flag from UserPicture in the avatar wrapper so the built-in tooltip renders automatically for all avatars
+  - Both the first-four direct avatars and the overflow popover avatars use the same wrapper, so tooltips appear uniformly
+  - _Requirements: 7.2_
+  - _Depends on: Task 18_
+
+- [x]\* 20. Test coverage for off-screen click and tooltip refactoring
+- [x]\* 20.1 (P) Unit tests for off-screen indicator click behavior
+  - Verify the indicator root has pointer cursor when a click handler is provided
+  - Verify clicking the indicator calls the callback with the correct user identifier
+  - Verify no click handler or pointer cursor when the callback is omitted
+  - _Requirements: 6.6, 6.7_
+
+- [x]\* 20.2 (P) Integration test for off-screen indicator scroll wiring
+  - Verify that when the mutable ref holds a function and an off-screen indicator is clicked, the function is called with the correct user identifier
+  - Verify that clicking when the ref is null does not throw
+  - _Requirements: 6.6_
+
+- [x]\* 20.3 (P) Unit tests for UserPicture tooltip refactoring
+  - Verify that without the tooltip-suppression flag, the component renders a single root element containing the image and a portal tooltip
+  - Verify that with the tooltip-suppression flag, only the image is rendered inside the root element
+  - Verify tooltip content includes the username prefix and display name
+  - _Requirements: 7.1, 7.3, 7.4_
+
+- [x]\* 20.4 Unit tests for EditingUserList tooltip integration
+  - Verify the avatar wrapper renders UserPicture without the tooltip-suppression flag
+  - Verify tooltips are present for both direct avatars and overflow popover avatars
+  - _Requirements: 7.2_

+ 3 - 1
.kiro/specs/collaborative-editor/design.md

@@ -77,7 +77,7 @@ graph TB
 |-------|------------------|------|
 |-------|------------------|------|
 | Client Provider | `y-websocket@^2.x` (WebsocketProvider) | Yjs document sync over WebSocket |
 | Client Provider | `y-websocket@^2.x` (WebsocketProvider) | Yjs document sync over WebSocket |
 | Server WebSocket | `ws@^8.x` (WebSocket.Server) | Native WebSocket server, `noServer: true` mode |
 | Server WebSocket | `ws@^8.x` (WebSocket.Server) | Native WebSocket server, `noServer: true` mode |
-| Server Yjs Utils | `y-websocket@^2.x` (`bin/utils`) | `setupWSConnection`, `getYDoc`, `WSSharedDoc` |
+| Server Yjs Utils | `y-websocket@^2.x` (`bin/utils`) | `setupWSConnection`, `getYDoc`, `WSSharedDoc`. Server-side type declarations (`y-websocket-server.d.ts`) derive the `Awareness` type via `WebsocketProvider['awareness']` instead of importing from `y-protocols/awareness`, because `y-protocols` is not a direct dependency. |
 | Persistence | `y-mongodb-provider` (extended) | Yjs document persistence to `yjs-writings` collection |
 | Persistence | `y-mongodb-provider` (extended) | Yjs document persistence to `yjs-writings` collection |
 | Event Bridge | Socket.IO `io` instance | Awareness state broadcasting to page rooms |
 | Event Bridge | Socket.IO `io` instance | Awareness state broadcasting to page rooms |
 | Auth | express-session + passport | WebSocket upgrade authentication via cookie |
 | Auth | express-session + passport | WebSocket upgrade authentication via cookie |
@@ -239,6 +239,8 @@ interface YWebsocketPersistence {
 - Awareness API: `provider.awareness.setLocalStateField`, `.on('update', ...)`
 - Awareness API: `provider.awareness.setLocalStateField`, `.on('update', ...)`
 - All side effects (provider creation, awareness setup) must be outside React state updaters to avoid render-phase violations
 - All side effects (provider creation, awareness setup) must be outside React state updaters to avoid render-phase violations
 
 
+> **Note**: Client-side awareness display (EditingUserList stability, rich cursor rendering) is designed in the [`collaborative-editor-awareness`](../collaborative-editor-awareness/) spec.
+
 ## Data Models
 ## Data Models
 
 
 No custom data models. Uses the existing `yjs-writings` MongoDB collection via `MongodbPersistence` (extended `y-mongodb-provider`). Collection schema, indexes, and persistence interface (`bindState` / `writeState`) are unchanged.
 No custom data models. Uses the existing `yjs-writings` MongoDB collection via `MongodbPersistence` (extended `y-mongodb-provider`). Collection schema, indexes, and persistence interface (`bindState` / `writeState`) are unchanged.

+ 4 - 2
.kiro/specs/collaborative-editor/requirements.md

@@ -58,14 +58,16 @@ GROWI provides real-time collaborative editing powered by Yjs, allowing multiple
 
 
 ### Requirement 5: Awareness and Presence Tracking
 ### Requirement 5: Awareness and Presence Tracking
 
 
-**Objective:** As a wiki user, I want to see which other users are currently editing the same page, so that I can coordinate edits and avoid conflicts.
+> **Note**: Client-side awareness display behavior (EditingUserList stability, rich cursor rendering) is specified in the [`collaborative-editor-awareness`](../collaborative-editor-awareness/requirements.md) spec. This requirement covers only the server-side awareness event bridging responsibility of the Yjs service.
+
+**Objective:** As a system component, I want awareness state changes to be broadcast to page-level Socket.IO rooms, so that non-editor UI components can reflect collaborative editing activity.
 
 
 #### Acceptance Criteria
 #### Acceptance Criteria
 
 
 1. While a user is editing a page, the Editor Client shall broadcast the user's presence information (name, username, avatar, cursor color) via the Yjs awareness protocol.
 1. While a user is editing a page, the Editor Client shall broadcast the user's presence information (name, username, avatar, cursor color) via the Yjs awareness protocol.
 2. When a user connects or disconnects from a collaborative editing session, the Yjs Service shall emit awareness state size updates to the page's Socket.IO room (`page:{pageId}`).
 2. When a user connects or disconnects from a collaborative editing session, the Yjs Service shall emit awareness state size updates to the page's Socket.IO room (`page:{pageId}`).
 3. When the last user disconnects from a document, the Yjs Service shall emit a draft status notification (`YjsHasYdocsNewerThanLatestRevisionUpdated`) to the page's Socket.IO room.
 3. When the last user disconnects from a document, the Yjs Service shall emit a draft status notification (`YjsHasYdocsNewerThanLatestRevisionUpdated`) to the page's Socket.IO room.
-4. The Editor Client shall display the list of active editors based on awareness state updates from the Yjs provider.
+4. The Editor Client shall display the list of active editors based on awareness state updates from the Yjs provider. See `collaborative-editor-awareness` spec for display-level requirements.
 
 
 ### Requirement 6: YDoc Status and Sync Integration
 ### Requirement 6: YDoc Status and Sync Integration
 
 

+ 5 - 0
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.module.scss

@@ -3,3 +3,8 @@
 .user-list-popover {
 .user-list-popover {
   @extend %user-list-popover;
   @extend %user-list-popover;
 }
 }
+
+.avatar-wrapper {
+  // Collapse inline-element ghost space inside the flex container
+  line-height: 0;
+}

+ 259 - 0
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.spec.tsx

@@ -0,0 +1,259 @@
+import type { EditingClient } from '@growi/editor';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+/**
+ * Unit tests for EditingUserList component.
+ *
+ * Covers:
+ * - Task 14: Color-matched avatar borders and click-to-scroll
+ * - Task 16.1: Unit tests for EditingUserList rendering and click behavior
+ * - Requirements: 5.1, 6.4, 6.5
+ */
+
+// ---------------------------------------------------------------------------
+// Module mocks
+// ---------------------------------------------------------------------------
+
+vi.mock('@growi/ui/dist/components', () => ({
+  UserPicture: ({
+    user,
+    className,
+    noTooltip,
+  }: {
+    user: EditingClient;
+    className?: string;
+    noTooltip?: boolean;
+  }) => (
+    <span
+      data-testid={`user-picture-${user.clientId}`}
+      data-no-tooltip={noTooltip ? 'true' : undefined}
+      className={className}
+    >
+      {user.name}
+    </span>
+  ),
+}));
+
+vi.mock('../../Common/UserPictureList', () => ({
+  default: ({ users }: { users: EditingClient[] }) => (
+    <div data-testid="user-picture-list">
+      {users.map((u) => (
+        <span key={u.clientId} data-testid={`overflow-user-${u.clientId}`}>
+          {u.name}
+        </span>
+      ))}
+    </div>
+  ),
+}));
+
+vi.mock('reactstrap', () => ({
+  Popover: ({
+    children,
+    isOpen,
+  }: {
+    children: React.ReactNode;
+    isOpen: boolean;
+  }) => (isOpen ? <div data-testid="popover">{children}</div> : null),
+  PopoverBody: ({ children }: { children: React.ReactNode }) => (
+    <div data-testid="popover-body">{children}</div>
+  ),
+}));
+
+vi.mock('./EditingUserList.module.scss', () => ({
+  default: { 'user-list-popover': 'user-list-popover' },
+}));
+
+// ---------------------------------------------------------------------------
+// Test data
+// ---------------------------------------------------------------------------
+
+const makeClient = (
+  id: number,
+  name: string,
+  color: string,
+): EditingClient => ({
+  clientId: id,
+  name,
+  color,
+  colorLight: `${color}33`,
+});
+
+const clientAlice = makeClient(1, 'Alice', '#ff0000');
+const clientBob = makeClient(2, 'Bob', '#00ff00');
+const clientCarol = makeClient(3, 'Carol', '#0000ff');
+const clientDave = makeClient(4, 'Dave', '#ffff00');
+const clientEve = makeClient(5, 'Eve', '#ff00ff');
+
+import { EditingUserList } from './EditingUserList';
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('EditingUserList — Task 16.1', () => {
+  describe('Req 5.1 — color-matched avatar borders', () => {
+    it("renders a wrapper with border color matching the user's cursor color", () => {
+      render(<EditingUserList clientList={[clientAlice]} />);
+
+      const wrapper = screen.getByTestId('avatar-wrapper-1');
+      expect(wrapper).toHaveStyle({ borderColor: clientAlice.color });
+    });
+
+    it('does NOT use the fixed border-info CSS class', () => {
+      render(<EditingUserList clientList={[clientAlice]} />);
+
+      const wrapper = screen.getByTestId('avatar-wrapper-1');
+      expect(wrapper).not.toHaveClass('border-info');
+    });
+
+    it('applies color-matched borders to all first-4 avatars', () => {
+      render(
+        <EditingUserList
+          clientList={[clientAlice, clientBob, clientCarol, clientDave]}
+        />,
+      );
+
+      for (const client of [clientAlice, clientBob, clientCarol, clientDave]) {
+        const wrapper = screen.getByTestId(`avatar-wrapper-${client.clientId}`);
+        expect(wrapper).toHaveStyle({ borderColor: client.color });
+      }
+    });
+  });
+
+  describe('Req 6.4 — cursor: pointer affordance', () => {
+    it('avatar wrapper is a button element (provides pointer affordance)', () => {
+      render(<EditingUserList clientList={[clientAlice]} />);
+
+      const wrapper = screen.getByTestId('avatar-wrapper-1');
+      expect(wrapper.tagName).toBe('BUTTON');
+    });
+  });
+
+  describe('Req 6.5 / Task 16.1 — clicking an avatar invokes callback with correct clientId', () => {
+    it("calls onUserClick with the client's clientId when clicked", async () => {
+      const onUserClick = vi.fn();
+      render(
+        <EditingUserList
+          clientList={[clientAlice, clientBob]}
+          onUserClick={onUserClick}
+        />,
+      );
+
+      await userEvent.click(screen.getByTestId('avatar-wrapper-1'));
+      expect(onUserClick).toHaveBeenCalledWith(clientAlice.clientId);
+
+      await userEvent.click(screen.getByTestId('avatar-wrapper-2'));
+      expect(onUserClick).toHaveBeenCalledWith(clientBob.clientId);
+    });
+
+    it('does not throw when onUserClick is not provided', async () => {
+      render(<EditingUserList clientList={[clientAlice]} />);
+      await userEvent.click(screen.getByTestId('avatar-wrapper-1'));
+      // No error expected
+    });
+  });
+
+  describe('Req 6.5 — overflow popover avatars support click-to-scroll', () => {
+    it('renders color-matched wrappers for overflow avatars in the popover', async () => {
+      const clients = [
+        clientAlice,
+        clientBob,
+        clientCarol,
+        clientDave,
+        clientEve,
+      ];
+      render(<EditingUserList clientList={clients} onUserClick={vi.fn()} />);
+
+      // Open the popover by clicking the +1 button
+      const btn = screen.getByRole('button', { name: /^\+1$/ });
+      await userEvent.click(btn);
+
+      // Eve is the 5th user, rendered in overflow
+      const eveWrapper = screen.queryByTestId('avatar-wrapper-5');
+      if (eveWrapper != null) {
+        expect(eveWrapper).toHaveStyle({ borderColor: clientEve.color });
+      }
+    });
+
+    it('calls onUserClick when an overflow avatar is clicked', async () => {
+      const onUserClick = vi.fn();
+      const clients = [
+        clientAlice,
+        clientBob,
+        clientCarol,
+        clientDave,
+        clientEve,
+      ];
+      render(
+        <EditingUserList clientList={clients} onUserClick={onUserClick} />,
+      );
+
+      // Open the popover
+      await userEvent.click(screen.getByRole('button', { name: /^\+1$/ }));
+
+      // Click Eve's avatar in the overflow
+      const eveWrapper = screen.queryByTestId('avatar-wrapper-5');
+      if (eveWrapper != null) {
+        await userEvent.click(eveWrapper);
+        expect(onUserClick).toHaveBeenCalledWith(clientEve.clientId);
+      }
+    });
+  });
+
+  describe('Empty list', () => {
+    it('renders nothing when clientList is empty', () => {
+      const { container } = render(<EditingUserList clientList={[]} />);
+      expect(container.firstChild).toBeNull();
+    });
+  });
+});
+
+/**
+ * Task 20.4 — EditingUserList tooltip integration
+ * Requirements: 7.2
+ */
+describe('EditingUserList — Task 20.4 (tooltip integration)', () => {
+  describe('Req 7.2 — UserPicture rendered without noTooltip so tooltip is active', () => {
+    it('does not pass noTooltip to UserPicture for direct avatars', () => {
+      render(<EditingUserList clientList={[clientAlice]} />);
+
+      const pic = screen.getByTestId('user-picture-1');
+      // data-no-tooltip attribute is only set when noTooltip=true; should be absent
+      expect(pic.getAttribute('data-no-tooltip')).toBeNull();
+    });
+
+    it('does not pass noTooltip to UserPicture for all first-4 direct avatars', () => {
+      render(
+        <EditingUserList
+          clientList={[clientAlice, clientBob, clientCarol, clientDave]}
+        />,
+      );
+
+      for (const client of [clientAlice, clientBob, clientCarol, clientDave]) {
+        const pic = screen.getByTestId(`user-picture-${client.clientId}`);
+        expect(pic.getAttribute('data-no-tooltip')).toBeNull();
+      }
+    });
+
+    it('does not pass noTooltip to UserPicture for overflow popover avatars', async () => {
+      render(
+        <EditingUserList
+          clientList={[
+            clientAlice,
+            clientBob,
+            clientCarol,
+            clientDave,
+            clientEve,
+          ]}
+        />,
+      );
+
+      // Open the overflow popover
+      await userEvent.click(screen.getByRole('button', { name: /^\+1$/ }));
+
+      const evePic = screen.getByTestId('user-picture-5');
+      expect(evePic.getAttribute('data-no-tooltip')).toBeNull();
+    });
+  });
+});

+ 36 - 13
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx

@@ -1,19 +1,37 @@
-import { type FC, useState } from 'react';
+import { type FC, useId, useState } from 'react';
 import type { EditingClient } from '@growi/editor';
 import type { EditingClient } from '@growi/editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { Popover, PopoverBody } from 'reactstrap';
 import { Popover, PopoverBody } from 'reactstrap';
 
 
-import UserPictureList from '../../Common/UserPictureList';
-
 import styles from './EditingUserList.module.scss';
 import styles from './EditingUserList.module.scss';
 
 
 const userListPopoverClass = styles['user-list-popover'] ?? '';
 const userListPopoverClass = styles['user-list-popover'] ?? '';
+const avatarWrapperClass = styles['avatar-wrapper'] ?? '';
 
 
 type Props = {
 type Props = {
   clientList: EditingClient[];
   clientList: EditingClient[];
+  onUserClick?: (clientId: number) => void;
+};
+
+const AvatarWrapper: FC<{
+  client: EditingClient;
+  onUserClick?: (clientId: number) => void;
+}> = ({ client, onUserClick }) => {
+  return (
+    <button
+      type="button"
+      data-testid={`avatar-wrapper-${client.clientId}`}
+      className={`${avatarWrapperClass} d-inline-flex align-items-center justify-content-center p-0 bg-transparent rounded-circle`}
+      style={{ border: `2px solid ${client.color}` }}
+      onClick={() => onUserClick?.(client.clientId)}
+    >
+      <UserPicture user={client} noLink />
+    </button>
+  );
 };
 };
 
 
-export const EditingUserList: FC<Props> = ({ clientList }) => {
+export const EditingUserList: FC<Props> = ({ clientList, onUserClick }) => {
+  const popoverTargetId = useId();
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
   const [isPopoverOpen, setIsPopoverOpen] = useState(false);
 
 
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
   const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
@@ -22,7 +40,7 @@ export const EditingUserList: FC<Props> = ({ clientList }) => {
   const remainingUsers = clientList.slice(4);
   const remainingUsers = clientList.slice(4);
 
 
   if (clientList.length === 0) {
   if (clientList.length === 0) {
-    return <></>;
+    return null;
   }
   }
 
 
   return (
   return (
@@ -30,11 +48,7 @@ export const EditingUserList: FC<Props> = ({ clientList }) => {
       <div className="d-flex justify-content-start justify-content-sm-end">
       <div className="d-flex justify-content-start justify-content-sm-end">
         {firstFourUsers.map((editingClient) => (
         {firstFourUsers.map((editingClient) => (
           <div key={editingClient.clientId} className="ms-1">
           <div key={editingClient.clientId} className="ms-1">
-            <UserPicture
-              user={editingClient}
-              noLink
-              className="border border-info"
-            />
+            <AvatarWrapper client={editingClient} onUserClick={onUserClick} />
           </div>
           </div>
         ))}
         ))}
 
 
@@ -42,8 +56,9 @@ export const EditingUserList: FC<Props> = ({ clientList }) => {
           <div className="ms-1">
           <div className="ms-1">
             <button
             <button
               type="button"
               type="button"
-              id="btn-editing-user"
+              id={popoverTargetId}
               className="btn border-0 bg-info-subtle rounded-pill p-0"
               className="btn border-0 bg-info-subtle rounded-pill p-0"
+              onClick={togglePopover}
             >
             >
               <span className="fw-bold text-info p-1">
               <span className="fw-bold text-info p-1">
                 +{remainingUsers.length}
                 +{remainingUsers.length}
@@ -52,12 +67,20 @@ export const EditingUserList: FC<Props> = ({ clientList }) => {
             <Popover
             <Popover
               placement="bottom"
               placement="bottom"
               isOpen={isPopoverOpen}
               isOpen={isPopoverOpen}
-              target="btn-editing-user"
+              target={popoverTargetId}
               toggle={togglePopover}
               toggle={togglePopover}
               trigger="legacy"
               trigger="legacy"
             >
             >
               <PopoverBody className={userListPopoverClass}>
               <PopoverBody className={userListPopoverClass}>
-                <UserPictureList users={remainingUsers} />
+                <div className="d-flex flex-wrap gap-1">
+                  {remainingUsers.map((editingClient) => (
+                    <AvatarWrapper
+                      key={editingClient.clientId}
+                      client={editingClient}
+                      onUserClick={onUserClick}
+                    />
+                  ))}
+                </div>
               </PopoverBody>
               </PopoverBody>
             </Popover>
             </Popover>
           </div>
           </div>

+ 8 - 1
apps/app/src/client/components/PageEditor/EditorNavbar/EditorNavbar.tsx

@@ -2,6 +2,7 @@ import type { JSX } from 'react';
 
 
 import { PageHeader } from '~/client/components/PageHeader';
 import { PageHeader } from '~/client/components/PageHeader';
 import { useEditingClients } from '~/states/ui/editor/editing-clients';
 import { useEditingClients } from '~/states/ui/editor/editing-clients';
+import { useScrollToRemoteCursor } from '~/states/ui/editor/scroll-to-remote-cursor';
 
 
 import { EditingUserList } from './EditingUserList';
 import { EditingUserList } from './EditingUserList';
 
 
@@ -11,7 +12,13 @@ const moduleClass = styles['editor-navbar'] ?? '';
 
 
 const EditingUsers = (): JSX.Element => {
 const EditingUsers = (): JSX.Element => {
   const editingClients = useEditingClients();
   const editingClients = useEditingClients();
-  return <EditingUserList clientList={editingClients} />;
+  const scrollToRemoteCursor = useScrollToRemoteCursor();
+  return (
+    <EditingUserList
+      clientList={editingClients}
+      onUserClick={scrollToRemoteCursor ?? undefined}
+    />
+  );
 };
 };
 
 
 export const EditorNavbar = (): JSX.Element => {
 export const EditorNavbar = (): JSX.Element => {

+ 3 - 0
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -57,6 +57,7 @@ import {
   useWaitingSaveProcessingActions,
   useWaitingSaveProcessingActions,
 } from '~/states/ui/editor';
 } from '~/states/ui/editor';
 import { useSetEditingClients } from '~/states/ui/editor/editing-clients';
 import { useSetEditingClients } from '~/states/ui/editor/editing-clients';
+import { useSetScrollToRemoteCursor } from '~/states/ui/editor/scroll-to-remote-cursor';
 import { useEditorSettings } from '~/stores/editor';
 import { useEditorSettings } from '~/stores/editor';
 import { useSWRxCurrentGrantData } from '~/stores/page';
 import { useSWRxCurrentGrantData } from '~/stores/page';
 import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
 import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
@@ -128,6 +129,7 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   );
   );
   const user = useCurrentUser();
   const user = useCurrentUser();
   const setEditingClients = useSetEditingClients();
   const setEditingClients = useSetEditingClients();
+  const setScrollToRemoteCursor = useSetScrollToRemoteCursor();
   const onConflict = useConflictResolver();
   const onConflict = useConflictResolver();
   const reservedNextCaretLine = useReservedNextCaretLineValue();
   const reservedNextCaretLine = useReservedNextCaretLineValue();
   const setReservedNextCaretLine = useSetReservedNextCaretLine();
   const setReservedNextCaretLine = useSetReservedNextCaretLine();
@@ -474,6 +476,7 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
           pageId={pageId ?? undefined}
           pageId={pageId ?? undefined}
           editorSettings={editorSettings}
           editorSettings={editorSettings}
           onEditorsUpdated={setEditingClients}
           onEditorsUpdated={setEditingClients}
+          onScrollToRemoteCursorReady={setScrollToRemoteCursor}
           cmProps={cmProps}
           cmProps={cmProps}
         />
         />
       </div>
       </div>

+ 2 - 2
apps/app/src/server/service/yjs/y-websocket-server.d.ts

@@ -1,13 +1,13 @@
 declare module 'y-websocket/bin/utils' {
 declare module 'y-websocket/bin/utils' {
   import type { IncomingMessage } from 'http';
   import type { IncomingMessage } from 'http';
   import type { WebSocket } from 'ws';
   import type { WebSocket } from 'ws';
-  import type { Awareness } from 'y-protocols/awareness';
+  import type { WebsocketProvider } from 'y-websocket';
   import * as Y from 'yjs';
   import * as Y from 'yjs';
 
 
   export class WSSharedDoc extends Y.Doc {
   export class WSSharedDoc extends Y.Doc {
     name: string;
     name: string;
     conns: Map<WebSocket, Set<number>>;
     conns: Map<WebSocket, Set<number>>;
-    awareness: Awareness;
+    awareness: WebsocketProvider['awareness'];
     whenInitialized: Promise<void>;
     whenInitialized: Promise<void>;
     constructor(name: string);
     constructor(name: string);
   }
   }

+ 25 - 0
apps/app/src/states/ui/editor/scroll-to-remote-cursor.ts

@@ -0,0 +1,25 @@
+import { useCallback } from 'react';
+import { atom, useAtomValue, useSetAtom } from 'jotai';
+
+type ScrollToRemoteCursorFn = (clientId: number) => void;
+
+const scrollToRemoteCursorAtom = atom<ScrollToRemoteCursorFn | null>(null);
+
+// Read-only hook
+export const useScrollToRemoteCursor = (): ScrollToRemoteCursorFn | null => {
+  return useAtomValue(scrollToRemoteCursorAtom);
+};
+
+// Setter hook — wraps function values to prevent Jotai from treating them as
+// updater functions (Jotai calls setAtom(fn) as setAtom(prev => fn(prev))).
+export const useSetScrollToRemoteCursor = (): ((
+  fn: ScrollToRemoteCursorFn | null,
+) => void) => {
+  const setAtom = useSetAtom(scrollToRemoteCursorAtom);
+  return useCallback(
+    (fn: ScrollToRemoteCursorFn | null) => {
+      setAtom(() => fn);
+    },
+    [setAtom],
+  );
+};

+ 5 - 1
packages/editor/src/@types/scss.d.ts

@@ -1,5 +1,9 @@
 // prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
 // prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
-declare module '*.scss';
+declare module '*.module.scss' {
+  const classes: Record<string, string>;
+  // biome-ignore lint/style/noDefaultExport: CSS Modules require default export
+  export default classes;
+}
 
 
 // prevent TS7016: Could not find a declaration file for module 'emoji-mart'.
 // prevent TS7016: Could not find a declaration file for module 'emoji-mart'.
 declare module 'emoji-mart';
 declare module 'emoji-mart';

+ 5 - 0
packages/editor/src/client/components/CodeMirrorEditorMain.tsx

@@ -28,6 +28,9 @@ type Props = CodeMirrorEditorProps & {
   enableCollaboration?: boolean;
   enableCollaboration?: boolean;
   enableUnifiedMergeView?: boolean;
   enableUnifiedMergeView?: boolean;
   onEditorsUpdated?: (clientList: EditingClient[]) => void;
   onEditorsUpdated?: (clientList: EditingClient[]) => void;
+  onScrollToRemoteCursorReady?: (
+    scrollFn: ((clientId: number) => void) | null,
+  ) => void;
 };
 };
 
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
@@ -39,6 +42,7 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
     cmProps,
     cmProps,
     onSave,
     onSave,
     onEditorsUpdated,
     onEditorsUpdated,
+    onScrollToRemoteCursorReady,
     ...otherProps
     ...otherProps
   } = props;
   } = props;
 
 
@@ -50,6 +54,7 @@ export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
     user,
     user,
     pageId,
     pageId,
     onEditorsUpdated,
     onEditorsUpdated,
+    onScrollToRemoteCursorReady,
     reviewMode: enableUnifiedMergeView,
     reviewMode: enableUnifiedMergeView,
   });
   });
 
 

+ 1 - 0
packages/editor/src/client/services-internal/extensions/index.ts

@@ -1,2 +1,3 @@
 export * from './emojiAutocompletionSettings';
 export * from './emojiAutocompletionSettings';
 export * from './setDataLine';
 export * from './setDataLine';
+export * from './y-rich-cursors/index';

+ 54 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/activity-tracker.ts

@@ -0,0 +1,54 @@
+/**
+ * Tracks per-client activity timestamps and inactivity timers.
+ *
+ * When a remote client's awareness state changes, the tracker records the
+ * timestamp and starts a 3-second inactivity timer. On expiry the supplied
+ * callback fires (typically dispatching a decoration rebuild).
+ */
+
+const ACTIVITY_TIMEOUT_MS = 3000;
+
+export class ActivityTracker {
+  private readonly lastActivityMap = new Map<number, number>();
+  private readonly activeTimers = new Map<
+    number,
+    ReturnType<typeof setTimeout>
+  >();
+
+  /** Record activity for a remote client; resets the inactivity timer. */
+  recordActivity(clientId: number, now: number, onInactive: () => void): void {
+    this.lastActivityMap.set(clientId, now);
+
+    const existing = this.activeTimers.get(clientId);
+    if (existing != null) clearTimeout(existing);
+
+    this.activeTimers.set(
+      clientId,
+      setTimeout(onInactive, ACTIVITY_TIMEOUT_MS),
+    );
+  }
+
+  /** Clean up tracking state for a disconnected client. */
+  removeClient(clientId: number): void {
+    this.lastActivityMap.delete(clientId);
+    const timer = this.activeTimers.get(clientId);
+    if (timer != null) clearTimeout(timer);
+    this.activeTimers.delete(clientId);
+  }
+
+  /** Whether the client has been active within the last 3 seconds. */
+  isActive(clientId: number, now: number): boolean {
+    return (
+      now - (this.lastActivityMap.get(clientId) ?? 0) < ACTIVITY_TIMEOUT_MS
+    );
+  }
+
+  /** Clear all timers and state. */
+  destroy(): void {
+    for (const timer of this.activeTimers.values()) {
+      clearTimeout(timer);
+    }
+    this.activeTimers.clear();
+    this.lastActivityMap.clear();
+  }
+}

+ 4 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/index.ts

@@ -0,0 +1,4 @@
+export type { OffScreenIndicatorOptions } from './off-screen-indicator';
+export { createOffScreenIndicator } from './off-screen-indicator';
+export { richCursorsTheme } from './theme';
+export { RichCaretWidget } from './widget';

+ 294 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/off-screen-indicator.spec.ts

@@ -0,0 +1,294 @@
+import { describe, expect, it } from 'vitest';
+
+import { createOffScreenIndicator } from './off-screen-indicator';
+
+/**
+ * Unit tests for off-screen indicators.
+ *
+ * Covers:
+ * - Task 9.2: Off-screen indicator DOM construction and avatar fallback
+ * - Requirements: 4.1, 4.2, 4.4
+ */
+
+describe('createOffScreenIndicator', () => {
+  it('renders an indicator with an upward Material Symbol for direction "above"', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 1,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: false,
+    });
+
+    const arrow = el.querySelector('.cm-offScreenArrow');
+    expect(arrow).not.toBeNull();
+    expect(arrow?.textContent).toBe('arrow_drop_up');
+    expect(arrow?.classList.contains('material-symbols-outlined')).toBe(true);
+  });
+
+  it('renders an indicator with a downward Material Symbol for direction "below"', () => {
+    const el = createOffScreenIndicator({
+      direction: 'below',
+      clientId: 1,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: false,
+    });
+
+    const arrow = el.querySelector('.cm-offScreenArrow');
+    expect(arrow?.textContent).toBe('arrow_drop_down');
+    expect(arrow?.classList.contains('material-symbols-outlined')).toBe(true);
+  });
+
+  it('places the arrow before the avatar for direction "above"', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 1,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: undefined,
+      isActive: false,
+    });
+    const children = Array.from(el.children);
+    expect(children[0]?.classList.contains('cm-offScreenArrow')).toBe(true);
+    expect(children[1]?.classList.contains('cm-offScreenInitials')).toBe(true);
+  });
+
+  it('places the avatar before the arrow for direction "below"', () => {
+    const el = createOffScreenIndicator({
+      direction: 'below',
+      clientId: 1,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: undefined,
+      isActive: false,
+    });
+    const children = Array.from(el.children);
+    expect(children[0]?.classList.contains('cm-offScreenInitials')).toBe(true);
+    expect(children[1]?.classList.contains('cm-offScreenArrow')).toBe(true);
+  });
+
+  it('renders an avatar image when imageUrlCached is provided', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 1,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: false,
+    });
+
+    const img = el.querySelector(
+      'img.cm-offScreenAvatar',
+    ) as HTMLImageElement | null;
+    expect(img).not.toBeNull();
+    expect(img?.src).toContain('/avatar.png');
+  });
+
+  it('renders initials fallback when imageUrlCached is undefined', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 1,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: undefined,
+      isActive: false,
+    });
+
+    const img = el.querySelector('img.cm-offScreenAvatar');
+    expect(img).toBeNull();
+
+    const initials = el.querySelector('.cm-offScreenInitials');
+    expect(initials).not.toBeNull();
+    expect(initials?.textContent).toBe('A');
+  });
+
+  it('applies cm-yRichCursorActive class when isActive is true', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 1,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: true,
+    });
+
+    expect(el.classList.contains('cm-yRichCursorActive')).toBe(true);
+  });
+
+  it('does NOT apply cm-yRichCursorActive class when isActive is false', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 1,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: false,
+    });
+
+    expect(el.classList.contains('cm-yRichCursorActive')).toBe(false);
+  });
+
+  it('applies border-color on the indicator wrapper from the color parameter', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 1,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: undefined,
+      isActive: false,
+    });
+
+    expect(el.style.borderColor).toBe('#ff0000');
+  });
+
+  it('sets borderColor on the avatar img to the cursor color', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 1,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: false,
+    });
+
+    const img = el.querySelector(
+      'img.cm-offScreenAvatar',
+    ) as HTMLImageElement | null;
+    expect(img?.style.borderColor).toBe('#ff0000');
+  });
+
+  it('sets borderColor on the initials element to the cursor color', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 1,
+      color: '#0000ff',
+      name: 'Alice',
+      imageUrlCached: undefined,
+      isActive: false,
+    });
+
+    const initials = el.querySelector(
+      '.cm-offScreenInitials',
+    ) as HTMLElement | null;
+    expect(initials?.style.borderColor).toBe('#0000ff');
+  });
+
+  it('sets borderColor on the onerror-fallback initials to the cursor color', () => {
+    const el = createOffScreenIndicator({
+      direction: 'below',
+      clientId: 1,
+      color: '#00ff00',
+      name: 'Alice',
+      imageUrlCached: '/broken.png',
+      isActive: false,
+    });
+
+    const img = el.querySelector('img.cm-offScreenAvatar') as HTMLImageElement;
+    img.dispatchEvent(new Event('error'));
+
+    const initials = el.querySelector(
+      '.cm-offScreenInitials',
+    ) as HTMLElement | null;
+    expect(initials?.style.borderColor).toBe('#00ff00');
+  });
+});
+
+describe('createOffScreenIndicator — hover tooltip', () => {
+  it('renders a tooltip with the user name and cursor color', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 1,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: '/avatar.png',
+      isActive: false,
+    });
+
+    const tooltip = el.querySelector('.cm-offScreenTooltip') as HTMLElement;
+    expect(tooltip.textContent).toBe('Alice');
+    expect(tooltip.style.backgroundColor).toBe('#ff0000');
+  });
+});
+
+/**
+ * Task 20.1 — Click behavior tests for off-screen indicators
+ * Requirements: 6.6, 6.7
+ */
+describe('createOffScreenIndicator — click behavior (Task 20.1)', () => {
+  it('applies cursor: pointer on the indicator root when onClick is provided', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 42,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: undefined,
+      isActive: false,
+      onClick: vi.fn(),
+    });
+
+    expect(el.style.cursor).toBe('pointer');
+  });
+
+  it('does NOT apply cursor: pointer when onClick is not provided', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 42,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: undefined,
+      isActive: false,
+    });
+
+    expect(el.style.cursor).toBe('');
+  });
+
+  it('calls onClick with the correct clientId when indicator is clicked', () => {
+    const onClick = vi.fn();
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      clientId: 42,
+      color: '#ff0000',
+      name: 'Alice',
+      imageUrlCached: undefined,
+      isActive: false,
+      onClick,
+    });
+
+    el.dispatchEvent(new Event('click'));
+
+    expect(onClick).toHaveBeenCalledOnce();
+    expect(onClick).toHaveBeenCalledWith(42);
+  });
+
+  it('does not throw when clicked and onClick is not provided', () => {
+    const el = createOffScreenIndicator({
+      direction: 'below',
+      clientId: 99,
+      color: '#00ff00',
+      name: 'Bob',
+      imageUrlCached: undefined,
+      isActive: false,
+    });
+
+    expect(() => el.dispatchEvent(new Event('click'))).not.toThrow();
+  });
+
+  it('works with clientId 0 (boundary value)', () => {
+    const onClick = vi.fn();
+    const el = createOffScreenIndicator({
+      direction: 'below',
+      clientId: 0,
+      color: '#0000ff',
+      name: 'Zero',
+      imageUrlCached: undefined,
+      isActive: false,
+      onClick,
+    });
+
+    el.dispatchEvent(new Event('click'));
+    expect(onClick).toHaveBeenCalledWith(0);
+  });
+});

+ 108 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/off-screen-indicator.ts

@@ -0,0 +1,108 @@
+import { toInitials } from './widget';
+
+export type OffScreenIndicatorOptions = {
+  direction: 'above' | 'below';
+  /** Client ID of the remote user; passed to onClick when the indicator is clicked. */
+  clientId: number;
+  color: string;
+  name: string;
+  imageUrlCached: string | undefined;
+  isActive: boolean;
+  /** Invoked with clientId when the indicator is clicked. Omit to suppress click handling. */
+  onClick?: (clientId: number) => void;
+};
+
+/**
+ * Creates an off-screen indicator DOM element for a remote cursor
+ * that is outside the visible viewport.
+ *
+ * DOM structure (above):
+ *   <span class="cm-offScreenIndicator">
+ *     <span class="material-symbols-outlined cm-offScreenArrow">arrow_drop_up</span>
+ *     <img class="cm-offScreenAvatar" />  or  <span class="cm-offScreenInitials" />
+ *   </span>
+ *
+ * DOM structure (below):
+ *   <span class="cm-offScreenIndicator">
+ *     <img class="cm-offScreenAvatar" />  or  <span class="cm-offScreenInitials" />
+ *     <span class="material-symbols-outlined cm-offScreenArrow">arrow_drop_down</span>
+ *   </span>
+ *
+ * Horizontal position (left / transform) is set by the caller via requestMeasure.
+ */
+export function createOffScreenIndicator(
+  opts: OffScreenIndicatorOptions,
+): HTMLElement {
+  const {
+    direction,
+    clientId,
+    color,
+    name,
+    imageUrlCached,
+    isActive,
+    onClick,
+  } = opts;
+
+  const indicator = document.createElement('span');
+  indicator.className = 'cm-offScreenIndicator';
+  indicator.style.borderColor = color;
+  if (isActive) {
+    indicator.classList.add('cm-yRichCursorActive');
+  }
+
+  if (onClick != null) {
+    indicator.style.cursor = 'pointer';
+    indicator.addEventListener('click', () => onClick(clientId));
+  }
+
+  const arrow = document.createElement('span');
+  arrow.className = 'material-symbols-outlined cm-offScreenArrow';
+  arrow.style.color = color;
+  arrow.textContent =
+    direction === 'above' ? 'arrow_drop_up' : 'arrow_drop_down';
+
+  let avatarEl: HTMLElement;
+  if (imageUrlCached) {
+    const img = document.createElement('img');
+    img.className = 'cm-offScreenAvatar';
+    img.src = imageUrlCached;
+    img.alt = name;
+    img.style.borderColor = color;
+    img.onerror = () => {
+      const initials = document.createElement('span');
+      initials.className = 'cm-offScreenInitials';
+      initials.style.backgroundColor = color;
+      initials.style.borderColor = color;
+      initials.textContent = toInitials(name);
+      img.replaceWith(initials);
+    };
+    avatarEl = img;
+  } else {
+    const initials = document.createElement('span');
+    initials.className = 'cm-offScreenInitials';
+    initials.style.backgroundColor = color;
+    initials.style.borderColor = color;
+    initials.textContent = toInitials(name);
+    avatarEl = initials;
+  }
+
+  const tooltip = document.createElement('span');
+  tooltip.className = 'cm-offScreenTooltip';
+  tooltip.style.backgroundColor = color;
+  tooltip.textContent = name;
+
+  // "above": arrow points up (toward the off-screen cursor), avatar below
+  // "below": avatar above, arrow points down (toward the off-screen cursor)
+  if (direction === 'above') {
+    indicator.appendChild(arrow);
+    indicator.appendChild(avatarEl);
+    tooltip.style.bottom = '0';
+  } else {
+    indicator.appendChild(avatarEl);
+    indicator.appendChild(arrow);
+    tooltip.style.top = '0';
+  }
+  indicator.appendChild(tooltip);
+
+  return indicator;
+}

+ 194 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/theme.ts

@@ -0,0 +1,194 @@
+import { EditorView } from '@codemirror/view';
+
+// Shared design tokens
+const AVATAR_SIZE = '20px';
+const IDLE_OPACITY = '0.6';
+
+export const richCursorsTheme = EditorView.baseTheme({
+  // Caret line — negative margins cancel out border width to avoid text shift.
+  // Modeled after yRemoteSelectionsTheme in y-codemirror.next.
+  '.cm-yRichCaret': {
+    position: 'relative',
+    borderLeft: '1px solid',
+    borderRight: '1px solid',
+    marginLeft: '-1px',
+    marginRight: '-1px',
+    boxSizing: 'border-box',
+    display: 'inline',
+  },
+
+  // Overlay flag — positioned below the caret.
+  // pointer-events: auto so the avatar itself is a hover/click target.
+  '.cm-yRichCursorFlag': {
+    position: 'absolute',
+    top: '100%',
+    left: '-9px', // center the avatar on the 1px caret
+    zIndex: '10',
+    opacity: IDLE_OPACITY,
+    transition: 'opacity 0.3s ease',
+  },
+  '.cm-yRichCaret:hover .cm-yRichCursorFlag, .cm-yRichCursorFlag:hover': {
+    opacity: '1',
+  },
+  '.cm-yRichCursorFlag.cm-yRichCursorActive': {
+    opacity: '1',
+  },
+
+  // Avatar image
+  '.cm-yRichCursorAvatar': {
+    width: AVATAR_SIZE,
+    height: AVATAR_SIZE,
+    borderRadius: '50%',
+    display: 'block',
+    borderStyle: 'solid',
+    borderWidth: '1.5px',
+    boxSizing: 'border-box',
+  },
+
+  // Initials fallback
+  '.cm-yRichCursorInitials': {
+    width: AVATAR_SIZE,
+    height: AVATAR_SIZE,
+    borderRadius: '50%',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    color: 'white',
+    fontSize: '9px',
+    fontWeight: 'bold',
+    borderStyle: 'solid',
+    borderWidth: '1.5px',
+    boxSizing: 'border-box',
+  },
+
+  // Name label — hidden by default, shown on hover.
+  // Sits behind the avatar; left border-radius matches the avatar circle.
+  '.cm-yRichCursorInfo': {
+    display: 'none',
+    position: 'absolute',
+    top: '0',
+    left: '0',
+    zIndex: '-1',
+    whiteSpace: 'nowrap',
+    padding: `0 6px 0 calc(${AVATAR_SIZE} + 4px)`,
+    borderRadius: `calc(${AVATAR_SIZE} / 2) 3px 3px calc(${AVATAR_SIZE} / 2)`,
+    color: 'white',
+    fontSize: '12px',
+    height: AVATAR_SIZE,
+    lineHeight: AVATAR_SIZE,
+  },
+  '.cm-yRichCursorFlag:hover .cm-yRichCursorInfo': {
+    display: 'block',
+  },
+
+  // --- Off-screen containers ---
+  // height: 0 so the containers themselves take no layout space.
+  // Indicators use position:absolute and overflow beyond the 0-height boundary
+  // (default overflow:visible), anchored to the edge via top/bottom on the indicator.
+  '.cm-offScreenTop, .cm-offScreenBottom': {
+    position: 'absolute',
+    left: '0',
+    right: '0',
+    height: '0',
+    pointerEvents: 'none',
+    zIndex: '10',
+  },
+  // top/bottom: 0 keeps the container at the editor's inner border edge.
+  // Indicators have z-index:10 and are children of .cm-editor, so they paint
+  // in front of .cm-editor's own border — no clipping from parent overflow:hidden.
+  '.cm-offScreenTop': {
+    top: '0',
+  },
+  '.cm-offScreenBottom': {
+    bottom: '0',
+  },
+
+  // Off-screen indicator — absolutely positioned; left/transform set by plugin
+  // via requestMeasure to reflect the remote cursor's column position.
+  // Default anchor: top:0 (used by topContainer — arrow touches the top edge).
+  '.cm-offScreenIndicator': {
+    position: 'absolute',
+    top: '0',
+    display: 'flex',
+    flexDirection: 'column',
+    alignItems: 'center',
+    pointerEvents: 'auto',
+  },
+  // In the bottom container the indicator hangs upward from the bottom edge,
+  // so the arrow (last child) sits right at/overlapping the editor's bottom border.
+  '.cm-offScreenBottom .cm-offScreenIndicator': {
+    top: 'auto',
+    bottom: '0',
+  },
+
+  // Arrow — always fully opaque; cursor color applied via inline style in JS.
+  //
+  // Material Symbols icons have ~20% internal whitespace on each side of the
+  // bounding box. clip-path trims both top and bottom simultaneously so the
+  // visible triangle is flush with both the avatar AND the editor edge.
+  // Negative margins compensate the clip so the flex layout stays correct.
+  // Arrow — always fully opaque; negative margins trim the ~20% internal
+  // whitespace of Material Symbols so the triangle sits flush with the avatar
+  // and the editor edge.
+  '.cm-offScreenArrow': {
+    fontFamily: 'var(--grw-font-family-material-symbols-outlined)',
+    fontSize: '20px',
+    lineHeight: '1',
+    display: 'block',
+    userSelect: 'none',
+    opacity: '1',
+    marginTop: '-8px',
+    marginBottom: '-8px',
+  },
+
+  // Avatar and initials fade when idle; full opacity when active.
+  '.cm-offScreenAvatar': {
+    width: AVATAR_SIZE,
+    height: AVATAR_SIZE,
+    borderRadius: '50%',
+    borderStyle: 'solid',
+    borderWidth: '1.5px',
+    boxSizing: 'border-box',
+    opacity: IDLE_OPACITY,
+    transition: 'opacity 0.3s ease',
+  },
+  '.cm-offScreenInitials': {
+    width: AVATAR_SIZE,
+    height: AVATAR_SIZE,
+    borderRadius: '50%',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    color: 'white',
+    fontSize: '9px',
+    fontWeight: 'bold',
+    borderStyle: 'solid',
+    borderWidth: '1.5px',
+    boxSizing: 'border-box',
+    opacity: IDLE_OPACITY,
+    transition: 'opacity 0.3s ease',
+  },
+  '.cm-offScreenIndicator.cm-yRichCursorActive .cm-offScreenAvatar, .cm-offScreenIndicator.cm-yRichCursorActive .cm-offScreenInitials':
+    {
+      opacity: '1',
+    },
+
+  // Name tooltip — tab shape behind the avatar, matching .cm-yRichCursorInfo.
+  // top/bottom set inline in JS to align with the avatar per direction.
+  '.cm-offScreenTooltip': {
+    display: 'none',
+    position: 'absolute',
+    left: '0',
+    zIndex: '-1',
+    whiteSpace: 'nowrap',
+    padding: `0 6px 0 calc(${AVATAR_SIZE} + 4px)`,
+    borderRadius: `calc(${AVATAR_SIZE} / 2) 3px 3px calc(${AVATAR_SIZE} / 2)`,
+    color: 'white',
+    fontSize: '12px',
+    height: AVATAR_SIZE,
+    lineHeight: AVATAR_SIZE,
+  },
+  '.cm-offScreenIndicator:hover .cm-offScreenTooltip': {
+    display: 'block',
+  },
+});

+ 221 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/widget.spec.ts

@@ -0,0 +1,221 @@
+import { describe, expect, it } from 'vitest';
+
+import { RichCaretWidget } from './widget';
+
+/**
+ * Unit tests for RichCaretWidget.
+ *
+ * Covers:
+ * - Task 9.1: Updated widget DOM structure, overlay flag, sizing, isActive class
+ * - Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.10
+ */
+
+const opts = (
+  overrides: Partial<ConstructorParameters<typeof RichCaretWidget>[0]> = {},
+) => ({
+  color: '#ff0000',
+  name: 'Alice',
+  imageUrlCached: undefined as string | undefined,
+  isActive: false,
+  ...overrides,
+});
+
+describe('RichCaretWidget', () => {
+  describe('toDOM()', () => {
+    it('renders a cm-yRichCaret span with border color from the color parameter', () => {
+      const widget = new RichCaretWidget(opts());
+      const dom = widget.toDOM();
+
+      expect(dom.className).toBe('cm-yRichCaret');
+      expect(dom.style.borderColor).toBe('#ff0000');
+    });
+
+    it('renders a flag container with position relative inside the caret element', () => {
+      const widget = new RichCaretWidget(opts());
+      const dom = widget.toDOM();
+
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      expect(flag).not.toBeNull();
+    });
+
+    it('renders an img element inside the flag when imageUrlCached is provided', () => {
+      const widget = new RichCaretWidget(
+        opts({ imageUrlCached: '/avatar.png' }),
+      );
+      const dom = widget.toDOM();
+
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      const img = flag?.querySelector(
+        'img.cm-yRichCursorAvatar',
+      ) as HTMLImageElement | null;
+      expect(img).not.toBeNull();
+      expect(img?.src).toContain('/avatar.png');
+      expect(img?.alt).toBe('Alice');
+    });
+
+    it('sets borderColor on the avatar img to the cursor color', () => {
+      const widget = new RichCaretWidget(
+        opts({ color: '#ff0000', imageUrlCached: '/avatar.png' }),
+      );
+      const dom = widget.toDOM();
+
+      const img = dom.querySelector(
+        'img.cm-yRichCursorAvatar',
+      ) as HTMLImageElement | null;
+      expect(img?.style.borderColor).toBe('#ff0000');
+    });
+
+    it('sets borderColor on the initials element to the cursor color', () => {
+      const widget = new RichCaretWidget(opts({ color: '#00ff00' }));
+      const dom = widget.toDOM();
+
+      const initials = dom.querySelector(
+        '.cm-yRichCursorInitials',
+      ) as HTMLElement | null;
+      expect(initials?.style.borderColor).toBe('#00ff00');
+    });
+
+    it('does NOT render an img element when imageUrlCached is undefined', () => {
+      const widget = new RichCaretWidget(opts());
+      const dom = widget.toDOM();
+
+      const img = dom.querySelector('img.cm-yRichCursorAvatar');
+      expect(img).toBeNull();
+    });
+
+    it('renders initials span inside the flag when imageUrlCached is undefined', () => {
+      const widget = new RichCaretWidget(opts({ name: 'Alice Bob' }));
+      const dom = widget.toDOM();
+
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      const initials = flag?.querySelector('.cm-yRichCursorInitials');
+      expect(initials).not.toBeNull();
+      expect(initials?.textContent).toBe('AB');
+    });
+
+    it('renders initials for a single-word name', () => {
+      const widget = new RichCaretWidget(opts());
+      const dom = widget.toDOM();
+
+      const initials = dom.querySelector('.cm-yRichCursorInitials');
+      expect(initials?.textContent).toBe('A');
+    });
+
+    it('replaces img with initials span on onerror', () => {
+      const widget = new RichCaretWidget(
+        opts({
+          color: '#0000ff',
+          name: 'Bob',
+          imageUrlCached: '/broken.png',
+        }),
+      );
+      const dom = widget.toDOM();
+
+      const img = dom.querySelector(
+        'img.cm-yRichCursorAvatar',
+      ) as HTMLImageElement;
+      expect(img).not.toBeNull();
+
+      // Simulate image load failure
+      img.dispatchEvent(new Event('error'));
+
+      expect(dom.querySelector('img.cm-yRichCursorAvatar')).toBeNull();
+      const initials = dom.querySelector(
+        '.cm-yRichCursorInitials',
+      ) as HTMLElement | null;
+      expect(initials).not.toBeNull();
+      expect(initials?.textContent).toBe('B');
+      expect(initials?.style.borderColor).toBe('#0000ff');
+    });
+
+    it('renders a name label inside the flag container', () => {
+      const widget = new RichCaretWidget(opts());
+      const dom = widget.toDOM();
+
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      const info = flag?.querySelector('.cm-yRichCursorInfo');
+      expect(info).not.toBeNull();
+      expect(info?.textContent).toBe('Alice');
+    });
+
+    it('applies cm-yRichCursorActive class to the flag element when isActive is true', () => {
+      const widget = new RichCaretWidget(opts({ isActive: true }));
+      const dom = widget.toDOM();
+
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      expect(flag?.classList.contains('cm-yRichCursorActive')).toBe(true);
+    });
+
+    it('does NOT apply cm-yRichCursorActive class to the flag when isActive is false', () => {
+      const widget = new RichCaretWidget(opts());
+      const dom = widget.toDOM();
+
+      const flag = dom.querySelector('.cm-yRichCursorFlag');
+      expect(flag?.classList.contains('cm-yRichCursorActive')).toBe(false);
+    });
+  });
+
+  describe('eq()', () => {
+    it('returns true when all fields match', () => {
+      const a = new RichCaretWidget(opts({ imageUrlCached: '/avatar.png' }));
+      const b = new RichCaretWidget(opts({ imageUrlCached: '/avatar.png' }));
+
+      expect(a.eq(b)).toBe(true);
+    });
+
+    it('returns false when color differs', () => {
+      const a = new RichCaretWidget(opts({ imageUrlCached: '/avatar.png' }));
+      const b = new RichCaretWidget(
+        opts({ color: '#0000ff', imageUrlCached: '/avatar.png' }),
+      );
+
+      expect(a.eq(b)).toBe(false);
+    });
+
+    it('returns false when name differs', () => {
+      const a = new RichCaretWidget(opts({ imageUrlCached: '/avatar.png' }));
+      const b = new RichCaretWidget(
+        opts({ name: 'Bob', imageUrlCached: '/avatar.png' }),
+      );
+
+      expect(a.eq(b)).toBe(false);
+    });
+
+    it('returns false when imageUrlCached differs', () => {
+      const a = new RichCaretWidget(opts({ imageUrlCached: '/avatar.png' }));
+      const b = new RichCaretWidget(opts({ imageUrlCached: '/other.png' }));
+
+      expect(a.eq(b)).toBe(false);
+    });
+
+    it('returns false when one has imageUrlCached and the other does not', () => {
+      const a = new RichCaretWidget(opts({ imageUrlCached: '/avatar.png' }));
+      const b = new RichCaretWidget(opts());
+
+      expect(a.eq(b)).toBe(false);
+    });
+
+    it('returns false when isActive differs', () => {
+      const a = new RichCaretWidget(
+        opts({ imageUrlCached: '/avatar.png', isActive: true }),
+      );
+      const b = new RichCaretWidget(opts({ imageUrlCached: '/avatar.png' }));
+
+      expect(a.eq(b)).toBe(false);
+    });
+  });
+
+  describe('ignoreEvent()', () => {
+    it('returns true', () => {
+      const widget = new RichCaretWidget(opts());
+      expect(widget.ignoreEvent()).toBe(true);
+    });
+  });
+
+  describe('estimatedHeight', () => {
+    it('is -1 (inline widget)', () => {
+      const widget = new RichCaretWidget(opts());
+      expect(widget.estimatedHeight).toBe(-1);
+    });
+  });
+});

+ 122 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/dom/widget.ts

@@ -0,0 +1,122 @@
+import { WidgetType } from '@codemirror/view';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Derives initials (up to 2 letters) from a display name. */
+export function toInitials(name: string): string {
+  const words = name.trim().split(/\s+/);
+  if (words.length === 1) return (words[0][0] ?? '').toUpperCase();
+  return (
+    (words[0][0] ?? '') + (words[words.length - 1][0] ?? '')
+  ).toUpperCase();
+}
+
+export function createInitialsElement(
+  name: string,
+  color: string,
+): HTMLSpanElement {
+  const el = document.createElement('span');
+  el.className = 'cm-yRichCursorInitials';
+  el.style.backgroundColor = color;
+  el.style.borderColor = color;
+  el.textContent = toInitials(name);
+  return el;
+}
+
+// ---------------------------------------------------------------------------
+// RichCaretWidget
+// ---------------------------------------------------------------------------
+
+export type RichCaretWidgetOptions = {
+  color: string;
+  name: string;
+  imageUrlCached: string | undefined;
+  isActive: boolean;
+};
+
+/**
+ * CodeMirror WidgetType that renders a cursor caret with an overlay flag
+ * containing avatar image (or initials fallback) and hover-revealed name label.
+ *
+ * DOM structure:
+ * <span class="cm-yRichCaret" style="border-color: {color}">
+ *   <span class="cm-yRichCursorFlag [cm-yRichCursorActive]">
+ *     <img class="cm-yRichCursorAvatar" />  OR  <span class="cm-yRichCursorInitials" />
+ *     <span class="cm-yRichCursorInfo" style="background-color: {color}">{name}</span>
+ *   </span>
+ * </span>
+ */
+export class RichCaretWidget extends WidgetType {
+  readonly color: string;
+  readonly name: string;
+  readonly imageUrlCached: string | undefined;
+  readonly isActive: boolean;
+
+  constructor(opts: RichCaretWidgetOptions) {
+    super();
+    this.color = opts.color;
+    this.name = opts.name;
+    this.imageUrlCached = opts.imageUrlCached;
+    this.isActive = opts.isActive;
+  }
+
+  toDOM(): HTMLElement {
+    const caret = document.createElement('span');
+    caret.className = 'cm-yRichCaret';
+    caret.style.borderColor = this.color;
+
+    // Word Joiner: zero-width but inherits the line's font-size,
+    // so the caret border stretches to match header line heights.
+    caret.appendChild(document.createTextNode('\u2060'));
+
+    const flag = document.createElement('span');
+    flag.className = 'cm-yRichCursorFlag';
+    if (this.isActive) {
+      flag.classList.add('cm-yRichCursorActive');
+    }
+
+    if (this.imageUrlCached) {
+      const img = document.createElement('img');
+      img.className = 'cm-yRichCursorAvatar';
+      img.src = this.imageUrlCached;
+      img.alt = this.name;
+      img.style.borderColor = this.color;
+      img.onerror = () => {
+        const initials = createInitialsElement(this.name, this.color);
+        img.replaceWith(initials);
+      };
+      flag.appendChild(img);
+    } else {
+      flag.appendChild(createInitialsElement(this.name, this.color));
+    }
+
+    const info = document.createElement('span');
+    info.className = 'cm-yRichCursorInfo';
+    info.style.backgroundColor = this.color;
+    info.textContent = this.name;
+    flag.appendChild(info);
+
+    caret.appendChild(flag);
+    return caret;
+  }
+
+  eq(other: WidgetType): boolean {
+    if (!(other instanceof RichCaretWidget)) return false;
+    return (
+      other.color === this.color &&
+      other.name === this.name &&
+      other.imageUrlCached === this.imageUrlCached &&
+      other.isActive === this.isActive
+    );
+  }
+
+  get estimatedHeight(): number {
+    return -1;
+  }
+
+  ignoreEvent(): boolean {
+    return true;
+  }
+}

+ 42 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/index.ts

@@ -0,0 +1,42 @@
+import type { Extension } from '@codemirror/state';
+import { ViewPlugin } from '@codemirror/view';
+import type { WebsocketProvider } from 'y-websocket';
+
+import { richCursorsTheme } from './dom';
+import type { ScrollCallbackRef } from './plugin';
+import { YRichCursorsPluginValue } from './plugin';
+
+type Awareness = WebsocketProvider['awareness'];
+
+/** Options for the yRichCursors extension. */
+export type YRichCursorsOptions = {
+  /**
+   * Mutable ref holding the scroll-to-remote-cursor callback.
+   * When set, off-screen indicator clicks invoke ref.current(clientId).
+   * Null or unset means clicks are no-ops.
+   */
+  onClickIndicator?: ScrollCallbackRef;
+};
+
+/**
+ * Creates a CodeMirror Extension that renders remote user cursors with
+ * name labels and avatar images, reading user data from state.editors.
+ *
+ * Also broadcasts the local user's cursor position via state.cursor.
+ * Renders clickable off-screen indicators for cursors outside the viewport.
+ */
+export function yRichCursors(
+  awareness: Awareness,
+  options?: YRichCursorsOptions,
+): Extension {
+  return [
+    ViewPlugin.define(
+      (view) =>
+        new YRichCursorsPluginValue(view, awareness, options?.onClickIndicator),
+      {
+        decorations: (v) => (v as YRichCursorsPluginValue).decorations,
+      },
+    ),
+    richCursorsTheme,
+  ];
+}

+ 55 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/local-cursor.ts

@@ -0,0 +1,55 @@
+import type { ViewUpdate } from '@codemirror/view';
+import type { WebsocketProvider } from 'y-websocket';
+import * as Y from 'yjs';
+
+type Awareness = WebsocketProvider['awareness'];
+
+type LocalCursorState = {
+  cursor?: {
+    anchor: Y.RelativePosition;
+    head: Y.RelativePosition;
+  };
+};
+
+/**
+ * Broadcasts the local user's cursor position to the Yjs awareness protocol.
+ *
+ * Compares the current selection with the stored awareness cursor to avoid
+ * redundant broadcasts. Clears the cursor field when the editor loses focus.
+ */
+export function broadcastLocalCursor(
+  viewUpdate: ViewUpdate,
+  awareness: Awareness,
+  ytext: Y.Text,
+): void {
+  const localState = awareness.getLocalState() as LocalCursorState | null;
+  if (localState == null) return;
+
+  const hasFocus =
+    viewUpdate.view.hasFocus && viewUpdate.view.dom.ownerDocument.hasFocus();
+  const sel = hasFocus ? viewUpdate.state.selection.main : null;
+
+  if (sel != null) {
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, sel.anchor);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, sel.head);
+
+    const currentAnchor =
+      localState.cursor?.anchor != null
+        ? Y.createRelativePositionFromJSON(localState.cursor.anchor)
+        : null;
+    const currentHead =
+      localState.cursor?.head != null
+        ? Y.createRelativePositionFromJSON(localState.cursor.head)
+        : null;
+
+    if (
+      localState.cursor == null ||
+      !Y.compareRelativePositions(currentAnchor, anchor) ||
+      !Y.compareRelativePositions(currentHead, head)
+    ) {
+      awareness.setLocalStateField('cursor', { anchor, head });
+    }
+  } else if (localState.cursor != null && hasFocus) {
+    awareness.setLocalStateField('cursor', null);
+  }
+}

+ 598 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/plugin.integ.ts

@@ -0,0 +1,598 @@
+import { EditorState } from '@codemirror/state';
+import type { ViewUpdate } from '@codemirror/view';
+import { EditorView } from '@codemirror/view';
+import { yCollab } from 'y-codemirror.next';
+import * as Y from 'yjs';
+
+import type { EditingClient } from '../../../../interfaces';
+import { yRichCursors } from './index';
+import { YRichCursorsPluginValue } from './plugin';
+
+/**
+ * Integration tests for collaborative awareness flow.
+ *
+ * Covers:
+ * - Task 5.1: Awareness update flow to EditingUserList with multiple simulated clients
+ * - Task 5.2: Cursor position broadcasting verification
+ * - Task 10.1: Viewport classification (off-screen exclusion)
+ * - Task 10.2: Activity tracking timer lifecycle
+ * - Requirements: 1.3, 2.1, 2.4, 3.5, 3.6, 3.10, 4.3, 4.6
+ */
+
+// ---------------------------------------------------------------------------
+// Minimal awareness stub matching y-protocols/awareness interface
+// ---------------------------------------------------------------------------
+
+type AwarenessState = {
+  editors?: EditingClient;
+  cursor?: { anchor: Y.RelativePosition; head: Y.RelativePosition };
+};
+
+class FakeAwareness {
+  private states = new Map<number, AwarenessState>();
+  private localClientId: number;
+  private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
+  readonly doc: Y.Doc;
+
+  constructor(doc: Y.Doc) {
+    this.doc = doc;
+    this.localClientId = doc.clientID;
+    this.states.set(this.localClientId, {});
+  }
+
+  get clientID(): number {
+    return this.localClientId;
+  }
+
+  getStates(): Map<number, AwarenessState> {
+    return this.states;
+  }
+
+  getLocalState(): AwarenessState | null {
+    return this.states.get(this.localClientId) ?? null;
+  }
+
+  setLocalState(state: AwarenessState | null): void {
+    if (state == null) {
+      this.states.delete(this.localClientId);
+    } else {
+      this.states.set(this.localClientId, state);
+    }
+  }
+
+  setLocalStateField<K extends keyof AwarenessState>(
+    field: K,
+    value: AwarenessState[K],
+  ): void {
+    const current = this.states.get(this.localClientId) ?? {};
+    this.states.set(this.localClientId, { ...current, [field]: value });
+    this.emit('change', [
+      { added: [], updated: [this.localClientId], removed: [] },
+    ]);
+  }
+
+  /** Simulate a remote client setting their state */
+  setRemoteClientState(clientId: number, state: AwarenessState | null): void {
+    const isNew = !this.states.has(clientId);
+    if (state == null) {
+      this.states.delete(clientId);
+      this.emit('change', [{ added: [], updated: [], removed: [clientId] }]);
+      this.emit('update', [{ added: [], updated: [], removed: [clientId] }]);
+    } else {
+      this.states.set(clientId, state);
+      this.emit('change', [
+        {
+          added: isNew ? [clientId] : [],
+          updated: isNew ? [] : [clientId],
+          removed: [],
+        },
+      ]);
+      this.emit('update', [
+        {
+          added: isNew ? [clientId] : [],
+          updated: isNew ? [] : [clientId],
+          removed: [],
+        },
+      ]);
+    }
+  }
+
+  on(event: string, listener: (...args: unknown[]) => void): void {
+    if (!this.listeners.has(event)) {
+      this.listeners.set(event, new Set());
+    }
+    this.listeners.get(event)?.add(listener);
+  }
+
+  off(event: string, listener: (...args: unknown[]) => void): void {
+    this.listeners.get(event)?.delete(listener);
+  }
+
+  private emit(event: string, args: unknown[]): void {
+    this.listeners.get(event)?.forEach((fn) => {
+      fn(...args);
+    });
+  }
+}
+
+// ---------------------------------------------------------------------------
+// emitEditorList helper (mirrors the fixed implementation)
+// ---------------------------------------------------------------------------
+
+function buildEditorList(awareness: FakeAwareness): EditingClient[] {
+  return Array.from(awareness.getStates().values())
+    .map((v) => v.editors)
+    .filter((v): v is EditingClient => v != null);
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const makeClient = (id: number, name: string): EditingClient => ({
+  clientId: id,
+  name,
+  color: `#${id.toString(16).padStart(6, '0')}`,
+  colorLight: `#${id.toString(16).padStart(6, '0')}33`,
+});
+
+describe('Task 5.1 — Awareness update flow to EditingUserList', () => {
+  let ydoc: Y.Doc;
+  let awareness: FakeAwareness;
+  const LOCAL_CLIENT_ID = 1;
+
+  beforeEach(() => {
+    ydoc = new Y.Doc({ guid: 'test-doc' });
+    // Force a stable clientID for the local client
+    Object.defineProperty(ydoc, 'clientID', { value: LOCAL_CLIENT_ID });
+    awareness = new FakeAwareness(ydoc);
+  });
+
+  it('displays both users when two clients both have state.editors set', () => {
+    const client1 = makeClient(LOCAL_CLIENT_ID, 'Alice');
+    const client2 = makeClient(2, 'Bob');
+
+    awareness.setLocalStateField('editors', client1);
+    awareness.setRemoteClientState(2, { editors: client2 });
+
+    const list = buildEditorList(awareness);
+    expect(list).toHaveLength(2);
+    expect(list.map((c) => c.name)).toContain('Alice');
+    expect(list.map((c) => c.name)).toContain('Bob');
+  });
+
+  it('shows only the client with editors when one client has no editors field yet', () => {
+    const client1 = makeClient(LOCAL_CLIENT_ID, 'Alice');
+
+    awareness.setLocalStateField('editors', client1);
+    // Client 2 connects but has not broadcast editors yet
+    awareness.setRemoteClientState(2, {});
+
+    const list = buildEditorList(awareness);
+    expect(list).toHaveLength(1);
+    expect(list[0].name).toBe('Alice');
+  });
+
+  it('emits updated list when a remote client sets their editors field', () => {
+    const onEditorsUpdated = vi.fn();
+    awareness.on('update', () => {
+      onEditorsUpdated(buildEditorList(awareness));
+    });
+
+    const remoteClient = makeClient(2, 'Bob');
+    awareness.setRemoteClientState(2, { editors: remoteClient });
+
+    expect(onEditorsUpdated).toHaveBeenCalled();
+    const calls = onEditorsUpdated.mock.calls;
+    const lastCall = calls[calls.length - 1]?.[0] as EditingClient[];
+    expect(lastCall.map((c) => c.name)).toContain('Bob');
+  });
+
+  it('user presence information broadcast via state.editors is accessible from awareness state', () => {
+    const client1 = makeClient(LOCAL_CLIENT_ID, 'Alice');
+    awareness.setLocalStateField('editors', client1);
+
+    const localState = awareness.getLocalState();
+    expect(localState?.editors).toEqual(client1);
+  });
+});
+
+describe('Task 5.2 — Cursor position broadcasting', () => {
+  let ydoc: Y.Doc;
+  let awareness: FakeAwareness;
+
+  beforeEach(() => {
+    ydoc = new Y.Doc({ guid: 'test-doc-cursor' });
+    Object.defineProperty(ydoc, 'clientID', { value: 10 });
+    awareness = new FakeAwareness(ydoc);
+  });
+
+  it('updates state.cursor when setLocalStateField("cursor", ...) is called', () => {
+    const ytext = ydoc.getText('codemirror');
+    ytext.insert(0, 'Hello World');
+
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, 0);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, 5);
+
+    awareness.setLocalStateField('cursor', { anchor, head });
+
+    const localState = awareness.getLocalState();
+    expect(localState?.cursor).not.toBeNull();
+    expect(localState?.cursor?.anchor).toBeDefined();
+    expect(localState?.cursor?.head).toBeDefined();
+  });
+
+  it('reconstructs absolute position from stored relative position', () => {
+    const ytext = ydoc.getText('codemirror');
+    ytext.insert(0, 'Hello World');
+
+    const anchorIndex = 3;
+    const headIndex = 7;
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, anchorIndex);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, headIndex);
+
+    awareness.setLocalStateField('cursor', { anchor, head });
+
+    const stored = awareness.getLocalState()?.cursor;
+    expect(stored).toBeDefined();
+    if (stored == null) throw new Error('cursor not stored');
+
+    const restoredAnchor = Y.createAbsolutePositionFromRelativePosition(
+      stored.anchor,
+      ydoc,
+    );
+    const restoredHead = Y.createAbsolutePositionFromRelativePosition(
+      stored.head,
+      ydoc,
+    );
+
+    expect(restoredAnchor?.index).toBe(anchorIndex);
+    expect(restoredHead?.index).toBe(headIndex);
+  });
+
+  it('remote client awareness state with state.editors and state.cursor is accessible', () => {
+    const ytext = ydoc.getText('codemirror');
+    ytext.insert(0, 'Hello World');
+
+    const remoteClient = makeClient(20, 'Remote User');
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, 2);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, 6);
+
+    awareness.setRemoteClientState(20, {
+      editors: remoteClient,
+      cursor: { anchor, head },
+    });
+
+    const remoteState = awareness.getStates().get(20);
+    expect(remoteState?.editors).toEqual(remoteClient);
+    expect(remoteState?.cursor?.anchor).toBeDefined();
+    expect(remoteState?.cursor?.head).toBeDefined();
+    if (remoteState?.cursor == null) throw new Error('remote cursor not set');
+
+    // Verify that positions can be reconstructed (widget would use this)
+    const restoredAnchor = Y.createAbsolutePositionFromRelativePosition(
+      remoteState.cursor.anchor,
+      ydoc,
+    );
+    const restoredHead = Y.createAbsolutePositionFromRelativePosition(
+      remoteState.cursor.head,
+      ydoc,
+    );
+    expect(restoredAnchor?.index).toBe(2);
+    expect(restoredHead?.index).toBe(6);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Task 10.1 — Viewport classification (off-screen exclusion)
+// ---------------------------------------------------------------------------
+
+describe('Task 10.1 — Remote cursors outside the viewport are excluded from widget decorations', () => {
+  it('does not create widget decorations for a cursor positioned beyond the viewport', () => {
+    const ydoc = new Y.Doc({ guid: 'viewport-test' });
+    const ytext = ydoc.getText('codemirror');
+    // Insert enough content so the remote cursor can be outside the viewport
+    const longContent = 'Line\n'.repeat(200);
+    ytext.insert(0, longContent);
+
+    const awareness = new FakeAwareness(ydoc);
+
+    const state = EditorState.create({
+      doc: longContent,
+      extensions: [yCollab(ytext, null), yRichCursors(awareness as never)],
+    });
+
+    // Create a view with a small height so the viewport is limited
+    const container = document.createElement('div');
+    container.style.height = '100px';
+    container.style.overflow = 'auto';
+    document.body.appendChild(container);
+
+    const view = new EditorView({ state, parent: container });
+
+    // Set a remote client with cursor at a far-away position (end of doc)
+    const farIndex = longContent.length - 10;
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, farIndex);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, farIndex);
+    const remoteClient = makeClient(999, 'FarUser');
+
+    awareness.setRemoteClientState(999, {
+      editors: remoteClient,
+      cursor: { anchor, head },
+    });
+
+    // Force a view update cycle
+    view.dispatch({});
+
+    // Check that no cm-yRichCaret widget is rendered in the visible content
+    const carets = view.dom.querySelectorAll('.cm-yRichCaret');
+    expect(carets.length).toBe(0);
+
+    view.destroy();
+    container.remove();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Task 12.2 — Off-screen indicator for cursor in viewport render buffer
+// ---------------------------------------------------------------------------
+
+describe('Task 12.2 — Off-screen indicator renders for cursor in render buffer but outside visibleRanges', () => {
+  it('places a below-indicator when cursor is inside viewport but outside visibleRanges', () => {
+    const ydoc = new Y.Doc({ guid: 'render-buffer-test' });
+    const ytext = ydoc.getText('codemirror');
+    const content = 'Hello World Foo Bar';
+    ytext.insert(0, content);
+
+    const awareness = new FakeAwareness(ydoc);
+
+    // Set remote state BEFORE creating the plugin so the change listener
+    // does not fire a real dispatch during construction.
+    const remoteClient = makeClient(99, 'BufferUser');
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, 10);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, 10);
+    awareness.setRemoteClientState(99, {
+      editors: remoteClient,
+      cursor: { anchor, head },
+    });
+
+    // Build a state that includes yCollab so ySyncFacet is configured.
+    const state = EditorState.create({
+      doc: content,
+      extensions: [yCollab(ytext, null)],
+    });
+
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+    const view = new EditorView({ state, parent: container });
+
+    // Instantiate the plugin directly — one pair of containers in view.dom.
+    const plugin = new YRichCursorsPluginValue(view, awareness as never);
+
+    // Construct a mock ViewUpdate where:
+    //   viewport  covers position 10 (render buffer includes it)
+    //   visibleRanges does NOT cover position 10 (only first 5 chars visible)
+    //
+    // Before the fix (viewport):  cursor 10 ≤ vpTo 18 → widget decoration  → bottomContainer EMPTY  → test FAILS
+    // After  the fix (visibleRanges): cursor 10 > vpTo 5  → below indicator → bottomContainer has 1  → test PASSES
+    const mockViewUpdate = {
+      state: view.state,
+      view: {
+        hasFocus: false,
+        viewport: { from: 0, to: content.length },
+        visibleRanges: [{ from: 0, to: 5 }],
+        // requestMeasure is a no-op in this unit test; X positioning is
+        // checked separately in integration scenarios.
+        requestMeasure: vi.fn(),
+      },
+    } as unknown as ViewUpdate;
+
+    plugin.update(mockViewUpdate);
+
+    const bottomContainer = view.dom.querySelector('.cm-offScreenBottom');
+    expect(bottomContainer?.children.length).toBe(1);
+
+    plugin.destroy();
+    view.destroy();
+    container.remove();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Task 20.2 — Off-screen indicator scroll wiring via scrollCallbackRef
+// ---------------------------------------------------------------------------
+
+describe('Task 20.2 — Off-screen indicator click wires through scrollCallbackRef', () => {
+  it('invokes ref.current with the correct clientId when an off-screen indicator is clicked', () => {
+    const ydoc = new Y.Doc({ guid: 'click-wire-test' });
+    const ytext = ydoc.getText('codemirror');
+    // Insert content long enough to push a remote cursor off-screen
+    const content = 'Line\n'.repeat(200);
+    ytext.insert(0, content);
+
+    const awareness = new FakeAwareness(ydoc);
+
+    const scrollFn = vi.fn();
+    const scrollCallbackRef: { current: ((clientId: number) => void) | null } =
+      { current: scrollFn };
+
+    const state = EditorState.create({
+      doc: content,
+      extensions: [
+        yCollab(ytext, null),
+        yRichCursors(awareness as never, {
+          onClickIndicator: scrollCallbackRef,
+        }),
+      ],
+    });
+
+    const container = document.createElement('div');
+    container.style.height = '100px';
+    container.style.overflow = 'auto';
+    document.body.appendChild(container);
+
+    const view = new EditorView({ state, parent: container });
+
+    // Place remote cursor far from viewport (end of document)
+    const farIndex = content.length - 10;
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, farIndex);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, farIndex);
+    const remoteClient = makeClient(77, 'FarUser');
+
+    awareness.setRemoteClientState(77, {
+      editors: remoteClient,
+      cursor: { anchor, head },
+    });
+
+    // Force update so the indicator is built
+    view.dispatch({});
+
+    // Find the off-screen indicator in the bottom container
+    const bottomContainer = view.dom.querySelector('.cm-offScreenBottom');
+    const indicator = bottomContainer?.querySelector(
+      '.cm-offScreenIndicator',
+    ) as HTMLElement | null;
+    expect(indicator).not.toBeNull();
+    if (indicator == null) throw new Error('indicator not found');
+
+    // Click the indicator
+    indicator.dispatchEvent(new Event('click'));
+
+    expect(scrollFn).toHaveBeenCalledOnce();
+    expect(scrollFn).toHaveBeenCalledWith(77);
+
+    view.destroy();
+    container.remove();
+  });
+
+  it('does not throw when ref.current is null and indicator is clicked', () => {
+    const ydoc = new Y.Doc({ guid: 'null-ref-test' });
+    const ytext = ydoc.getText('codemirror');
+    const content = 'Line\n'.repeat(200);
+    ytext.insert(0, content);
+
+    const awareness = new FakeAwareness(ydoc);
+
+    const scrollCallbackRef: { current: ((clientId: number) => void) | null } =
+      { current: null };
+
+    const state = EditorState.create({
+      doc: content,
+      extensions: [
+        yCollab(ytext, null),
+        yRichCursors(awareness as never, {
+          onClickIndicator: scrollCallbackRef,
+        }),
+      ],
+    });
+
+    const container = document.createElement('div');
+    container.style.height = '100px';
+    container.style.overflow = 'auto';
+    document.body.appendChild(container);
+
+    const view = new EditorView({ state, parent: container });
+
+    const farIndex = content.length - 10;
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, farIndex);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, farIndex);
+    const remoteClient = makeClient(88, 'NullRefUser');
+
+    awareness.setRemoteClientState(88, {
+      editors: remoteClient,
+      cursor: { anchor, head },
+    });
+
+    view.dispatch({});
+
+    const bottomContainer = view.dom.querySelector('.cm-offScreenBottom');
+    const indicator = bottomContainer?.querySelector(
+      '.cm-offScreenIndicator',
+    ) as HTMLElement | null;
+
+    // Indicator must exist so the click actually fires
+    if (indicator == null) throw new Error('indicator not found');
+    // Clicking when ref.current is null must be a silent no-op (no throw)
+    indicator.dispatchEvent(new Event('click'));
+
+    view.destroy();
+    container.remove();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Task 10.2 — Activity tracking timer lifecycle
+// ---------------------------------------------------------------------------
+
+describe('Task 10.2 — Activity tracking timer lifecycle with fake timers', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('marks a remote client as active after awareness change, then inactive after 3s', () => {
+    const ydoc = new Y.Doc({ guid: 'activity-test' });
+    const ytext = ydoc.getText('codemirror');
+    ytext.insert(0, 'Hello World');
+
+    const awareness = new FakeAwareness(ydoc);
+
+    const state = EditorState.create({
+      doc: 'Hello World',
+      extensions: [yCollab(ytext, null), yRichCursors(awareness as never)],
+    });
+
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+    const view = new EditorView({ state, parent: container });
+
+    // Set a remote client with cursor in viewport
+    const anchor = Y.createRelativePositionFromTypeIndex(ytext, 0);
+    const head = Y.createRelativePositionFromTypeIndex(ytext, 3);
+    const remoteClient = makeClient(50, 'ActiveUser');
+
+    awareness.setRemoteClientState(50, {
+      editors: remoteClient,
+      cursor: { anchor, head },
+    });
+
+    // Force update
+    view.dispatch({});
+
+    // The widget should have the active class (just changed)
+    let carets = view.dom.querySelectorAll(
+      '.cm-yRichCursorFlag.cm-yRichCursorActive',
+    );
+    expect(carets.length).toBe(1);
+
+    // Advance 3 seconds — timer fires, triggering a decoration rebuild
+    vi.advanceTimersByTime(3000);
+
+    // After the timer dispatch, the widget should lose the active class
+    carets = view.dom.querySelectorAll(
+      '.cm-yRichCursorFlag.cm-yRichCursorActive',
+    );
+    expect(carets.length).toBe(0);
+
+    // A new awareness change should re-activate
+    awareness.setRemoteClientState(50, {
+      editors: remoteClient,
+      cursor: {
+        anchor: Y.createRelativePositionFromTypeIndex(ytext, 1),
+        head: Y.createRelativePositionFromTypeIndex(ytext, 5),
+      },
+    });
+    view.dispatch({});
+
+    carets = view.dom.querySelectorAll(
+      '.cm-yRichCursorFlag.cm-yRichCursorActive',
+    );
+    expect(carets.length).toBe(1);
+
+    view.destroy();
+    container.remove();
+  });
+});

+ 276 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/plugin.ts

@@ -0,0 +1,276 @@
+import { Annotation, RangeSet } from '@codemirror/state';
+import type { DecorationSet, ViewUpdate } from '@codemirror/view';
+import { Decoration, type EditorView } from '@codemirror/view';
+import { ySyncFacet } from 'y-codemirror.next';
+import type { WebsocketProvider } from 'y-websocket';
+import * as Y from 'yjs';
+
+import type { EditingClient } from '../../../../interfaces';
+import { ActivityTracker } from './activity-tracker';
+import { createOffScreenIndicator, RichCaretWidget } from './dom';
+import { broadcastLocalCursor } from './local-cursor';
+import {
+  classifyCursorPosition,
+  createViewportContext,
+} from './viewport-classification';
+
+type Awareness = WebsocketProvider['awareness'];
+
+type AwarenessState = {
+  editors?: EditingClient;
+  cursor?: {
+    anchor: Y.RelativePosition;
+    head: Y.RelativePosition;
+  };
+};
+
+type IndicatorEntry = { el: HTMLElement; headIndex: number };
+
+/** Mutable ref container for the scroll-to-remote-cursor function. */
+export type ScrollCallbackRef = {
+  current: ((clientId: number) => void) | null;
+};
+
+export const yRichCursorsAnnotation = Annotation.define<number[]>();
+
+export class YRichCursorsPluginValue {
+  decorations: DecorationSet;
+  private readonly awareness: Awareness;
+  private readonly scrollCallbackRef: ScrollCallbackRef | undefined;
+  private readonly activityTracker = new ActivityTracker();
+  private readonly changeListener: (update: {
+    added: number[];
+    updated: number[];
+    removed: number[];
+  }) => void;
+  private readonly topContainer: HTMLElement;
+  private readonly bottomContainer: HTMLElement;
+
+  constructor(
+    view: EditorView,
+    awareness: Awareness,
+    scrollCallbackRef?: ScrollCallbackRef,
+  ) {
+    this.awareness = awareness;
+    this.scrollCallbackRef = scrollCallbackRef;
+    this.decorations = RangeSet.of([]);
+
+    // Create off-screen containers
+    this.topContainer = document.createElement('div');
+    this.topContainer.className = 'cm-offScreenTop';
+    this.bottomContainer = document.createElement('div');
+    this.bottomContainer.className = 'cm-offScreenBottom';
+    view.dom.appendChild(this.topContainer);
+    view.dom.appendChild(this.bottomContainer);
+
+    this.changeListener = ({ added, updated, removed }) => {
+      const clients = added.concat(updated).concat(removed);
+      const remoteClients = clients.filter(
+        (id) => id !== awareness.doc.clientID,
+      );
+      if (remoteClients.length === 0) return;
+
+      const now = Date.now();
+      for (const clientId of remoteClients) {
+        if (!removed.includes(clientId)) {
+          this.activityTracker.recordActivity(clientId, now, () => {
+            view.dispatch({
+              annotations: [yRichCursorsAnnotation.of([])],
+            });
+          });
+        } else {
+          this.activityTracker.removeClient(clientId);
+        }
+      }
+
+      view.dispatch({
+        annotations: [yRichCursorsAnnotation.of([])],
+      });
+    };
+    this.awareness.on('change', this.changeListener);
+  }
+
+  destroy(): void {
+    this.awareness.off('change', this.changeListener);
+    this.activityTracker.destroy();
+    this.topContainer.remove();
+    this.bottomContainer.remove();
+  }
+
+  update(viewUpdate: ViewUpdate): void {
+    const conf = viewUpdate.state.facet(ySyncFacet);
+    const ytext = conf?.ytext;
+    const ydoc = ytext?.doc as Y.Doc | undefined;
+
+    // Broadcast local cursor position
+    if (ytext != null) {
+      broadcastLocalCursor(viewUpdate, this.awareness, ytext);
+    }
+
+    // Rebuild remote cursor decorations
+    if (ytext == null || ydoc == null) {
+      this.decorations = RangeSet.of([]);
+      this.topContainer.replaceChildren();
+      this.bottomContainer.replaceChildren();
+      return;
+    }
+
+    const { decorations, aboveIndicators, belowIndicators } =
+      this.buildRemoteCursors(viewUpdate, ytext, ydoc);
+
+    this.decorations = Decoration.set(decorations, true);
+    this.topContainer.replaceChildren(...aboveIndicators.map(({ el }) => el));
+    this.bottomContainer.replaceChildren(
+      ...belowIndicators.map(({ el }) => el),
+    );
+
+    this.positionIndicatorsHorizontally(viewUpdate, [
+      ...aboveIndicators,
+      ...belowIndicators,
+    ]);
+  }
+
+  /** Iterates remote awareness states and builds decorations / off-screen indicators. */
+  private buildRemoteCursors(
+    viewUpdate: ViewUpdate,
+    ytext: Y.Text,
+    ydoc: Y.Doc,
+  ): {
+    decorations: { from: number; to: number; value: Decoration }[];
+    aboveIndicators: IndicatorEntry[];
+    belowIndicators: IndicatorEntry[];
+  } {
+    const decorations: { from: number; to: number; value: Decoration }[] = [];
+    const aboveIndicators: IndicatorEntry[] = [];
+    const belowIndicators: IndicatorEntry[] = [];
+    const localClientId = this.awareness.doc.clientID;
+    const ctx = createViewportContext(viewUpdate.view);
+    const now = Date.now();
+
+    // Build the click handler once (reads ref.current lazily at call time)
+    const onClickIndicator =
+      this.scrollCallbackRef != null
+        ? (id: number) => this.scrollCallbackRef?.current?.(id)
+        : undefined;
+
+    this.awareness.getStates().forEach((rawState, clientId) => {
+      if (clientId === localClientId) return;
+
+      const state = rawState as AwarenessState;
+      const editors = state.editors;
+      const cursor = state.cursor;
+
+      if (editors == null || cursor?.anchor == null || cursor?.head == null) {
+        return;
+      }
+
+      const anchor = Y.createAbsolutePositionFromRelativePosition(
+        cursor.anchor,
+        ydoc,
+      );
+      const head = Y.createAbsolutePositionFromRelativePosition(
+        cursor.head,
+        ydoc,
+      );
+
+      if (
+        anchor == null ||
+        head == null ||
+        anchor.type !== ytext ||
+        head.type !== ytext
+      ) {
+        return;
+      }
+
+      const headIndex = head.index;
+      const isActive = this.activityTracker.isActive(clientId, now);
+      const classification = classifyCursorPosition(
+        ctx,
+        viewUpdate.view,
+        headIndex,
+      );
+
+      if (classification !== 'in-viewport') {
+        const target =
+          classification === 'above' ? aboveIndicators : belowIndicators;
+        target.push({
+          el: createOffScreenIndicator({
+            direction: classification,
+            clientId,
+            color: editors.color,
+            name: editors.name,
+            imageUrlCached: editors.imageUrlCached,
+            isActive,
+            onClick: onClickIndicator,
+          }),
+          headIndex,
+        });
+        return;
+      }
+
+      // In-viewport: render decorations
+      const start = Math.min(anchor.index, headIndex);
+      const end = Math.max(anchor.index, headIndex);
+
+      if (start !== end) {
+        decorations.push({
+          from: start,
+          to: end,
+          value: Decoration.mark({
+            attributes: { style: `background-color: ${editors.colorLight}` },
+            class: 'cm-ySelection',
+          }),
+        });
+      }
+
+      decorations.push({
+        from: headIndex,
+        to: headIndex,
+        value: Decoration.widget({
+          side: headIndex - anchor.index > 0 ? -1 : 1,
+          block: false,
+          widget: new RichCaretWidget({
+            color: editors.color,
+            name: editors.name,
+            imageUrlCached: editors.imageUrlCached,
+            isActive,
+          }),
+        }),
+      });
+    });
+
+    return { decorations, aboveIndicators, belowIndicators };
+  }
+
+  /** Defers horizontal positioning to CodeMirror's measure phase. */
+  private positionIndicatorsHorizontally(
+    viewUpdate: ViewUpdate,
+    indicators: IndicatorEntry[],
+  ): void {
+    if (indicators.length === 0) return;
+
+    viewUpdate.view.requestMeasure({
+      read: (view) => {
+        const editorLeft = view.dom.getBoundingClientRect().left;
+        return indicators.map(({ headIndex }) => {
+          const coords = view.coordsAtPos(headIndex, 1);
+          if (coords != null) {
+            return coords.left - editorLeft;
+          }
+          // Fallback for virtualised positions (outside CodeMirror's viewport)
+          const line = view.state.doc.lineAt(headIndex);
+          const col = headIndex - line.from;
+          const contentLeft =
+            view.contentDOM.getBoundingClientRect().left - editorLeft;
+          return contentLeft + col * view.defaultCharacterWidth;
+        });
+      },
+      write: (positions) => {
+        indicators.forEach(({ el }, i) => {
+          el.style.left = `${positions[i]}px`;
+          el.style.transform = 'translateX(-50%)';
+        });
+      },
+    });
+  }
+}

+ 95 - 0
packages/editor/src/client/services-internal/extensions/y-rich-cursors/viewport-classification.ts

@@ -0,0 +1,95 @@
+import type { EditorView } from '@codemirror/view';
+
+export type CursorVisibility = 'above' | 'below' | 'in-viewport';
+
+/**
+ * Pre-computed viewport context, created once per update() call.
+ *
+ * Two classification strategies:
+ * - "ranged": visibleRanges is a true sub-range of viewport
+ *   (fixed-height editor, or tests with styled heights)
+ * - "coords": visibleRanges == viewport, page handles scrolling
+ *   (GROWI's page-scroll production setup).
+ *   Also covers the degenerate case (scrollDOM height == 0 in jsdom)
+ *   where screenVisibleTop == screenVisibleBottom, causing cursors
+ *   with positive lineBlock.top to be classified as "below".
+ */
+export type ViewportContext =
+  | { readonly kind: 'ranged'; readonly vpFrom: number; readonly vpTo: number }
+  | {
+      readonly kind: 'coords';
+      readonly scrollDOMTop: number;
+      readonly scrollTop: number;
+      readonly screenVisibleTop: number;
+      readonly screenVisibleBottom: number;
+    };
+
+/**
+ * Determines the viewport classification mode from the current editor state.
+ *
+ * `getBoundingClientRect()` is a raw DOM call (not a CodeMirror layout read)
+ * so it is safe to call during `update()`. `lineBlockAt()` (used later in
+ * `classifyCursorPosition`) reads the stored height map and is also safe.
+ */
+export function createViewportContext(view: EditorView): ViewportContext {
+  const { visibleRanges, viewport } = view;
+  const { from: viewportFrom, to: viewportTo } = viewport;
+
+  const hasVisibleRanges = visibleRanges.length > 0;
+
+  // rangedMode: visibleRanges is a meaningful sub-range of viewport.
+  // Requires the visible area to be non-empty (to > from) so that a 0-height
+  // editor (jsdom degenerate) doesn't accidentally classify every cursor as
+  // off-screen via a vpTo of 0.
+  const rangedMode =
+    hasVisibleRanges &&
+    visibleRanges[visibleRanges.length - 1].to > visibleRanges[0].from &&
+    (visibleRanges[0].from > viewportFrom ||
+      visibleRanges[visibleRanges.length - 1].to < viewportTo);
+
+  if (rangedMode) {
+    return {
+      kind: 'ranged',
+      vpFrom: visibleRanges[0].from,
+      vpTo: visibleRanges[visibleRanges.length - 1].to,
+    };
+  }
+
+  // coords mode: compare screen Y of cursor against the editor's visible rect.
+  // When scrollDOMRect.height == 0 (jsdom), screenVisibleTop == screenVisibleBottom,
+  // so cursors with positive lineBlock.top are classified as "below" by the
+  // natural comparison in classifyCursorPosition.
+  const scrollDOMRect = view.scrollDOM.getBoundingClientRect();
+  return {
+    kind: 'coords',
+    scrollDOMTop: scrollDOMRect.top,
+    scrollTop: view.scrollDOM.scrollTop,
+    screenVisibleTop: Math.max(scrollDOMRect.top, 0),
+    screenVisibleBottom: Math.min(scrollDOMRect.bottom, window.innerHeight),
+  };
+}
+
+/**
+ * Classifies a remote cursor as above, below, or within the visible viewport.
+ */
+export function classifyCursorPosition(
+  ctx: ViewportContext,
+  view: EditorView,
+  headIndex: number,
+): CursorVisibility {
+  switch (ctx.kind) {
+    case 'ranged': {
+      if (headIndex < ctx.vpFrom) return 'above';
+      if (headIndex > ctx.vpTo) return 'below';
+      return 'in-viewport';
+    }
+    case 'coords': {
+      const lineBlock = view.lineBlockAt(headIndex);
+      const cursorTop = ctx.scrollDOMTop + lineBlock.top - ctx.scrollTop;
+      const cursorBottom = ctx.scrollDOMTop + lineBlock.bottom - ctx.scrollTop;
+      if (cursorBottom < ctx.screenVisibleTop) return 'above';
+      if (cursorTop > ctx.screenVisibleBottom) return 'below';
+      return 'in-viewport';
+    }
+  }
+}

+ 149 - 0
packages/editor/src/client/stores/use-collaborative-editor-mode-scroll.spec.ts

@@ -0,0 +1,149 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import * as Y from 'yjs';
+
+import type { EditingClient } from '../../interfaces';
+
+/**
+ * Integration tests for the scroll-to-remote-cursor logic extracted from
+ * use-collaborative-editor-mode.ts.
+ *
+ * Covers:
+ * - Task 13.2: Scroll function creation and registration
+ * - Task 16.2: Integration test for scroll function
+ * - Requirements: 6.1, 6.2, 6.3
+ */
+
+// ---------------------------------------------------------------------------
+// Minimal stubs
+// ---------------------------------------------------------------------------
+
+type AwarenessState = {
+  editors?: EditingClient;
+  cursor?: {
+    anchor: Y.RelativePosition;
+    head: Y.RelativePosition;
+  };
+};
+
+type FakeEditorView = {
+  dispatch: ReturnType<typeof vi.fn>;
+  scrollDOM: { style: { scrollBehavior: string } };
+};
+
+// Import the pure function after it is implemented.
+// Using a dynamic import wrapper so this test file can compile before
+// the implementation exists (RED phase). We test the extracted pure function.
+import { createScrollToRemoteCursorFn } from './use-collaborative-editor-mode';
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('createScrollToRemoteCursorFn — Task 16.2', () => {
+  let ydoc: Y.Doc;
+  let ytext: Y.Text;
+  let states: Map<number, AwarenessState>;
+  let awareness: {
+    getStates: () => Map<number, AwarenessState>;
+    doc: Y.Doc;
+  };
+  let view: FakeEditorView;
+
+  beforeEach(() => {
+    ydoc = new Y.Doc();
+    ytext = ydoc.getText('codemirror');
+    ytext.insert(0, 'Hello World');
+
+    states = new Map<number, AwarenessState>();
+    awareness = {
+      getStates: () => states,
+      doc: ydoc,
+    };
+
+    view = { dispatch: vi.fn(), scrollDOM: { style: { scrollBehavior: '' } } };
+  });
+
+  describe('Task 16.2 — configuration callback receives a scroll function', () => {
+    it('returns a function (not null/undefined)', () => {
+      const scrollFn = createScrollToRemoteCursorFn(
+        awareness,
+        ydoc,
+        () => view as never,
+      );
+      expect(typeof scrollFn).toBe('function');
+    });
+  });
+
+  describe('Task 16.2 — calling scrollFn with a valid remote client dispatches scrollIntoView', () => {
+    it('dispatches an effect when cursor.head resolves to a valid position', () => {
+      const remoteClientId = 42;
+      const headIndex = 5;
+
+      const head = Y.createRelativePositionFromTypeIndex(ytext, headIndex);
+      const anchor = Y.createRelativePositionFromTypeIndex(ytext, 0);
+      states.set(remoteClientId, { cursor: { anchor, head } });
+
+      const scrollFn = createScrollToRemoteCursorFn(
+        awareness,
+        ydoc,
+        () => view as never,
+      );
+      scrollFn(remoteClientId);
+
+      expect(view.dispatch).toHaveBeenCalledOnce();
+      const callArg = view.dispatch.mock.calls[0][0];
+      expect(callArg).toHaveProperty('effects');
+    });
+  });
+
+  describe('Task 16.2 / Req 6.3 — no-op when cursor is absent', () => {
+    it('does not dispatch when the client has no cursor in awareness', () => {
+      const remoteClientId = 42;
+      // Client exists in awareness but has no cursor
+      states.set(remoteClientId, {
+        editors: {
+          clientId: remoteClientId,
+          name: 'Bob',
+          color: '#0000ff',
+          colorLight: '#0000ff33',
+        },
+      });
+
+      const scrollFn = createScrollToRemoteCursorFn(
+        awareness,
+        ydoc,
+        () => view as never,
+      );
+      scrollFn(remoteClientId);
+
+      expect(view.dispatch).not.toHaveBeenCalled();
+    });
+
+    it('does not dispatch when the client is completely absent from awareness', () => {
+      const scrollFn = createScrollToRemoteCursorFn(
+        awareness,
+        ydoc,
+        () => view as never,
+      );
+      scrollFn(9999);
+
+      expect(view.dispatch).not.toHaveBeenCalled();
+    });
+
+    it('does not dispatch when the editor view is not mounted', () => {
+      const remoteClientId = 42;
+      const head = Y.createRelativePositionFromTypeIndex(ytext, 3);
+      const anchor = Y.createRelativePositionFromTypeIndex(ytext, 0);
+      states.set(remoteClientId, { cursor: { anchor, head } });
+
+      const scrollFn = createScrollToRemoteCursorFn(
+        awareness,
+        ydoc,
+        () => undefined,
+      );
+      scrollFn(remoteClientId);
+
+      expect(view.dispatch).not.toHaveBeenCalled();
+    });
+  });
+});

+ 142 - 0
packages/editor/src/client/stores/use-collaborative-editor-mode.spec.ts

@@ -0,0 +1,142 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import type { EditingClient } from '../../interfaces';
+
+/**
+ * Unit tests for awareness state handling logic extracted from
+ * use-collaborative-editor-mode.ts.
+ *
+ * These tests cover:
+ * - Task 1.1: undefined awareness entries are filtered before onEditorsUpdated is called
+ * - Task 1.2: awareness.getStates().delete() is NOT called for removed clients
+ * - Task 4.1: Requirements 1.1, 1.2, 1.4
+ */
+
+// ---------------------------------------------------------------------------
+// Helpers — minimal stubs that replicate the logic under test
+// ---------------------------------------------------------------------------
+
+type AwarenessState = { editors?: EditingClient };
+
+/** Replicates the FIXED emitEditorList logic */
+function emitEditorList(
+  states: Map<number, AwarenessState>,
+  onEditorsUpdated: (list: EditingClient[]) => void,
+): void {
+  const clientList = Array.from(states.values())
+    .map((v) => v.editors)
+    .filter((v): v is EditingClient => v != null);
+  onEditorsUpdated(clientList);
+}
+
+/** Replicates the FIXED updateAwarenessHandler logic */
+function updateAwarenessHandler(
+  _update: { added: number[]; updated: number[]; removed: number[] },
+  awareness: { getStates: () => Map<number, AwarenessState> },
+  onEditorsUpdated: (list: EditingClient[]) => void,
+): void {
+  // Task 1.2: No .delete() call for removed client IDs
+  emitEditorList(awareness.getStates(), onEditorsUpdated);
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const validClient: EditingClient = {
+  clientId: 1,
+  name: 'Alice',
+  color: '#ff0000',
+  colorLight: '#ff000033',
+};
+
+describe('emitEditorList', () => {
+  describe('Task 1.1 / 1.4 — filter undefined awareness entries', () => {
+    it('passes only valid EditingClient entries to onEditorsUpdated', () => {
+      const states = new Map<number, AwarenessState>([
+        [1, { editors: validClient }],
+        [2, {}], // no editors field
+        [3, { editors: undefined }], // editors explicitly undefined
+      ]);
+      const onEditorsUpdated = vi.fn();
+
+      emitEditorList(states, onEditorsUpdated);
+
+      expect(onEditorsUpdated).toHaveBeenCalledOnce();
+      expect(onEditorsUpdated).toHaveBeenCalledWith([validClient]);
+    });
+
+    it('calls onEditorsUpdated with an empty array when no state has editors', () => {
+      const states = new Map<number, AwarenessState>([
+        [1, {}],
+        [2, { editors: undefined }],
+      ]);
+      const onEditorsUpdated = vi.fn();
+
+      emitEditorList(states, onEditorsUpdated);
+
+      expect(onEditorsUpdated).toHaveBeenCalledWith([]);
+    });
+
+    it('passes all valid entries when every state has editors', () => {
+      const anotherClient: EditingClient = {
+        clientId: 2,
+        name: 'Bob',
+        color: '#0000ff',
+        colorLight: '#0000ff33',
+      };
+      const states = new Map<number, AwarenessState>([
+        [1, { editors: validClient }],
+        [2, { editors: anotherClient }],
+      ]);
+      const onEditorsUpdated = vi.fn();
+
+      emitEditorList(states, onEditorsUpdated);
+
+      expect(onEditorsUpdated).toHaveBeenCalledWith([
+        validClient,
+        anotherClient,
+      ]);
+    });
+  });
+});
+
+describe('updateAwarenessHandler', () => {
+  describe('Task 1.2 — no direct mutation of awareness.getStates()', () => {
+    it('does NOT call .delete() on the awareness states map for removed clients', () => {
+      const deleteSpy = vi.fn();
+      const states = new Map<number, AwarenessState>([
+        [1, { editors: validClient }],
+      ]);
+      states.delete = deleteSpy;
+
+      const awareness = { getStates: () => states };
+      const onEditorsUpdated = vi.fn();
+
+      updateAwarenessHandler(
+        { added: [], updated: [], removed: [99] },
+        awareness,
+        onEditorsUpdated,
+      );
+
+      expect(deleteSpy).not.toHaveBeenCalled();
+    });
+
+    it('still calls onEditorsUpdated after a removed event', () => {
+      const states = new Map<number, AwarenessState>([
+        [1, { editors: validClient }],
+      ]);
+      const awareness = { getStates: () => states };
+      const onEditorsUpdated = vi.fn();
+
+      updateAwarenessHandler(
+        { added: [], updated: [], removed: [99] },
+        awareness,
+        onEditorsUpdated,
+      );
+
+      expect(onEditorsUpdated).toHaveBeenCalledOnce();
+      expect(onEditorsUpdated).toHaveBeenCalledWith([validClient]);
+    });
+  });
+});

+ 117 - 15
packages/editor/src/client/stores/use-collaborative-editor-mode.ts

@@ -1,5 +1,5 @@
-import { useEffect, useState } from 'react';
-import { keymap } from '@codemirror/view';
+import { useEffect, useRef, useState } from 'react';
+import { EditorView, keymap } from '@codemirror/view';
 import { YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
 import { YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next';
 import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next';
@@ -9,13 +9,77 @@ import * as Y from 'yjs';
 import { userColor } from '../../consts';
 import { userColor } from '../../consts';
 import type { EditingClient } from '../../interfaces';
 import type { EditingClient } from '../../interfaces';
 import type { UseCodeMirrorEditor } from '../services';
 import type { UseCodeMirrorEditor } from '../services';
+import { yRichCursors } from '../services-internal/extensions/y-rich-cursors';
 import { useSecondaryYdocs } from './use-secondary-ydocs';
 import { useSecondaryYdocs } from './use-secondary-ydocs';
 
 
+type Awareness = WebsocketProvider['awareness'];
+
+type AwarenessState = {
+  editors?: EditingClient;
+  cursor?: {
+    anchor: Y.RelativePosition;
+    head: Y.RelativePosition;
+  };
+};
+
 type Configuration = {
 type Configuration = {
   user?: IUserHasId;
   user?: IUserHasId;
   pageId?: string;
   pageId?: string;
   reviewMode?: boolean;
   reviewMode?: boolean;
   onEditorsUpdated?: (clientList: EditingClient[]) => void;
   onEditorsUpdated?: (clientList: EditingClient[]) => void;
+  onScrollToRemoteCursorReady?: (
+    scrollFn: ((clientId: number) => void) | null,
+  ) => void;
+};
+
+/**
+ * Pure function that creates a scroll-to-remote-cursor callback.
+ * Extracted for unit testability.
+ *
+ * @param awareness - Yjs awareness instance for reading remote cursor positions
+ * @param activeDoc - The active Y.Doc used to resolve relative positions
+ * @param getView - Lazy accessor for the CodeMirror EditorView
+ */
+export const createScrollToRemoteCursorFn = (
+  awareness: Pick<Awareness, 'getStates'>,
+  activeDoc: Y.Doc,
+  getView: () => EditorView | undefined,
+): ((clientId: number) => void) => {
+  return (clientId: number) => {
+    const state = awareness.getStates().get(clientId) as
+      | AwarenessState
+      | undefined;
+    const cursor = state?.cursor;
+    if (cursor?.head == null) return;
+
+    const pos = Y.createAbsolutePositionFromRelativePosition(
+      cursor.head,
+      activeDoc,
+    );
+    if (pos == null) return;
+
+    const view = getView();
+    if (view == null) return;
+
+    const { scrollDOM } = view;
+    const prevBehavior = scrollDOM.style.scrollBehavior;
+    scrollDOM.style.scrollBehavior = 'smooth';
+
+    // First scroll — uses estimated heights for distant lines, so may land
+    // at an approximate position.
+    view.dispatch({
+      effects: EditorView.scrollIntoView(pos.index, { y: 'center' }),
+    });
+
+    // Second scroll — after the view stabilizes, measured heights are
+    // available and the scroll lands at the correct position.
+    setTimeout(() => {
+      scrollDOM.style.scrollBehavior = prevBehavior;
+      view.dispatch({
+        effects: EditorView.scrollIntoView(pos.index, { y: 'center' }),
+      });
+    }, 500);
+  };
 };
 };
 
 
 export const useCollaborativeEditorMode = (
 export const useCollaborativeEditorMode = (
@@ -23,7 +87,17 @@ export const useCollaborativeEditorMode = (
   codeMirrorEditor?: UseCodeMirrorEditor,
   codeMirrorEditor?: UseCodeMirrorEditor,
   configuration?: Configuration,
   configuration?: Configuration,
 ): void => {
 ): void => {
-  const { user, pageId, onEditorsUpdated, reviewMode } = configuration ?? {};
+  const {
+    user,
+    pageId,
+    onEditorsUpdated,
+    reviewMode,
+    onScrollToRemoteCursorReady,
+  } = configuration ?? {};
+
+  // Stable mutable ref passed to yRichCursors so off-screen indicator clicks
+  // can invoke the scroll function without recreating the extension.
+  const scrollCallbackRef = useRef<((clientId: number) => void) | null>(null);
 
 
   const { primaryDoc, activeDoc } =
   const { primaryDoc, activeDoc } =
     useSecondaryYdocs(isEnabled, {
     useSecondaryYdocs(isEnabled, {
@@ -69,13 +143,10 @@ export const useCollaborativeEditorMode = (
 
 
     const emitEditorList = () => {
     const emitEditorList = () => {
       if (onEditorsUpdated == null) return;
       if (onEditorsUpdated == null) return;
-      const clientList: EditingClient[] = Array.from(
-        awareness.getStates().values(),
-        (value) => value.editors,
-      );
-      if (Array.isArray(clientList)) {
-        onEditorsUpdated(clientList);
-      }
+      const clientList = Array.from(awareness.getStates().values())
+        .map((value) => value.editors)
+        .filter((v): v is EditingClient => v != null);
+      onEditorsUpdated(clientList);
     };
     };
 
 
     const providerSyncHandler = (isSync: boolean) => {
     const providerSyncHandler = (isSync: boolean) => {
@@ -84,14 +155,11 @@ export const useCollaborativeEditorMode = (
 
 
     _provider.on('sync', providerSyncHandler);
     _provider.on('sync', providerSyncHandler);
 
 
-    const updateAwarenessHandler = (update: {
+    const updateAwarenessHandler = (_update: {
       added: number[];
       added: number[];
       updated: number[];
       updated: number[];
       removed: number[];
       removed: number[];
     }) => {
     }) => {
-      for (const clientId of update.removed) {
-        awareness.getStates().delete(clientId);
-      }
       emitEditorList();
       emitEditorList();
     };
     };
 
 
@@ -129,7 +197,8 @@ export const useCollaborativeEditorMode = (
 
 
     const extensions = [
     const extensions = [
       keymap.of(yUndoManagerKeymap),
       keymap.of(yUndoManagerKeymap),
-      yCollab(activeText, provider.awareness, { undoManager }),
+      yCollab(activeText, null, { undoManager }),
+      yRichCursors(provider.awareness, { onClickIndicator: scrollCallbackRef }),
     ];
     ];
 
 
     const cleanupFunctions = extensions.map((ext) =>
     const cleanupFunctions = extensions.map((ext) =>
@@ -143,4 +212,37 @@ export const useCollaborativeEditorMode = (
       codeMirrorEditor.initDoc('');
       codeMirrorEditor.initDoc('');
     };
     };
   }, [isEnabled, codeMirrorEditor, provider, primaryDoc, activeDoc]);
   }, [isEnabled, codeMirrorEditor, provider, primaryDoc, activeDoc]);
+
+  // Setup scroll-to-remote-cursor callback
+  useEffect(() => {
+    if (
+      !isEnabled ||
+      provider == null ||
+      activeDoc == null ||
+      codeMirrorEditor == null
+    ) {
+      onScrollToRemoteCursorReady?.(null);
+      return;
+    }
+
+    const scrollFn = createScrollToRemoteCursorFn(
+      provider.awareness,
+      activeDoc,
+      () => codeMirrorEditor.view,
+    );
+
+    scrollCallbackRef.current = scrollFn;
+    onScrollToRemoteCursorReady?.(scrollFn);
+
+    return () => {
+      scrollCallbackRef.current = null;
+      onScrollToRemoteCursorReady?.(null);
+    };
+  }, [
+    isEnabled,
+    provider,
+    activeDoc,
+    codeMirrorEditor,
+    onScrollToRemoteCursorReady,
+  ]);
 };
 };

+ 1 - 0
packages/editor/tsconfig.json

@@ -20,6 +20,7 @@
     "noUnusedLocals": true,
     "noUnusedLocals": true,
     "noUnusedParameters": true,
     "noUnusedParameters": true,
     "noFallthroughCasesInSwitch": true,
     "noFallthroughCasesInSwitch": true,
+    "types": ["vitest/globals"],
 
 
     "paths": {
     "paths": {
       "/*": ["./public/*"]
       "/*": ["./public/*"]

+ 12 - 0
packages/editor/vitest.config.ts

@@ -0,0 +1,12 @@
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  plugins: [tsconfigPaths()],
+  test: {
+    clearMocks: true,
+    globals: true,
+    environment: 'happy-dom',
+    include: ['**/*.{spec,integ}.{ts,tsx}'],
+  },
+});

+ 6 - 0
packages/ui/src/@types/scss.d.ts

@@ -0,0 +1,6 @@
+// prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
+declare module '*.module.scss' {
+  const classes: Record<string, string>;
+  // biome-ignore lint/style/noDefaultExport: CSS Modules require default export
+  export default classes;
+}

+ 136 - 0
packages/ui/src/components/UserPicture.spec.tsx

@@ -0,0 +1,136 @@
+import { render, screen } from '@testing-library/react';
+
+/**
+ * Unit tests for the refactored UserPicture component.
+ *
+ * Task 20.3 — UserPicture tooltip refactoring
+ * Requirements: 7.1, 7.3, 7.4, 7.5
+ *
+ * Key structural invariant:
+ *   Before refactor: withTooltip HOC returns a Fragment
+ *     → two sibling elements in the container (<span> + <Tooltip>)
+ *   After refactor: tooltip is a child of the root <span>
+ *     → exactly one child element in the container
+ */
+
+// ---------------------------------------------------------------------------
+// Module mocks
+// ---------------------------------------------------------------------------
+
+// Mock next/dynamic to return a synchronous stub component.
+// This makes the dynamically-loaded UncontrolledTooltip testable without
+// async chunk loading or portals in the test environment.
+vi.mock('next/dynamic', () => ({
+  default: (_importFn: () => Promise<unknown>, _opts?: unknown) => {
+    // The stub renders its children inline so we can inspect tooltip content.
+    const Stub = ({ children }: { children?: React.ReactNode }) => (
+      <span data-testid="mock-tooltip">{children}</span>
+    );
+    return Stub;
+  },
+}));
+
+vi.mock('next/router', () => ({
+  useRouter: () => ({ push: vi.fn() }),
+}));
+
+// ---------------------------------------------------------------------------
+// Component under test (imported AFTER mocks are in place)
+// ---------------------------------------------------------------------------
+
+import { UserPicture } from './UserPicture';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const makeUser = (overrides?: object) => ({
+  name: 'Alice',
+  username: 'alice',
+  imageUrlCached: '/avatar.png',
+  ...overrides,
+});
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('UserPicture — Task 20.3 (tooltip refactoring, req 7.1, 7.3, 7.4)', () => {
+  describe('Req 7.3 / 7.4 — single root element (no Fragment)', () => {
+    it('renders exactly one child element in the container when noTooltip is not set', () => {
+      const { container } = render(<UserPicture user={makeUser()} noLink />);
+
+      // After HOC removal, exactly ONE root child (the <span>).
+      // Before the fix two siblings lived at this level (span + tooltip HOC fragment).
+      expect(container.children).toHaveLength(1);
+      expect(container.firstElementChild?.tagName).toBe('SPAN');
+    });
+
+    it('renders exactly one child element in the container when noTooltip is true', () => {
+      const { container } = render(
+        <UserPicture user={makeUser()} noLink noTooltip />,
+      );
+
+      expect(container.children).toHaveLength(1);
+      expect(container.firstElementChild?.tagName).toBe('SPAN');
+    });
+  });
+
+  describe('Req 7.4 — image rendered inside the root span', () => {
+    it('renders the avatar image', () => {
+      render(<UserPicture user={makeUser()} noLink />);
+
+      // screen.getByRole throws if not found — implicit assertion of presence
+      const img = screen.getByRole('img') as HTMLImageElement;
+      expect(img.src).toContain('/avatar.png');
+    });
+
+    it('the image is nested inside the root span', () => {
+      const { container } = render(<UserPicture user={makeUser()} noLink />);
+
+      const rootSpan = container.firstElementChild;
+      expect(rootSpan?.querySelector('img')).not.toBeNull();
+    });
+  });
+
+  describe('Req 7.1 — tooltip renders when noTooltip is absent', () => {
+    it('renders the tooltip stub when noTooltip is not set and user has a name', () => {
+      render(<UserPicture user={makeUser()} noLink />);
+
+      expect(screen.queryByTestId('mock-tooltip')).not.toBeNull();
+    });
+
+    it('does not render the tooltip stub when noTooltip is true', () => {
+      render(<UserPicture user={makeUser()} noLink noTooltip />);
+
+      expect(screen.queryByTestId('mock-tooltip')).toBeNull();
+    });
+
+    it('includes @username in tooltip content when username is available', () => {
+      render(<UserPicture user={makeUser({ username: 'alice' })} noLink />);
+
+      const tooltip = screen.queryByTestId('mock-tooltip');
+      expect(tooltip?.textContent).toContain('@alice');
+    });
+
+    it('includes the display name in tooltip content', () => {
+      render(<UserPicture user={makeUser({ name: 'Alice' })} noLink />);
+
+      const tooltip = screen.queryByTestId('mock-tooltip');
+      expect(tooltip?.textContent).toContain('Alice');
+    });
+  });
+
+  describe('Req 7.3 — tooltip is nested inside root span (portal child, not sibling)', () => {
+    it('tooltip stub is a descendant of the root span (not a sibling)', () => {
+      const { container } = render(<UserPicture user={makeUser()} noLink />);
+
+      // Single root child; tooltip stub is inside it
+      expect(container.children).toHaveLength(1);
+      const rootSpan = container.firstElementChild;
+      expect(
+        rootSpan?.querySelector('[data-testid="mock-tooltip"]'),
+      ).not.toBeNull();
+    });
+  });
+});

+ 48 - 45
packages/ui/src/components/UserPicture.tsx

@@ -82,42 +82,6 @@ const UserPictureRootWithLink = forwardRef<
   );
   );
 });
 });
 
 
-// wrapper with Tooltip
-const withTooltip =
-  <P extends BaseUserPictureRootProps>(
-    UserPictureSpanElm: React.ForwardRefExoticComponent<
-      P & React.RefAttributes<HTMLSpanElement>
-    >,
-  ) =>
-  (props: P): JSX.Element => {
-    const { displayName, size } = props;
-    const username = 'username' in props ? props.username : undefined;
-
-    const tooltipClassName = `${moduleTooltipClass} user-picture-tooltip-${size ?? 'md'}`;
-    const userPictureRef = useRef<HTMLSpanElement>(null);
-
-    return (
-      <>
-        <UserPictureSpanElm ref={userPictureRef} {...props} />
-        <UncontrolledTooltip
-          placement="bottom"
-          target={userPictureRef}
-          popperClassName={tooltipClassName}
-          delay={0}
-          fade={false}
-        >
-          {username ? (
-            <>
-              {`@${username}`}
-              <br />
-            </>
-          ) : null}
-          {displayName}
-        </UncontrolledTooltip>
-      </>
-    );
-  };
-
 /**
 /**
  * type guard to determine whether the specified object is IUser
  * type guard to determine whether the specified object is IUser
  */
  */
@@ -181,20 +145,59 @@ export const UserPicture = memo((userProps: Props): JSX.Element => {
     .filter(Boolean)
     .filter(Boolean)
     .join(' ');
     .join(' ');
 
 
+  // ref is always called unconditionally to satisfy React hooks rules.
+  // Passed to the root element so UncontrolledTooltip can target it.
+  const rootRef = useRef<HTMLSpanElement>(null);
+
+  const tooltipClassName = `${moduleTooltipClass} user-picture-tooltip-${size ?? 'md'}`;
+
   // biome-ignore lint/performance/noImgElement: ignore
   // biome-ignore lint/performance/noImgElement: ignore
   const imgElement = <img src={src} alt={displayName} className={className} />;
   const imgElement = <img src={src} alt={displayName} className={className} />;
-  const baseProps = { displayName, size, children: imgElement };
+
+  const children = (
+    <>
+      {imgElement}
+      {showTooltip && (
+        <UncontrolledTooltip
+          placement="bottom"
+          target={rootRef}
+          popperClassName={tooltipClassName}
+          delay={0}
+          fade={false}
+        >
+          {username ? (
+            <>
+              {`@${username}`}
+              <br />
+            </>
+          ) : null}
+          {displayName}
+        </UncontrolledTooltip>
+      )}
+    </>
+  );
 
 
   if (username == null || noLink) {
   if (username == null || noLink) {
-    const Component = showTooltip
-      ? withTooltip(UserPictureRootWithoutLink)
-      : UserPictureRootWithoutLink;
-    return <Component {...baseProps} />;
+    return (
+      <UserPictureRootWithoutLink
+        ref={rootRef}
+        displayName={displayName}
+        size={size}
+      >
+        {children}
+      </UserPictureRootWithoutLink>
+    );
   }
   }
 
 
-  const Component = showTooltip
-    ? withTooltip(UserPictureRootWithLink)
-    : UserPictureRootWithLink;
-  return <Component {...baseProps} username={username} />;
+  return (
+    <UserPictureRootWithLink
+      ref={rootRef}
+      displayName={displayName}
+      size={size}
+      username={username}
+    >
+      {children}
+    </UserPictureRootWithLink>
+  );
 });
 });
 UserPicture.displayName = 'UserPicture';
 UserPicture.displayName = 'UserPicture';

+ 3 - 0
packages/ui/test/setup.ts

@@ -0,0 +1,3 @@
+// Test setup for packages/ui
+// @testing-library/jest-dom is not a devDependency here;
+// tests use standard vitest matchers only.

+ 1 - 0
packages/ui/tsconfig.json

@@ -3,6 +3,7 @@
   "extends": "../../tsconfig.base.json",
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
   "compilerOptions": {
     "jsx": "react-jsx",
     "jsx": "react-jsx",
+    "types": ["vitest/globals"],
 
 
     "paths": {
     "paths": {
       "~/*": ["./src/*"]
       "~/*": ["./src/*"]

+ 14 - 0
packages/ui/vitest.config.ts

@@ -0,0 +1,14 @@
+import react from '@vitejs/plugin-react';
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  plugins: [tsconfigPaths(), react()],
+  test: {
+    clearMocks: true,
+    globals: true,
+    environment: 'happy-dom',
+    include: ['src/**/*.spec.{ts,tsx}'],
+    setupFiles: ['./test/setup.ts'],
+  },
+});