Yuki Takei 1 неделя назад
Родитель
Сommit
f674e04be8

+ 228 - 23
.kiro/specs/collaborative-editor-awareness/design.md

@@ -38,7 +38,7 @@ The current flow has two defects:
 graph TB
     subgraph packages_editor
         COLLAB[use-collaborative-editor-mode]
-        RICH[yRichCursors ViewPlugin - new]
+        RICH[yRichCursors ViewPlugin]
         YCOLLAB[yCollab - null awareness]
     end
 
@@ -64,15 +64,17 @@ graph TB
     COLLAB -->|awareness| RICH
     RICH -->|reads state.editors| AWR
     RICH -->|sets state.cursor| AWR
+    RICH -->|viewport comparison| RICH
     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
+- `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.viewport` to decide between widget decoration (in-view) and DOM overlay (off-screen)
 
 ### Technology Stack
 
@@ -131,19 +133,30 @@ sequenceDiagram
 | 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', ...)` |
+| 3.1 | Avatar overlay below caret (no block space) | `yRichCursors` | `RichCaretWidget.toDOM()` — `position: absolute` overlay |
+| 3.2 | Avatar size 24×24px (matches EditingUserList) | `yRichCursors` | `RichCaretWidget.toDOM()` — CSS sizing |
+| 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 and fallback color from `state.editors.color` | `yRichCursors` | `RichCaretWidget` constructor |
+| 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 pinned to top edge (↑) | `yRichCursors` | `offScreenContainer` top overlay |
+| 4.2 | Off-screen indicator pinned to bottom edge (↓) | `yRichCursors` | `offScreenContainer` bottom overlay |
+| 4.3 | No indicator when cursor is in viewport | `yRichCursors` | viewport comparison in `update()` |
+| 4.4 | Same avatar/color as in-editor widget | `yRichCursors` | shared `state.editors` data |
+| 4.5 | Multiple indicators side by side | `yRichCursors` | horizontal flex layout |
+| 4.6 | Transition on scroll (indicator ↔ widget) | `yRichCursors` | `viewportChanged` check in `update()` |
+| 4.7 | Overlay positioning (no layout impact) | `yRichCursors` | `position: absolute` on `view.dom` |
 
 ## 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 |
+| `yRichCursors` | packages/editor — Extension | Custom ViewPlugin: broadcasts local cursor position, renders in-viewport cursors with overlay avatar+hover name+activity opacity, renders off-screen indicators at editor edges | 3.1–3.10, 4.1–4.7 | `@codemirror/view` (P0), `y-websocket awareness` (P0) | Service |
 
 ### packages/editor — Hook
 
@@ -198,8 +211,8 @@ sequenceDiagram
 
 | 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 |
+| Intent | CodeMirror ViewPlugin — broadcasts local cursor position, renders in-viewport cursors with overlay avatar and hover-revealed name, renders off-screen indicators pinned to editor edges for cursors outside the viewport |
+| Requirements | 3.1–3.10, 4.1–4.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`)
@@ -234,7 +247,8 @@ Preconditions:
 - `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
+- 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`
 - Local cursor position is broadcast to awareness as `state.cursor.{ anchor, head }` on each focus-selection change
 
 Invariants:
@@ -245,23 +259,201 @@ Invariants:
 ##### 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 class="cm-yRichCaret" style="border-color: {color}; position: relative;">
+  <!-- Overlay flag: positioned below the caret, does NOT consume block space -->
+  <span class="cm-yRichCursorFlag">
+    <!-- Avatar: 16×16px circular -->
+    <img class="cm-yRichCursorAvatar" src="{imageUrlCached}" alt="{name}" />
+    <!-- OR fallback when img absent or fails to load: -->
+    <span class="cm-yRichCursorInitials" style="background-color: {color}">{initials}</span>
+    <!-- Name label: hidden by default, shown on :hover -->
+    <span class="cm-yRichCursorInfo" style="background-color: {color}">{name}</span>
+  </span>
 </span>
 ```
 
+**CSS strategy** (applied via `EditorView.baseTheme` exported alongside the ViewPlugin):
+
+`:hover` pseudo-class cannot be expressed via inline styles, so a `baseTheme` is mandatory. The theme is included in the Extension array returned by `yRichCursors()`.
+
+```css
+/* Caret line — the hover anchor */
+.cm-yRichCaret {
+  position: relative;
+}
+
+/* Overlay flag — pointer-events: none to avoid stealing clicks from the editor.
+   Shown on caret hover so the user can then interact with the flag. */
+.cm-yRichCursorFlag {
+  position: absolute;
+  top: 100%;           /* directly below the caret line */
+  left: -8px;          /* center the 16px avatar on the 1px caret */
+  z-index: 10;
+  pointer-events: none;          /* default: pass clicks through to editor */
+}
+.cm-yRichCaret:hover .cm-yRichCursorFlag {
+  pointer-events: auto;          /* enable interaction once caret is hovered */
+}
+
+.cm-yRichCursorAvatar {
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  display: block;
+}
+.cm-yRichCursorInitials {
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: white;
+  font-size: 8px;
+  font-weight: bold;
+}
+
+/* Name label — hidden by default, shown when the flag itself is hovered */
+.cm-yRichCursorInfo {
+  display: none;
+  position: absolute;
+  top: 0;
+  left: 20px;          /* right of the 16px avatar + 4px gap */
+  white-space: nowrap;
+  padding: 2px 6px;
+  border-radius: 3px;
+  color: white;
+  font-size: 12px;
+  line-height: 16px;
+}
+.cm-yRichCursorFlag:hover .cm-yRichCursorInfo {
+  display: block;      /* shown on hover */
+}
+
+/* --- Opacity: semi-transparent by default, full on hover or active editing --- */
+.cm-yRichCursorFlag {
+  opacity: 0.4;
+  transition: opacity 0.3s ease;
+}
+.cm-yRichCaret:hover .cm-yRichCursorFlag,
+.cm-yRichCursorFlag.cm-yRichCursorActive {
+  opacity: 1;
+}
+```
+
+**Activity tracking for opacity** (JavaScript, within `YRichCursorsPluginValue`):
+- Maintain `lastActivityMap: Map<number, number>` — maps `clientId` → timestamp of last awareness cursor change
+- Maintain `activeTimers: Map<number, ReturnType<typeof setTimeout>>` — maps `clientId` → timer handle
+- On awareness `change` for remote clients:
+  - Update `lastActivityMap.set(clientId, Date.now())`
+  - Clear any existing timer for that client, set a new `setTimeout(3000)` that calls `view.dispatch()` with `yRichCursorsAnnotation` to trigger a decoration rebuild
+- In `update()`, when building decorations:
+  - Compute `isActive = (Date.now() - (lastActivityMap.get(clientId) ?? 0)) < 3000` for each remote client
+  - Pass `isActive` to `new RichCaretWidget(color, name, imageUrlCached, isActive)` — `toDOM()` applies `.cm-yRichCursorActive` when `true`
+  - Pass `isActive` when building off-screen indicator elements as well (add `.cm-yRichCursorActive` class to `.cm-offScreenIndicator`)
+- `eq()` includes `isActive`, so a state transition (active→inactive or vice versa) triggers `toDOM()` re-creation — this occurs at most twice per user per 3-second cycle, which is acceptable
+- On `destroy()`: clear all timers
+
+**Off-screen indicators** also respect the same opacity pattern: `.cm-offScreenIndicator` defaults to `opacity: 0.4` and receives `.cm-yRichCursorActive` when the remote user is active.
+
+**Pointer-events strategy**: The overlay flag uses `pointer-events: none` by default so it never intercepts clicks or text selection in the editor. When the user hovers the caret line (`.cm-yRichCaret:hover`), `pointer-events: auto` is enabled on the flag, allowing the user to then hover the avatar to reveal the name label. This two-step hover cascade ensures the editor remains fully interactive while still providing discoverability.
+
+**Design decision — CSS-only, no React**: The overlay, sizing, and hover behavior are all achievable with `position: absolute` and the `:hover` pseudo-class. No JavaScript state management is needed, so `document.createElement` remains the implementation strategy. React's `createRoot` would introduce async rendering (flash of empty container), context isolation, and per-widget overhead without any benefit.
+
 `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)
+- Constructor parameters: `color: string`, `name: string`, `imageUrlCached: string | undefined`, `isActive: boolean`
+- `toDOM()`: creates the DOM tree above using `document.createElement`; attaches `onerror` on `<img>` to replace with initials fallback; applies CSS classes via `baseTheme`; adds `.cm-yRichCursorActive` to `.cm-yRichCursorFlag` when `isActive` is `true`
+- `eq(other)`: returns `true` when `color`, `name`, `imageUrlCached`, and `isActive` all match (avoids unnecessary re-creation; activity state transitions cause at most 2 re-creations per user per 3-second cycle)
 - `estimatedHeight`: `-1` (inline widget)
 - `ignoreEvent()`: `true`
 
 Selection highlight: rendered as `Decoration.mark` on the selected range with `background-color: {colorLight}` (same as `yRemoteSelections`).
 
+##### Off-Screen Cursor Indicators
+
+When a remote cursor's absolute position falls outside `view.viewport.from`..`view.viewport.to`, the ViewPlugin renders an off-screen indicator instead of a widget decoration.
+
+**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 (zero height) 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)
+│   ├── .cm-offScreenIndicator (Alice ↑)
+│   └── .cm-offScreenIndicator (Bob ↑)
+└── .cm-offScreenBottom ← bottomContainer (absolute, bottom: 0)
+    └── .cm-offScreenIndicator (Charlie ↓)
+```
+
+**Indicator DOM structure**:
+```html
+<span class="cm-offScreenIndicator">
+  <span class="cm-offScreenArrow">↑</span>  <!-- or ↓ -->
+  <img class="cm-offScreenAvatar" src="{imageUrlCached}" alt="{name}" />
+  <!-- OR fallback: -->
+  <span class="cm-offScreenInitials" style="background-color: {color}">{initials}</span>
+</span>
+```
+
+**CSS** (included in the same `EditorView.baseTheme`):
+```css
+.cm-offScreenTop,
+.cm-offScreenBottom {
+  position: absolute;
+  left: 0;
+  right: 0;
+  display: flex;
+  gap: 4px;
+  padding: 2px 4px;
+  pointer-events: none;
+  z-index: 10;
+}
+.cm-offScreenTop {
+  top: 0;
+}
+.cm-offScreenBottom {
+  bottom: 0;
+}
+.cm-offScreenIndicator {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  opacity: 0.4;
+  transition: opacity 0.3s ease;
+}
+.cm-offScreenIndicator.cm-yRichCursorActive {
+  opacity: 1;
+}
+.cm-offScreenArrow {
+  font-size: 10px;
+  line-height: 1;
+}
+.cm-offScreenAvatar {
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+}
+.cm-offScreenInitials {
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: white;
+  font-size: 8px;
+  font-weight: bold;
+}
+```
+
+**Update cycle**:
+1. In the `update(viewUpdate)` method, after computing absolute positions for all remote cursors, classify each into: `inViewport`, `above`, or `below` based on comparison with `view.viewport.{from, to}`
+2. For `inViewport` cursors: create `Decoration.widget` (same as current behavior)
+3. For `above` / `below` cursors: rebuild `topContainer` / `bottomContainer` children via `replaceChildren()` — clear old indicator elements and append new ones
+4. Containers are rebuilt on every update where `viewportChanged` is true OR awareness has changed (same trigger as decoration rebuild)
+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.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
@@ -297,9 +489,11 @@ type CursorState = {
 | 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>` |
+| 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 |
 
 ## Testing Strategy
 
@@ -308,8 +502,11 @@ type CursorState = {
 - `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
+- `RichCaretWidget.toDOM()`: when `imageUrlCached` is provided, renders `<img>` element (24×24px, circular); when undefined, renders initials `<span>` with `background-color` from `color`
+- Avatar fallback: `onerror` on `<img>` replaces the element with the initials fallback (colored circle)
+- Overlay positioning: the `.cm-yRichCursorFlag` element has `position: absolute` and `top: 100%` (does not consume block space)
+- Hover behavior (structural only): `.cm-yRichCursorInfo` exists in the DOM with no inline `display` override (the `baseTheme` sets `display: none` by default). Actual `:hover` toggle is CSS-only and cannot be simulated in happy-dom/jsdom — **deferred to E2E tests (Playwright)**
+- Activity tracking: `RichCaretWidget` constructed with `isActive: true` adds `.cm-yRichCursorActive` to the flag element; with `isActive: false` it does not. `eq()` returns `false` when `isActive` differs, triggering widget re-creation
 
 ### Integration Tests
 
@@ -317,7 +514,15 @@ type CursorState = {
 - 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
+- Off-screen classification: given a remote cursor position outside `view.viewport`, verify the cursor is not rendered as a widget decoration (widget count is zero for that client)
+
+### E2E Tests (Playwright)
+
+- `:hover` behavior on `.cm-yRichCursorFlag`: verify name label appears on hover, hidden otherwise (Req 3.3)
+- Off-screen indicator visibility: scroll the editor so a remote cursor goes off-screen; verify `.cm-offScreenTop` or `.cm-offScreenBottom` contains the expected indicator element; scroll back and verify the indicator disappears and the in-editor widget reappears (Req 4.6)
+- Pointer-events: verify that clicking on text underneath the overlay flag correctly places the editor cursor (Req 4.7)
 
 ### 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`
+- Off-screen container updates use `replaceChildren()` for efficient batch DOM mutation; containers are not removed/re-created on each update cycle

+ 26 - 8
.kiro/specs/collaborative-editor-awareness/requirements.md

@@ -37,15 +37,33 @@ GROWI's collaborative editor uses Yjs awareness protocol to track which users ar
 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
+### Requirement 3: Rich Cursor Display (Overlay Avatar)
 
-**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.
+**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 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.
+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 16×16 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 and avatar fallback background color shall match the `color` value from the user's awareness state, consistent with the color shown in `EditingUserList`.
+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, accompanied by an upward arrow (↑), indicating 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, accompanied by a downward arrow (↓), indicating 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), their indicators shall be displayed side by side (horizontally) at the corresponding edge of the editor.
+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.

+ 3 - 3
.kiro/specs/collaborative-editor-awareness/spec.json

@@ -1,8 +1,8 @@
 {
   "feature_name": "collaborative-editor-awareness",
   "created_at": "2026-04-07T00:00:00.000Z",
-  "updated_at": "2026-04-07T06:00:00.000Z",
-  "language": "en",
+  "updated_at": "2026-04-08T13:00:00.000Z",
+  "language": "ja",
   "phase": "tasks-generated",
   "approvals": {
     "requirements": {
@@ -15,7 +15,7 @@
     },
     "tasks": {
       "generated": true,
-      "approved": true
+      "approved": false
     }
   },
   "ready_for_implementation": true

+ 105 - 34
.kiro/specs/collaborative-editor-awareness/tasks.md

@@ -12,54 +12,125 @@
   - Rely on Yjs to clean up disconnected client entries before emitting the `update` event, as per the Yjs awareness contract
   - _Requirements: 1.2_
 
-- [x] 2. (P) Build the Rich Cursor Extension
+- [x] 2. Build the Rich Cursor Extension (Initial)
 - [x] 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_
+  - _Requirements: 3.4, 3.5_
 
 - [x] 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_
+  - _Requirements: 3.6, 3.7_
 
 - [x] 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_
+  - _Requirements: 3.6, 3.7_
 
 - [x] 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_
+  - Suppress the default cursor plugin by passing `null` as the awareness argument to `yCollab`
+  - Add the rich cursor extension as a sibling extension alongside `yCollab` output
+  - Verify `yUndoManagerKeymap` is not duplicated
+  - _Requirements: 1.3, 2.4, 3.6_
 
-- [x] 4. Unit Tests for Core Behaviors
+- [x] 4. Unit Tests for Core Behaviors (Initial)
 - [x] 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_
 
 - [x] 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_
+  - _Requirements: 3.4, 3.5_
 
-- [x] 5. Integration Tests for Multi-Client Collaborative Scenarios
+- [x] 5. Integration Tests for Multi-Client Collaborative Scenarios (Initial)
 - [x] 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_
 
 - [x] 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_
+  - _Requirements: 3.6, 3.7_
+
+- [ ] 6. Add baseTheme with Overlay Positioning, Hover, and Opacity Rules
+- [ ] 6.1 (P) Create the EditorView.baseTheme defining all cursor overlay CSS rules
+  - Define overlay positioning for the cursor flag element: absolute below the caret, centered on the 1px caret line
+  - Set avatar and initials fallback sizes to 16×16 pixels with circular clipping
+  - Set up the two-step hover cascade: pointer-events none by default on the flag, enabled on caret hover
+  - Define the name label as hidden by default, shown on flag hover
+  - Set the default opacity to semi-transparent with a smooth transition, full opacity on caret hover or when the active class is present
+  - Include the theme extension in the return value of the rich cursors factory function
+  - _Requirements: 3.1, 3.2, 3.3, 3.8, 3.9_
+
+- [ ] 6.2 (P) Define off-screen container and indicator styles in the same baseTheme
+  - Define the top and bottom off-screen containers as absolute-positioned, flex-layout, pointer-events none
+  - Define the off-screen indicator as flex with gap, semi-transparent by default, full opacity with the active class
+  - Define the off-screen avatar and initials with 16×16 sizing matching the in-editor widget
+  - Define the arrow indicator styling
+  - _Requirements: 4.5, 4.7_
+
+- [ ] 7. Rework RichCaretWidget for Overlay Avatar with Activity State
+- [ ] 7.1 Rebuild the widget DOM to render as an overlay with avatar, initials fallback, and hover-revealed name label
+  - Restructure the widget DOM to wrap the avatar and name label inside a flag container element positioned as an overlay below the caret
+  - Render the avatar image at 16×16 pixels when the image URL is available, with an error handler that swaps in the initials fallback
+  - When no image URL is provided, render the initials fallback directly as a colored circle with the user's initial letters
+  - Render the name label element inside the flag container (visibility controlled by the baseTheme hover rule)
+  - Accept an `isActive` parameter and apply the active CSS class to the flag element when true
+  - Update the equality check to include the `isActive` parameter alongside color, name, and image URL
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.10_
+
+- [ ] 7.2 Add activity tracking to the ViewPlugin with per-client timers
+  - Maintain a map of each remote client's last awareness change timestamp
+  - Maintain a map of per-client timer handles for the 3-second inactivity window
+  - On awareness change for a remote client, record the current timestamp and reset the client's timer to dispatch a decoration rebuild after 3 seconds
+  - When building decorations in the update method, compute each client's active state by comparing the current time against the last activity timestamp
+  - Pass the computed active state to the widget constructor so the DOM reflects the current activity
+  - Clear all timers on plugin destruction
+  - _Requirements: 3.10_
+
+- [ ] 8. Build Off-Screen Cursor Indicators
+- [ ] 8.1 Create persistent off-screen containers attached to the editor DOM
+  - Create top and bottom container elements in the ViewPlugin constructor and append them to the editor's outer DOM element
+  - The containers remain in the DOM for the plugin's lifetime (empty when no off-screen cursors exist)
+  - Remove both containers in the plugin's destroy method
+  - _Requirements: 4.7_
+
+- [ ] 8.2 Classify remote cursors by viewport position and render off-screen indicators
+  - After computing absolute positions for all remote cursors in the update method, compare each position against the current viewport range
+  - For cursors above the viewport, build an indicator element (arrow up + avatar or initials fallback) and add it to the top container
+  - For cursors below the viewport, build an indicator element (arrow down + avatar or initials fallback) and add it to the bottom container
+  - For cursors within the viewport, render the in-editor widget decoration as before (no off-screen indicator)
+  - Replace container children on each relevant update cycle using a batch DOM operation
+  - Apply the active CSS class to off-screen indicators when the corresponding client's activity state is active
+  - Rebuild containers when the viewport changes or awareness changes
+  - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
+
+- [ ] 9. Unit Tests for Updated Widget and Off-Screen Indicators
+- [ ] 9.1 (P) Test the updated widget DOM structure, overlay flag, sizing, and isActive class behavior
+  - Verify the widget renders a flag container with position absolute styling inside the caret element
+  - Verify the avatar image renders at 16×16 when image URL is provided
+  - Verify the initials fallback renders with the user's color as background when no image URL is given
+  - Verify the image error handler replaces the image with the initials fallback
+  - Verify the name label element exists inside the flag container
+  - Verify the flag element receives the active CSS class when isActive is true, and does not when false
+  - Verify the equality check returns false when isActive differs
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.10_
+
+- [ ] 9.2 (P) Test off-screen indicator DOM construction and avatar fallback
+  - Verify an off-screen indicator element contains an arrow element and an avatar image when image URL is provided
+  - Verify an off-screen indicator falls back to an initials element when image URL is absent
+  - Verify the active CSS class is applied to the indicator element when the client is active
+  - _Requirements: 4.1, 4.2, 4.4_
+
+- [ ] 10. Integration Tests for Viewport Classification and Activity Tracking
+- [ ] 10.1 Test that remote cursors outside the viewport are excluded from widget decorations
+  - Simulate a remote client with a cursor position beyond the viewport range and verify that no widget decoration is created for that client
+  - _Requirements: 4.3, 4.6_
+
+- [ ] 10.2 Test activity tracking timer lifecycle with fake timers
+  - Simulate an awareness change for a remote client and verify the client is marked as active
+  - Advance fake timers by 3 seconds and verify a decoration rebuild is triggered, resulting in the client being marked as inactive
+  - Simulate a new awareness change before the timer expires and verify the timer is reset
+  - _Requirements: 3.10_
+
+- [ ]\* 11. E2E Tests for Hover, Opacity, and Off-Screen Transitions
+- [ ]\* 11.1 (P) Test hover behavior on the cursor overlay flag
+  - Hover over a remote user's caret area and verify the name label becomes visible
+  - Move the cursor away and verify the name label is hidden
+  - Verify that clicking on text underneath the overlay correctly places the editor cursor
+  - _Requirements: 3.3, 3.9_
+
+- [ ]\* 11.2 (P) Test off-screen indicator visibility on scroll
+  - Scroll the editor so a remote user's cursor goes above the viewport and verify the top off-screen container shows an indicator with the correct avatar and arrow
+  - Scroll back to reveal the cursor and verify the off-screen indicator disappears and the in-editor widget reappears
+  - _Requirements: 4.1, 4.2, 4.3, 4.6_