Yuki Takei hai 1 semana
pai
achega
cbf5d9ba82

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

@@ -0,0 +1,318 @@
+# Design Document: collaborative-editor-awareness
+
+## Overview
+
+**Purpose**: This feature fixes intermittent disappearance of the `EditingUserList` component and upgrades in-editor cursors to display a user's name and avatar alongside the cursor caret.
+
+**Users**: All GROWI users who use real-time collaborative page editing. They will see stable editing-user indicators and rich, avatar-bearing cursor flags that identify co-editors by name and profile image.
+
+**Impact**: Modifies `use-collaborative-editor-mode` in `@growi/editor`, replaces the default `yRemoteSelections` cursor plugin from `y-codemirror.next` with a purpose-built `yRichCursors` ViewPlugin, and adds one new source file.
+
+### 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
+
+### Non-Goals
+
+- Server-side awareness bridging (covered in `collaborative-editor` spec)
+- Changes to the `EditingUserList` React component
+- 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 - new]
+        YCOLLAB[yCollab - null awareness]
+    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]
+    end
+
+    CM --> COLLAB
+    COLLAB -->|null awareness| YCOLLAB
+    YCOLLAB --> YSYNC
+    YCOLLAB --> YUNDO
+    COLLAB -->|awareness| RICH
+    RICH -->|reads state.editors| AWR
+    RICH -->|sets state.cursor| AWR
+    COLLAB -->|filtered clientList| ATOM
+    ATOM --> 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
+- `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
+
+### 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`) | Unchanged |
+
+## System Flows
+
+### 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})
+    AW-->>RC: awareness.on('change') fires
+    RC->>RC: rebuild decorations from state.editors + state.cursor
+    RC->>CM: dispatch with new DecorationSet
+```
+
+## 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 | Cursor name label | `yRichCursors` | `RichCaretWidget.toDOM()` |
+| 3.2 | Cursor avatar image | `yRichCursors` | `RichCaretWidget.toDOM()` — `<img>` from `state.editors.imageUrlCached` |
+| 3.3 | Avatar fallback for missing image | `yRichCursors` | `RichCaretWidget.toDOM()` — initials fallback |
+| 3.4 | Cursor flag color from `state.editors.color` | `yRichCursors` | `RichCaretWidget` constructor |
+| 3.5 | Custom cursor via replacement plugin | `yRichCursors` replaces `yRemoteSelections` | `yCollab(activeText, null, { undoManager })` |
+| 3.6 | Cursor updates on awareness change | `yRichCursors` awareness change listener | `awareness.on('change', ...)` |
+
+## 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 | 1.1–1.4, 2.1, 2.4 | `yCollab` (P0), `yRichCursors` (P0) | State |
+| `yRichCursors` | packages/editor — Extension | Custom ViewPlugin: broadcasts local cursor position, renders remote cursors with avatar+name | 3.1–3.6 | `@codemirror/view` (P0), `y-websocket awareness` (P0) | Service |
+
+### 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 |
+
+**Responsibilities & Constraints**
+- Filters `undefined` awareness entries before calling `onEditorsUpdated`
+- Does not mutate `awareness.getStates()` directly
+- Composes `yCollab(null)` + `yRichCursors(awareness)` to achieve text-sync, undo, and rich cursor rendering without the default `yRemoteSelections` plugin
+
+**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)
+
+**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.
+
+---
+
+### packages/editor — Extension
+
+#### `yRichCursors` (new)
+
+| Field | Detail |
+|-------|--------|
+| Intent | CodeMirror ViewPlugin — broadcasts local cursor position, renders remote cursors with name and avatar from `state.editors` |
+| Requirements | 3.1, 3.2, 3.3, 3.4, 3.5, 3.6 |
+
+**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` (P0)
+- External: `@codemirror/state` `RangeSet`, `Annotation` (P0)
+- External: `yjs` `createRelativePositionFromTypeIndex`, `createAbsolutePositionFromRelativePosition` (P0)
+- External: `y-codemirror.next` `ySyncFacet` (to access `ytext` for position conversion) (P0)
+- Inbound: `provider.awareness` passed as parameter (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+/**
+ * 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.
+ */
+export function yRichCursors(awareness: Awareness): 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
+
+Postconditions:
+- Remote cursors are rendered as `cm-yRichCursor` widgets at each remote client's head position
+- 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 invalidated and rebuilt on every awareness `change` event affecting cursor or editors fields
+- `state.cursor` field is written exclusively by `yRichCursors`; no other plugin or code path may call `awareness.setLocalStateField('cursor', ...)` to avoid data races
+
+##### Widget DOM Structure
+
+```
+<span class="cm-yRichCaret" style="border-color: {color}">
+  <img class="cm-yRichCursorAvatar" src="{imageUrlCached}" alt="{name}" />
+  <!-- fallback when img fails to load: -->
+  <!-- <span class="cm-yRichCursorInitials">{initials}</span> -->
+  <span class="cm-yRichCursorInfo" style="background-color: {color}">{name}</span>
+</span>
+```
+
+`RichCaretWidget` (extends `WidgetType`):
+- Constructor parameters: `color: string`, `name: string`, `imageUrlCached: string | undefined`
+- `toDOM()`: creates the DOM tree above using `document.createElement`; attaches `onerror` on `<img>` to replace with initials fallback
+- `eq(other)`: returns `true` when `color`, `name`, and `imageUrlCached` all match (avoids unnecessary re-creation)
+- `estimatedHeight`: `-1` (inline widget)
+- `ignoreEvent()`: `true`
+
+Selection highlight: rendered as `Decoration.mark` on the selected range with `background-color: {colorLight}` (same as `yRemoteSelections`).
+
+**Implementation Notes**
+- Integration: file location `packages/editor/src/client/services-internal/extensions/y-rich-cursors.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
+
+## 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>` |
+| `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. |
+
+## Testing Strategy
+
+### Unit Tests
+
+- `emitEditorList` filter: given awareness states `[{ editors: validClient }, {}, { editors: undefined }]`, `onEditorsUpdated` is called with only the valid client
+- `updateAwarenessHandler`: `removed` client IDs are processed without calling `awareness.getStates().delete()`
+- `RichCaretWidget.eq()`: returns `true` for same color/name/imageUrlCached, `false` for any difference
+- `RichCaretWidget.toDOM()`: when `imageUrlCached` is provided, renders `<img>` element; when undefined, renders initials `<span>`
+- Avatar fallback: `onerror` on `<img>` replaces the element with the initials fallback
+
+### Integration Tests
+
+- Two simulated awareness clients: both have `state.editors` set → `EditingUserList` receives two valid entries
+- One client has no `state.editors` (just connected) → `EditingUserList` receives only the client that has editors set
+- Cursor position broadcast: on selection change, `awareness.getLocalState().cursor` is updated with the correct relative position
+- Remote cursor rendering: given awareness state with `state.cursor` and `state.editors`, the editor view contains a `cm-yRichCaret` widget at the correct position
+
+### Performance
+
+- `RichCaretWidget.eq()` prevents re-creation when awareness updates do not change user info — confirmed by CodeMirror's decoration update logic calling `eq` before `toDOM`

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

@@ -0,0 +1,51 @@
+# 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
+
+**Objective:** As a wiki user editing collaboratively, I want to see other users' cursors with their display name and profile image, so that I can easily identify who is editing where in the document.
+
+#### Acceptance Criteria
+
+1. While multiple users are editing the same page, the Collaborative Editor Client shall render each remote user's cursor with a flag that displays the user's display name.
+2. While multiple users are editing the same page, the Collaborative Editor Client shall render each remote user's cursor flag with the user's profile image (avatar) when `imageUrlCached` is available in their awareness state.
+3. If a remote user's awareness state does not include `imageUrlCached` (e.g., guest user or profile image not set), the Collaborative Editor Client shall render the cursor flag with the user's initials or a generic avatar fallback instead of a broken image.
+4. The cursor flag color shall match the `color` value from the user's awareness state, consistent with the color shown in `EditingUserList`.
+5. The Collaborative Editor Client shall pass a custom `cursorBuilder` function to `yCollab` (from `y-codemirror.next`) to produce the styled cursor DOM element containing name and avatar.
+6. 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.

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

@@ -0,0 +1,108 @@
+# 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)
+- **Key Findings**:
+  - `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`
+
+## 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).
+
+## 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

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

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

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

@@ -0,0 +1,65 @@
+# Implementation Plan
+
+- [ ] 1. Stabilize the Editing User List
+- [ ] 1.1 Fix awareness state filter so undefined entries never reach the editor list renderer
+  - Filter the awareness state values to exclude any entry that does not have a valid `editors` field before passing the list to the editing user list callback
+  - Replace the existing array mapping that produces `undefined` for uninitialized clients with a filter that skips those entries entirely
+  - Ensure the filtered list contains only valid `EditingClient` values
+  - _Requirements: 1.1, 1.4_
+
+- [ ] 1.2 Remove direct mutation of the Yjs-managed awareness map on client disconnect
+  - Remove the `awareness.getStates().delete(clientId)` calls that incorrectly mutate Yjs-internal state when a client ID appears in the `removed` list
+  - Rely on Yjs to clean up disconnected client entries before emitting the `update` event, as per the Yjs awareness contract
+  - _Requirements: 1.2_
+
+- [ ] 2. (P) Build the Rich Cursor Extension
+- [ ] 2.1 (P) Implement cursor widget DOM with name label, avatar image, and initials fallback
+  - Create a cursor widget class that renders a styled caret element containing the user's display name and profile image
+  - Use the `color` value from the awareness editors field to set the flag background and border color
+  - When `imageUrlCached` is available, render an `<img>` element; when it is undefined or empty, render a `<span>` showing the user's initials instead
+  - Attach an `onerror` handler on the image element that replaces it with the initials fallback at runtime if the image URL fails to load
+  - Implement widget equality check so that widgets with identical color, name, and image URL are not recreated unnecessarily
+  - _Requirements: 3.1, 3.2, 3.3, 3.4_
+
+- [ ] 2.2 (P) Broadcast local cursor position to awareness on each selection change
+  - Inside the cursor extension's view update handler, derive the local user's cursor anchor and head positions and convert them to Yjs relative positions using the ytext reference from `ySyncFacet`
+  - Write the converted positions to the `cursor` field of the local awareness state using `setLocalStateField`
+  - _Requirements: 3.5, 3.6_
+
+- [ ] 2.3 (P) Render remote cursor decorations rebuilt from awareness state changes
+  - Register a listener on awareness `change` events to rebuild the full decoration set whenever any cursor or editors field changes
+  - For each remote client (excluding the local client), read `state.editors` for user identity and `state.cursor` for position; skip clients that lack either field
+  - Create a caret widget decoration at the cursor's head position and a mark decoration over the selected range using the user's `colorLight` value for the highlight
+  - Dispatch the rebuilt decoration set to update the editor view
+  - _Requirements: 3.5, 3.6_
+
+- [ ] 3. Integrate Rich Cursor Extension into the Editor Configuration
+  - Change the `yCollab` call to pass `null` as the awareness argument, which suppresses the built-in `yRemoteSelections` and `yRemoteSelectionsTheme` plugins while keeping text-sync and undo behavior intact
+  - Add the new rich cursor extension as a sibling extension alongside the `yCollab` output in the editor extension array
+  - Verify that `yUndoManagerKeymap` is not duplicated, since `yCollab` already includes it in its return array
+  - _Requirements: 1.3, 2.4, 3.5_
+
+- [ ] 4. Unit Tests for Core Behaviors
+- [ ] 4.1 (P) Test awareness state filtering and mutation-free disconnect handling in the hook
+  - Given awareness states that include one valid client, one empty state, and one state with `editors: undefined`, verify that the editor list callback receives only the valid client
+  - Given a `removed` client list in the awareness update event, verify that the awareness map is not mutated and no `.delete()` is called
+  - _Requirements: 1.1, 1.2, 1.4_
+
+- [ ] 4.2 (P) Test cursor widget construction, equality, and avatar fallback behavior
+  - Given a widget with a provided image URL, verify that the rendered DOM contains an `<img>` element with the correct `src` attribute
+  - Given a widget without an image URL, verify that the rendered DOM shows only initials and no `<img>` element
+  - Verify that the `onerror` handler on the image element swaps the image out for the initials fallback
+  - Verify that the equality check returns `true` only when color, name, and image URL all match
+  - _Requirements: 3.1, 3.2, 3.3, 3.4_
+
+- [ ] 5. Integration Tests for Multi-Client Collaborative Scenarios
+- [ ] 5.1 Test awareness update flow to EditingUserList with multiple simulated clients
+  - Simulate two clients that both have `state.editors` set and verify that the editor list displays both users
+  - Simulate one client with `state.editors` and one client without (newly connected) and verify that only the client with editors appears in the list
+  - Verify that user presence information broadcast via `state.editors` is accessible from the awareness state
+  - _Requirements: 1.3, 2.1, 2.4_
+
+- [ ] 5.2 Test cursor position broadcasting and remote cursor rendering in the editor view
+  - Given a simulated selection change, verify that the local awareness state `cursor` field is updated with the expected relative positions
+  - Given a remote client's awareness state with both `state.editors` and `state.cursor` set, verify that a `cm-yRichCaret` widget appears in the editor view at the correct position
+  - _Requirements: 3.5, 3.6_

+ 2 - 0
.kiro/specs/collaborative-editor/design.md

@@ -239,6 +239,8 @@ interface YWebsocketPersistence {
 - 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
 
+> **Note**: Client-side awareness display (EditingUserList stability, rich cursor rendering) is designed in the [`collaborative-editor-awareness`](../collaborative-editor-awareness/) spec.
+
 ## 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.

+ 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
 
-**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
 
 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}`).
 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