diff --git a/apps/web/e2e/collab.spec.ts b/apps/web/e2e/collab.spec.ts
index d9e24b8..4f5d9e2 100644
--- a/apps/web/e2e/collab.spec.ts
+++ b/apps/web/e2e/collab.spec.ts
@@ -88,6 +88,39 @@ test('offline edits continue locally and sync on reconnect', async ({ browser })
await admin.close();
});
+test('remote carets and the presence strip reflect participants (#37)', async ({ browser }) => {
+ const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
+ const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
+ const ownerMe = (await (await owner.request.get('/api/v1/auth/me')).json()) as {
+ displayName: string;
+ };
+ const pond = await personalPond(owner);
+ const created = await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
+ data: { title: `Collab Presence ${Date.now()}` },
+ });
+ const { slug } = await created.json();
+
+ const pageA = await openEditor(owner, pond.slug, slug);
+ const pageB = await openEditor(admin, pond.slug, slug);
+
+ // Both participants appear in each presence strip.
+ await expect(pageA.locator('.presence-avatar')).toHaveCount(2, { timeout: 10000 });
+ await expect(pageB.locator('.presence-avatar')).toHaveCount(2, { timeout: 10000 });
+
+ // The owner's cursor shows up as a named caret in the admin's editor.
+ await pageA.locator('.ProseMirror').click();
+ await pageA.keyboard.type('cursor is here');
+ await expect(pageB.locator('.collab-caret__label')).toHaveText(ownerMe.displayName, {
+ timeout: 10000,
+ });
+
+ // Disconnecting the owner drops them from the admin's presence strip.
+ await owner.close();
+ await expect(pageB.locator('.presence-avatar')).toHaveCount(1, { timeout: 15000 });
+
+ await admin.close();
+});
+
// Read-only participants (live changes visible, typing blocked, reason shown)
// need a real read-only grant to obtain a `ro` collab token. Under the interim
// access model seeing and modifying coincide, so no user is issued a `ro` token
diff --git a/apps/web/package.json b/apps/web/package.json
index b24a494..5a1dd90 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -21,6 +21,7 @@
"@tiptap/core": "^3.27.1",
"@tiptap/extension-bubble-menu": "^3.27.1",
"@tiptap/extension-collaboration": "^3.27.1",
+ "@tiptap/extension-collaboration-caret": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
"i18next": "^26.3.4",
diff --git a/apps/web/src/editor/PresenceStrip.tsx b/apps/web/src/editor/PresenceStrip.tsx
new file mode 100644
index 0000000..3070ecc
--- /dev/null
+++ b/apps/web/src/editor/PresenceStrip.tsx
@@ -0,0 +1,60 @@
+import type { HocuspocusProvider } from '@hocuspocus/provider';
+import { useTranslation } from 'react-i18next';
+
+import { usePresence } from './use-presence';
+
+const MAX_AVATARS = 5;
+
+/** One or two letters standing in for an avatar image. */
+function initials(name: string): string {
+ const parts = name.trim().split(/\s+/).filter(Boolean);
+ if (parts.length === 0) return '?';
+ if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
+ return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase();
+}
+
+/** Avatar strip of the participants currently connected to the page (#37). */
+export function PresenceStrip({
+ provider,
+}: {
+ provider: HocuspocusProvider | null;
+}): React.JSX.Element | null {
+ const { t } = useTranslation('editor');
+ const participants = usePresence(provider);
+ if (participants.length === 0) return null;
+
+ const shown = participants.slice(0, MAX_AVATARS);
+ const overflow = participants.length - shown.length;
+
+ return (
+
+ {shown.map((participant) => (
+
+ {initials(participant.name)}
+ {participant.readOnly && (
+
+ ◦
+
+ )}
+
+ ))}
+ {overflow > 0 && (
+
+ +{overflow}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/editor/collaboration-caret.ts b/apps/web/src/editor/collaboration-caret.ts
new file mode 100644
index 0000000..e5f4542
--- /dev/null
+++ b/apps/web/src/editor/collaboration-caret.ts
@@ -0,0 +1,62 @@
+import type { HocuspocusProvider } from '@hocuspocus/provider';
+import { CollaborationCaret } from '@tiptap/extension-collaboration-caret';
+
+import { colorForUser } from './user-color';
+
+/** The awareness `user` payload each client broadcasts (issue #37). */
+export interface CaretUser {
+ userId: string;
+ name: string;
+ color: string;
+ readOnly: boolean;
+}
+
+/** Remote caret with a name flag. Read-only participants broadcast presence
+ * but render no caret (they appear in the strip only). */
+function renderCaret(user: Record): HTMLElement {
+ const caret = document.createElement('span');
+ if (user.readOnly) {
+ caret.className = 'collab-caret collab-caret--hidden';
+ return caret;
+ }
+ const color = String(user.color);
+ caret.className = 'collab-caret';
+ caret.style.borderColor = color;
+ const label = document.createElement('span');
+ label.className = 'collab-caret__label';
+ label.style.backgroundColor = color;
+ label.textContent = String(user.name ?? '');
+ caret.appendChild(label);
+ return caret;
+}
+
+function renderSelection(user: Record): Record {
+ return {
+ nodeName: 'span',
+ class: 'collab-selection',
+ // 8-digit hex adds ~20% alpha so the selection tint stays subtle.
+ style: user.readOnly
+ ? 'background-color: transparent'
+ : `background-color: ${String(user.color)}33`,
+ };
+}
+
+/** The collaboration-caret extension configured for the current user. */
+export function collaborationCaretFor(
+ provider: HocuspocusProvider,
+ user: { id: string; displayName: string },
+ readOnly: boolean,
+) {
+ const caretUser: CaretUser = {
+ userId: user.id,
+ name: user.displayName,
+ color: colorForUser(user.id),
+ readOnly,
+ };
+ return CollaborationCaret.configure({
+ provider,
+ user: caretUser,
+ render: renderCaret,
+ selectionRender: renderSelection,
+ });
+}
diff --git a/apps/web/src/editor/use-presence.ts b/apps/web/src/editor/use-presence.ts
new file mode 100644
index 0000000..61c33e4
--- /dev/null
+++ b/apps/web/src/editor/use-presence.ts
@@ -0,0 +1,43 @@
+import type { HocuspocusProvider } from '@hocuspocus/provider';
+import { useEffect, useState } from 'react';
+
+import type { CaretUser } from './collaboration-caret';
+
+export type Participant = CaretUser;
+
+/**
+ * The current participants on a page, derived from the collab awareness states
+ * (issue #37). Deduplicated by user id (a user with two tabs is one avatar) and
+ * updated whenever awareness changes, so a disconnect drops the participant
+ * within seconds.
+ */
+export function usePresence(provider: HocuspocusProvider | null): Participant[] {
+ const [participants, setParticipants] = useState([]);
+
+ useEffect(() => {
+ const awareness = provider?.awareness;
+ if (!awareness) {
+ setParticipants([]);
+ return;
+ }
+ const update = (): void => {
+ const byUser = new Map();
+ for (const state of awareness.getStates().values()) {
+ const user = (state as { user?: Partial }).user;
+ if (!user?.userId || byUser.has(user.userId)) continue;
+ byUser.set(user.userId, {
+ userId: user.userId,
+ name: user.name ?? '',
+ color: user.color ?? '#495057',
+ readOnly: Boolean(user.readOnly),
+ });
+ }
+ setParticipants([...byUser.values()]);
+ };
+ update();
+ awareness.on('change', update);
+ return () => awareness.off('change', update);
+ }, [provider]);
+
+ return participants;
+}
diff --git a/apps/web/src/editor/user-color.test.ts b/apps/web/src/editor/user-color.test.ts
new file mode 100644
index 0000000..9fea94f
--- /dev/null
+++ b/apps/web/src/editor/user-color.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+
+import { colorForUser, USER_COLORS } from './user-color';
+
+/** WCAG relative luminance of an sRGB hex colour. */
+function luminance(hex: string): number {
+ const channels = [1, 3, 5].map((i) => {
+ const c = parseInt(hex.slice(i, i + 2), 16) / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
+}
+
+/** Contrast ratio between two colours (WCAG 2.x). */
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi! + 0.05) / (lo! + 0.05);
+}
+
+describe('user colours', () => {
+ it('assigns a stable colour for the same user id', () => {
+ expect(colorForUser('user-abc')).toBe(colorForUser('user-abc'));
+ expect(colorForUser('11111111-2222-3333-4444-555555555555')).toBe(
+ colorForUser('11111111-2222-3333-4444-555555555555'),
+ );
+ });
+
+ it('every palette colour clears AA contrast against white label text', () => {
+ for (const color of USER_COLORS) {
+ expect(contrast('#ffffff', color), `contrast for ${color}`).toBeGreaterThanOrEqual(4.5);
+ }
+ });
+
+ it('spreads ids across more than one colour', () => {
+ const used = new Set(Array.from({ length: 50 }, (_, i) => colorForUser(`user-${i}`)));
+ expect(used.size).toBeGreaterThan(1);
+ });
+});
diff --git a/apps/web/src/editor/user-color.ts b/apps/web/src/editor/user-color.ts
new file mode 100644
index 0000000..ad1288e
--- /dev/null
+++ b/apps/web/src/editor/user-color.ts
@@ -0,0 +1,32 @@
+/**
+ * Stable, distinguishable colours for collaboration cursors and presence
+ * avatars (issue #37). Each colour is dark enough that white label text on it
+ * clears the WCAG AA contrast ratio (4.5:1) — asserted in `user-color.test.ts`.
+ * The palette is deliberately small and hand-picked rather than generated, so
+ * every colour is legible and the set stays visually balanced.
+ */
+export const USER_COLORS = [
+ '#1a5fb4', // blue
+ '#26734d', // green
+ '#6c3fb5', // purple
+ '#b02a37', // red
+ '#9a4a00', // brown
+ '#a03672', // magenta
+ '#0e6b7d', // teal
+ '#495057', // slate
+] as const;
+
+/** A small, stable string hash (FNV-1a, 32-bit) — deterministic across sessions. */
+function hashString(value: string): number {
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < value.length; i += 1) {
+ hash ^= value.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return hash >>> 0;
+}
+
+/** The palette colour assigned to a user id — stable for the same id. */
+export function colorForUser(userId: string): string {
+ return USER_COLORS[hashString(userId) % USER_COLORS.length]!;
+}
diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx
index 9659ca0..f9f3712 100644
--- a/apps/web/src/pages/PageEditorPage.tsx
+++ b/apps/web/src/pages/PageEditorPage.tsx
@@ -7,9 +7,12 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router-dom';
import * as Y from 'yjs';
+import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
+import { collaborationCaretFor } from '../editor/collaboration-caret';
import { documentExtensions } from '../editor/document-extensions';
import { ImageUpload } from '../editor/image-upload';
+import { PresenceStrip } from '../editor/PresenceStrip';
import { Toolbar } from '../editor/Toolbar';
import { useCollabProvider } from '../editor/use-collab-provider';
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
@@ -19,6 +22,7 @@ type Mode = 'view' | 'edit';
function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React.JSX.Element {
const { t } = useTranslation('editor');
+ const { user } = useAuth();
// Created and destroyed within the same effect (not `useMemo` + a separate
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
@@ -45,18 +49,23 @@ function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React.
{
// `documentExtensions` alone is a valid (uncollaborated) schema, so the
// editor never gets built without its 'doc'/'paragraph'/'text' nodes
- // while `ydoc` is still being created (see the effect above).
+ // while `ydoc` is still being created (see the effect above). The
+ // collaboration-caret extension is added once the provider exists, so
+ // remote carets and presence share the same awareness (#37).
extensions: ydoc
? [
...documentExtensions,
ImageUpload.configure({ pondId: page.pondId }),
Collaboration.configure({ document: ydoc, field: 'default' }),
+ ...(collab.provider && user
+ ? [collaborationCaretFor(collab.provider, user, readOnly)]
+ : []),
]
: documentExtensions,
editable: canEdit,
immediatelyRender: false,
},
- [ydoc],
+ [ydoc, collab.provider, readOnly, user?.id],
);
useLayoutEffect(() => {
@@ -71,6 +80,7 @@ function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React.
{t(`connection.${collab.status}`)}
+
{mode === 'edit' && readOnly && (
{t('readOnly.notice')}
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css
index 81a1ec2..5ace071 100644
--- a/apps/web/src/styles/base.css
+++ b/apps/web/src/styles/base.css
@@ -553,6 +553,76 @@ button {
color: var(--color-danger);
}
+.presence-strip {
+ display: flex;
+ gap: calc(-1 * var(--space-1));
+ align-items: center;
+ padding: var(--space-1) var(--space-3);
+}
+
+.presence-avatar {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ margin-left: -0.4rem;
+ border: 2px solid var(--color-bg);
+ border-radius: 50%;
+ color: #ffffff;
+ font-size: 0.7rem;
+ font-weight: 600;
+}
+
+.presence-avatar:first-child {
+ margin-left: 0;
+}
+
+.presence-avatar--overflow {
+ background: var(--color-text-muted);
+}
+
+.presence-avatar__ro {
+ position: absolute;
+ right: -0.15rem;
+ bottom: -0.15rem;
+ font-size: 0.75rem;
+ line-height: 1;
+}
+
+/* Remote collaboration carets and selections (issue #37). */
+.collab-caret {
+ position: relative;
+ margin-left: -1px;
+ margin-right: -1px;
+ border-left: 1px solid;
+ border-right: 1px solid;
+ word-break: normal;
+ pointer-events: none;
+}
+
+.collab-caret--hidden {
+ display: none;
+}
+
+.collab-caret__label {
+ position: absolute;
+ top: -1.4em;
+ left: -1px;
+ padding: 0.05rem 0.3rem;
+ border-radius: 3px 3px 3px 0;
+ color: #ffffff;
+ font-size: 0.7rem;
+ font-weight: 600;
+ white-space: nowrap;
+ user-select: none;
+}
+
+.collab-selection {
+ border-radius: 2px;
+}
+
.editor-banner {
margin: var(--space-1) var(--space-3);
padding: var(--space-2) var(--space-3);
diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json
index 32a7d67..4e2a3d7 100644
--- a/packages/shared/i18n/de/editor.json
+++ b/packages/shared/i18n/de/editor.json
@@ -20,6 +20,10 @@
"tooLarge": {
"notice": "Diese Seite hat ihre maximale Größe erreicht, daher wurde deine letzte Änderung nicht gespeichert. Bitte entferne etwas Inhalt."
},
+ "presence": {
+ "label": "{{count}} Personen auf dieser Seite",
+ "viewer": "{{name}} (nur Lesen)"
+ },
"toolbar": {
"paragraph": "Absatz",
"heading1": "Überschrift 1",
diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json
index a64bbb7..dd34544 100644
--- a/packages/shared/i18n/en/editor.json
+++ b/packages/shared/i18n/en/editor.json
@@ -20,6 +20,10 @@
"tooLarge": {
"notice": "This page has reached its maximum size, so your latest change wasn't saved. Please remove some content."
},
+ "presence": {
+ "label": "{{count}} people on this page",
+ "viewer": "{{name}} (read-only)"
+ },
"toolbar": {
"paragraph": "Paragraph",
"heading1": "Heading 1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4e264cc..d217073 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -208,6 +208,9 @@ importers:
'@tiptap/extension-collaboration':
specifier: ^3.27.1
version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31)
+ '@tiptap/extension-collaboration-caret':
+ specifier: ^3.27.1
+ version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))
'@tiptap/pm':
specifier: ^3.27.1
version: 3.27.1
@@ -1585,6 +1588,13 @@ packages:
'@tiptap/core': 3.27.1
'@tiptap/pm': 3.27.1
+ '@tiptap/extension-collaboration-caret@3.27.1':
+ resolution: {integrity: sha512-mKyYMBkgQGWrlZLGKxQvPP28WaTeZOMUfQg0a6KBnexM4vhX7q2xqAGqCsCFaczQaTPsaJ+/6Bela9Ueh0sPdQ==}
+ peerDependencies:
+ '@tiptap/core': 3.27.1
+ '@tiptap/pm': 3.27.1
+ '@tiptap/y-tiptap': ^3.0.5
+
'@tiptap/extension-collaboration@3.27.1':
resolution: {integrity: sha512-Da7WeKNIaLsbcHBWlgexMgm5ygoA1mhRroFND1vweLNsWIPxvyjci7jrq/uDN1tSnpqMlJsdyW0tXzbVGYTpMw==}
peerDependencies:
@@ -5146,6 +5156,12 @@ snapshots:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
'@tiptap/pm': 3.27.1
+ '@tiptap/extension-collaboration-caret@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))':
+ dependencies:
+ '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
+ '@tiptap/pm': 3.27.1
+ '@tiptap/y-tiptap': 3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
+
'@tiptap/extension-collaboration@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/y-tiptap@3.0.6(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31)':
dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)