Просмотр исходного кода

feat: enhance off-screen indicators with Material Symbols and improved styling

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

+ 49 - 21
.kiro/specs/collaborative-editor-awareness/design.md

@@ -137,19 +137,21 @@ sequenceDiagram
 | 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 and fallback color from `state.editors.color` | `yRichCursors` | `RichCaretWidget` constructor |
+| 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 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` | `visibleRanges` comparison in `update()` |
+| 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 | Multiple indicators side by side | `yRichCursors` | horizontal flex layout |
-| 4.6 | Transition on scroll (indicator ↔ widget) | `yRichCursors` | `visibleRanges` check in `update()` |
+| 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 |
 
 ## Components and Interfaces
 
@@ -262,7 +264,8 @@ Invariants:
 <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" />  OR  <span class="cm-yRichCursorInitials" />
+    <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>
@@ -274,8 +277,9 @@ 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**: Semi-transparent at idle, full on hover or when `.cm-yRichCursorActive` is present (3-second activity window).
-- **Design tokens**: `AVATAR_SIZE` and `IDLE_OPACITY` are defined as constants at the top of `theme.ts` and shared across all cursor/off-screen styles.
+- **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.
 
@@ -295,28 +299,52 @@ Selection highlight: `Decoration.mark` on selected range with `background-color:
 
 ##### Off-Screen Cursor Indicators
 
-When a remote cursor's absolute position falls outside the actually visible content range (`view.visibleRanges`), the ViewPlugin renders an off-screen indicator instead of a widget decoration. Note: `view.viewport` includes CodeMirror's pre-render buffer and must NOT be used for visibility classification — `view.visibleRanges` returns only the ranges the user can actually see.
+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.
 
-**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.
+**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)
-│   ├── .cm-offScreenIndicator (Alice ↑)
-│   └── .cm-offScreenIndicator (Bob ↑)
-└── .cm-offScreenBottom ← bottomContainer (absolute, bottom: 0)
-    └── .cm-offScreenIndicator (Charlie ↓)
+├── .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"
 ```
 
-**Indicator DOM structure**: Arrow (`↑` / `↓`) + avatar image (or initials fallback). Built by `createOffScreenIndicator()` in `off-screen-indicator.ts`. Sizing uses the same `AVATAR_SIZE` token from `theme.ts`. Opacity follows the same idle/active pattern as in-editor widgets.
+**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. 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.visibleRanges` (first range's `from` for top boundary, last range's `to` for bottom boundary)
-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)
+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**

+ 7 - 5
.kiro/specs/collaborative-editor-awareness/requirements.md

@@ -44,10 +44,10 @@ GROWI's collaborative editor uses Yjs awareness protocol to track which users ar
 #### 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 16×16 CSS pixels (circular), smaller than `EditingUserList` to minimize interference with editor content.
+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 and avatar fallback background color shall match the `color` value from the user's awareness state, consistent with the color shown in `EditingUserList`.
+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.
@@ -60,10 +60,12 @@ GROWI's collaborative editor uses Yjs awareness protocol to track which users ar
 
 #### 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.
+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), their indicators shall be displayed side by side (horizontally) at the corresponding edge of the editor.
+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.

+ 81 - 5
packages/editor/src/client/services-internal/extensions/y-rich-cursors/off-screen-indicator.spec.ts

@@ -11,7 +11,7 @@ import { createOffScreenIndicator } from './off-screen-indicator';
  */
 
 describe('createOffScreenIndicator', () => {
-  it('renders an indicator with an upward arrow for direction "above"', () => {
+  it('renders an indicator with an upward Material Symbol for direction "above"', () => {
     const el = createOffScreenIndicator({
       direction: 'above',
       color: '#ff0000',
@@ -22,10 +22,11 @@ describe('createOffScreenIndicator', () => {
 
     const arrow = el.querySelector('.cm-offScreenArrow');
     expect(arrow).not.toBeNull();
-    expect(arrow?.textContent).toBe('↑');
+    expect(arrow?.textContent).toBe('arrow_drop_up');
+    expect(arrow?.classList.contains('material-symbols-outlined')).toBe(true);
   });
 
-  it('renders an indicator with a downward arrow for direction "below"', () => {
+  it('renders an indicator with a downward Material Symbol for direction "below"', () => {
     const el = createOffScreenIndicator({
       direction: 'below',
       color: '#ff0000',
@@ -35,7 +36,34 @@ describe('createOffScreenIndicator', () => {
     });
 
     const arrow = el.querySelector('.cm-offScreenArrow');
-    expect(arrow?.textContent).toBe('↓');
+    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',
+      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',
+      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', () => {
@@ -95,7 +123,7 @@ describe('createOffScreenIndicator', () => {
     expect(el.classList.contains('cm-yRichCursorActive')).toBe(false);
   });
 
-  it('applies border-color from the color parameter', () => {
+  it('applies border-color on the indicator wrapper from the color parameter', () => {
     const el = createOffScreenIndicator({
       direction: 'above',
       color: '#ff0000',
@@ -106,4 +134,52 @@ describe('createOffScreenIndicator', () => {
 
     expect(el.style.borderColor).toBe('#ff0000');
   });
+
+  it('sets borderColor on the avatar img to the cursor color', () => {
+    const el = createOffScreenIndicator({
+      direction: 'above',
+      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',
+      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',
+      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');
+  });
 });

+ 34 - 5
packages/editor/src/client/services-internal/extensions/y-rich-cursors/off-screen-indicator.ts

@@ -11,6 +11,20 @@ export type OffScreenIndicatorOptions = {
 /**
  * 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,
@@ -25,29 +39,44 @@ export function createOffScreenIndicator(
   }
 
   const arrow = document.createElement('span');
-  arrow.className = 'cm-offScreenArrow';
-  arrow.textContent = direction === 'above' ? '↑' : '↓';
-  indicator.appendChild(arrow);
+  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);
     };
-    indicator.appendChild(img);
+    avatarEl = img;
   } else {
     const initials = document.createElement('span');
     initials.className = 'cm-offScreenInitials';
     initials.style.backgroundColor = color;
+    initials.style.borderColor = color;
     initials.textContent = toInitials(name);
-    indicator.appendChild(initials);
+    avatarEl = initials;
+  }
+
+  // "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);
+  } else {
+    indicator.appendChild(avatarEl);
+    indicator.appendChild(arrow);
   }
 
   return indicator;

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

@@ -378,6 +378,9 @@ describe('Task 12.2 — Off-screen indicator renders for cursor in render buffer
         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;
 

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

@@ -153,8 +153,9 @@ export class YRichCursorsPluginValue {
     }
 
     const decorations: { from: number; to: number; value: Decoration }[] = [];
-    const aboveIndicators: HTMLElement[] = [];
-    const belowIndicators: HTMLElement[] = [];
+    type IndicatorEntry = { el: HTMLElement; headIndex: number };
+    const aboveIndicators: IndicatorEntry[] = [];
+    const belowIndicators: IndicatorEntry[] = [];
     const localClientId = this.awareness.doc.clientID;
 
     const visibleRanges = viewUpdate.view.visibleRanges;
@@ -245,27 +246,29 @@ export class YRichCursorsPluginValue {
       // Classify: off-screen (above/below) or in-viewport
       if (rangedMode) {
         if (headIndex < vpFrom) {
-          aboveIndicators.push(
-            createOffScreenIndicator({
+          aboveIndicators.push({
+            el: createOffScreenIndicator({
               direction: 'above',
               color: editors.color,
               name: editors.name,
               imageUrlCached: editors.imageUrlCached,
               isActive,
             }),
-          );
+            headIndex,
+          });
           return;
         }
         if (headIndex > vpTo) {
-          belowIndicators.push(
-            createOffScreenIndicator({
+          belowIndicators.push({
+            el: createOffScreenIndicator({
               direction: 'below',
               color: editors.color,
               name: editors.name,
               imageUrlCached: editors.imageUrlCached,
               isActive,
             }),
-          );
+            headIndex,
+          });
           return;
         }
       } else {
@@ -280,27 +283,29 @@ export class YRichCursorsPluginValue {
         const cursorBottom = scrollDOMTop + lineBlock.bottom - scrollTop;
 
         if (cursorBottom < screenVisibleTop) {
-          aboveIndicators.push(
-            createOffScreenIndicator({
+          aboveIndicators.push({
+            el: createOffScreenIndicator({
               direction: 'above',
               color: editors.color,
               name: editors.name,
               imageUrlCached: editors.imageUrlCached,
               isActive,
             }),
-          );
+            headIndex,
+          });
           return;
         }
         if (cursorTop > screenVisibleBottom) {
-          belowIndicators.push(
-            createOffScreenIndicator({
+          belowIndicators.push({
+            el: createOffScreenIndicator({
               direction: 'below',
               color: editors.color,
               name: editors.name,
               imageUrlCached: editors.imageUrlCached,
               isActive,
             }),
-          );
+            headIndex,
+          });
           return;
         }
       }
@@ -337,7 +342,38 @@ export class YRichCursorsPluginValue {
     });
 
     this.decorations = Decoration.set(decorations, true);
-    this.topContainer.replaceChildren(...aboveIndicators);
-    this.bottomContainer.replaceChildren(...belowIndicators);
+    this.topContainer.replaceChildren(...aboveIndicators.map(({ el }) => el));
+    this.bottomContainer.replaceChildren(
+      ...belowIndicators.map(({ el }) => el),
+    );
+
+    // Position each indicator horizontally at the remote cursor's column.
+    // coordsAtPos reads layout so it must be deferred to the measure phase.
+    const allIndicators = [...aboveIndicators, ...belowIndicators];
+    if (allIndicators.length > 0) {
+      viewUpdate.view.requestMeasure({
+        read: (view) => {
+          const editorLeft = view.dom.getBoundingClientRect().left;
+          return allIndicators.map(({ headIndex: hi }) => {
+            const coords = view.coordsAtPos(hi, 1);
+            if (coords != null) {
+              return coords.left - editorLeft;
+            }
+            // Fallback for virtualised positions (outside CodeMirror's viewport)
+            const line = view.state.doc.lineAt(hi);
+            const col = hi - line.from;
+            const contentLeft =
+              view.contentDOM.getBoundingClientRect().left - editorLeft;
+            return contentLeft + col * view.defaultCharacterWidth;
+          });
+        },
+        write: (positions) => {
+          allIndicators.forEach(({ el }, i) => {
+            el.style.left = `${positions[i]}px`;
+            el.style.transform = 'translateX(-50%)';
+          });
+        },
+      });
+    }
   }
 }

+ 38 - 11
packages/editor/src/client/services-internal/extensions/y-rich-cursors/theme.ts

@@ -40,6 +40,9 @@ export const richCursorsTheme = EditorView.baseTheme({
     height: AVATAR_SIZE,
     borderRadius: '50%',
     display: 'block',
+    borderStyle: 'solid',
+    borderWidth: '1.5px',
+    boxSizing: 'border-box',
   },
 
   // Initials fallback
@@ -53,6 +56,9 @@ export const richCursorsTheme = EditorView.baseTheme({
     color: 'white',
     fontSize: '9px',
     fontWeight: 'bold',
+    borderStyle: 'solid',
+    borderWidth: '1.5px',
+    boxSizing: 'border-box',
   },
 
   // Name label — hidden by default, shown on hover.
@@ -76,13 +82,12 @@ export const richCursorsTheme = EditorView.baseTheme({
   },
 
   // --- Off-screen containers ---
+  // Height = avatar + compact arrow with no extra padding.
   '.cm-offScreenTop, .cm-offScreenBottom': {
     position: 'absolute',
     left: '0',
     right: '0',
-    display: 'flex',
-    gap: '4px',
-    padding: '2px 4px',
+    height: `calc(${AVATAR_SIZE} + 14px)`,
     pointerEvents: 'none',
     zIndex: '10',
   },
@@ -93,25 +98,38 @@ export const richCursorsTheme = EditorView.baseTheme({
     bottom: '0',
   },
 
-  // Off-screen indicator
+  // Off-screen indicator — absolutely positioned; left/transform set by plugin
+  // via requestMeasure to reflect the remote cursor's column position.
+  // Opacity is intentionally NOT set here — it lives on the avatar/initials only
+  // so the arrow always renders fully opaque (CSS opacity cannot be "cancelled"
+  // on children; mixing opaque arrow + faded avatar requires separate rules).
   '.cm-offScreenIndicator': {
+    position: 'absolute',
+    top: '0',
     display: 'flex',
+    flexDirection: 'column',
     alignItems: 'center',
-    gap: '2px',
-    opacity: IDLE_OPACITY,
-    transition: 'opacity 0.3s ease',
-  },
-  '.cm-offScreenIndicator.cm-yRichCursorActive': {
-    opacity: '1',
   },
+
+  // Arrow — always fully opaque; cursor color applied via inline style in JS.
   '.cm-offScreenArrow': {
-    fontSize: '10px',
+    fontFamily: 'var(--grw-font-family-material-symbols-outlined)',
+    fontSize: '14px',
     lineHeight: '1',
+    userSelect: 'none',
+    opacity: '1',
   },
+
+  // 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,
@@ -123,5 +141,14 @@ export const richCursorsTheme = EditorView.baseTheme({
     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',
+    },
 });

+ 26 - 1
packages/editor/src/client/services-internal/extensions/y-rich-cursors/widget.spec.ts

@@ -53,6 +53,28 @@ describe('RichCaretWidget', () => {
       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();
@@ -98,9 +120,12 @@ describe('RichCaretWidget', () => {
       img.dispatchEvent(new Event('error'));
 
       expect(dom.querySelector('img.cm-yRichCursorAvatar')).toBeNull();
-      const initials = dom.querySelector('.cm-yRichCursorInitials');
+      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', () => {

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

@@ -20,6 +20,7 @@ export function createInitialsElement(
   const el = document.createElement('span');
   el.className = 'cm-yRichCursorInitials';
   el.style.backgroundColor = color;
+  el.style.borderColor = color;
   el.textContent = toInitials(name);
   return el;
 }
@@ -81,6 +82,7 @@ export class RichCaretWidget extends WidgetType {
       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);