docster/participant-pointers.md

1752 lines
59 KiB
Markdown

# Participant Collaboration Design: List-First with Optional Cursors
## Executive Summary
After implementing cursor presence (Story 3), we gained valuable insights about collaboration awareness in drawing applications. While remote cursors provide rich feedback, they can create visual complexity that interferes with the primary drawing experience.
This design proposes a **participant list-first approach** where collaboration awareness is primarily communicated through a clean participant list UI, with optional remote cursors as progressive enhancement. The system uses `ParticipantsState` as the foundation for all collaboration features and maintains the tool-aware cursor architecture for when cursors are enabled.
### Collaboration Challenges
1. **Visual Complexity** - Multiple cursors can clutter the drawing canvas and distract from primary tasks
2. **Information Overload** - High-frequency cursor updates can overwhelm users during complex drawing
3. **Essential vs Optional** - Need to distinguish between essential collaboration awareness and optional visual feedback
4. **Canvas Clarity** - Drawing applications benefit from clean, unobscured workspace
5. **Progressive Enhancement** - Users should get immediate collaboration value with option for richer feedback
### Proposed Solution
Implement a **participant list-first collaboration system** with optional enhanced cursors:
- **Primary UI**: Clean participant list showing active collaborators with status indicators
- **ParticipantsState**: Centralized state management for all participant data and events
- **Optional Cursors**: Tool-aware remote cursor system as progressive enhancement
- **Toggle Control**: Users can enable/disable remote cursors based on preference and context
## Participant-First Architecture
### Core Components
```mermaid
graph TB
subgraph "Primary Collaboration UI"
PL[ParticipantList Component]
PS[ParticipantsState]
PI[ParticipantIndicators]
end
subgraph "Optional Cursor System"
TPC[ToolPointerCoordinator]
CT[CursorToggle]
FCS[FrequencyControlSystem]
end
subgraph "Plugin Handlers (When Cursors Enabled)"
EPH[ElementDragPointerHandler]
LPH[LineToolPointerHandler]
CPH[ConnectionToolPointerHandler]
NPH[NavigationPointerHandler]
end
subgraph "Phoenix Integration"
PP[Phoenix Presence]
LV[LiveView Events]
BC[Broadcast Channel]
end
PS --> PL
PS --> PI
PS <--> PP
PS --> TPC
CT --> TPC
TPC --> EPH
TPC --> LPH
TPC --> CPH
TPC --> NPH
PP <--> LV
LV <--> BC
```
### Data Flow
1. **Participant joins** → Phoenix Presence → ParticipantsState → ParticipantList update
2. **Activity indicators** → Tool/interaction events → ParticipantsState → Status indicators
3. **Optional cursors** → CursorToggle → ToolPointerCoordinator → Smart positioning
4. **Presence updates** → Phoenix Presence → Real-time participant list updates
5. **Clean canvas** → Cursor-free drawing experience with sidebar collaboration awareness
## Implementation Design
### 1. ParticipantsState - Foundation Layer
```typescript
// Core participant management - foundation for all collaboration features
export class ParticipantsState {
private participants = new Map<string, ParticipantContext>()
private currentSessionId: string
private presenceCallbacks = new Set<ParticipantPresenceCallback>()
private activityCallbacks = new Set<ParticipantActivityCallback>()
constructor(sessionId: string) {
this.currentSessionId = sessionId
}
// Core participant management
addParticipant(participant: ParticipantContext): void {
this.participants.set(participant.sessionId, participant)
this.notifyPresenceChange('joined', participant)
}
removeParticipant(sessionId: string): void {
const participant = this.participants.get(sessionId)
if (participant) {
this.participants.delete(sessionId)
this.notifyPresenceChange('left', participant)
}
}
updateParticipantActivity(sessionId: string, activity: ParticipantActivity): void {
const participant = this.participants.get(sessionId)
if (participant) {
const updated = { ...participant, lastActivity: activity, lastSeen: Date.now() }
this.participants.set(sessionId, updated)
this.notifyActivityChange(updated, activity)
}
}
// UI subscription methods
onPresenceChange(callback: ParticipantPresenceCallback): () => void {
this.presenceCallbacks.add(callback)
return () => this.presenceCallbacks.delete(callback)
}
onActivityChange(callback: ParticipantActivityCallback): () => void {
this.activityCallbacks.add(callback)
return () => this.activityCallbacks.delete(callback)
}
// Data access for UI components
getAllParticipants(): ParticipantContext[] {
return Array.from(this.participants.values())
}
getActiveParticipants(): ParticipantContext[] {
const now = Date.now()
return this.getAllParticipants().filter(p =>
(now - p.lastSeen) < PARTICIPANT_TIMEOUT_MS
)
}
getParticipantCount(): number {
return this.getActiveParticipants().length
}
private notifyPresenceChange(type: 'joined' | 'left', participant: ParticipantContext): void {
this.presenceCallbacks.forEach(callback => callback(type, participant))
}
private notifyActivityChange(participant: ParticipantContext, activity: ParticipantActivity): void {
this.activityCallbacks.forEach(callback => callback(participant, activity))
}
}
// Enhanced participant context with activity tracking
interface ParticipantContext {
readonly sessionId: string
readonly name: string
readonly color: string
readonly joinedAt: number
readonly lastSeen: number
readonly lastActivity?: ParticipantActivity
readonly metadata?: ParticipantMetadata
}
interface ParticipantActivity {
readonly type: 'drawing' | 'dragging' | 'connecting' | 'navigating' | 'idle'
readonly toolType?: string
readonly timestamp: number
readonly elementId?: string // For drag/connect activities
}
interface ParticipantMetadata {
readonly avatarUrl?: string
readonly role?: 'host' | 'participant' | 'observer'
readonly preferences?: UserPreferences
}
interface UserPreferences {
readonly showCursors: boolean
readonly cursorStyle: 'minimal' | 'detailed'
readonly activityNotifications: boolean
}
// Callback types for UI reactivity
type ParticipantPresenceCallback = (type: 'joined' | 'left', participant: ParticipantContext) => void
type ParticipantActivityCallback = (participant: ParticipantContext, activity: ParticipantActivity) => void
const PARTICIPANT_TIMEOUT_MS = 30000 // 30 seconds
```
### 2. ParticipantList UI Component
```typescript
// Primary collaboration UI - participant list with activity indicators
export class ParticipantListComponent {
private participantsState: ParticipantsState
private element: HTMLElement
private unsubscribers: (() => void)[] = []
constructor(participantsState: ParticipantsState, container: HTMLElement) {
this.participantsState = participantsState
this.element = this.createElement()
container.appendChild(this.element)
this.setupSubscriptions()
this.render()
}
private setupSubscriptions(): void {
// React to participant presence changes
this.unsubscribers.push(
this.participantsState.onPresenceChange((type, participant) => {
this.handlePresenceChange(type, participant)
this.render()
})
)
// React to participant activity changes
this.unsubscribers.push(
this.participantsState.onActivityChange((participant, activity) => {
this.updateParticipantActivity(participant, activity)
})
)
}
private render(): void {
const participants = this.participantsState.getActiveParticipants()
this.element.innerHTML = `
<div class="participant-list">
<div class="participant-header">
<h3>Collaborators (${participants.length})</h3>
<button class="cursor-toggle" title="Toggle remote cursors">
👁️
</button>
</div>
<div class="participant-items">
${participants.map(p => this.renderParticipant(p)).join('')}
</div>
</div>
`
this.setupEventHandlers()
}
private renderParticipant(participant: ParticipantContext): string {
const activityIcon = this.getActivityIcon(participant.lastActivity)
const activityText = this.getActivityText(participant.lastActivity)
const isCurrentUser = participant.sessionId === this.participantsState.getCurrentSessionId()
return `
<div class="participant-item ${isCurrentUser ? 'current-user' : ''}"
data-session-id="${participant.sessionId}">
<div class="participant-avatar" style="background-color: ${participant.color}">
${participant.name.charAt(0).toUpperCase()}
</div>
<div class="participant-info">
<div class="participant-name">
${participant.name}${isCurrentUser ? ' (You)' : ''}
</div>
<div class="participant-activity">
<span class="activity-icon">${activityIcon}</span>
<span class="activity-text">${activityText}</span>
</div>
</div>
<div class="participant-status ${this.getConnectionStatus(participant)}"></div>
</div>
`
}
private getActivityIcon(activity?: ParticipantActivity): string {
if (!activity) return '💤'
switch (activity.type) {
case 'drawing': return '✏️'
case 'dragging': return '🤏'
case 'connecting': return '🔗'
case 'navigating': return '👆'
case 'idle': return '💤'
default: return '👆'
}
}
private getActivityText(activity?: ParticipantActivity): string {
if (!activity) return 'Idle'
const timeSince = Date.now() - activity.timestamp
if (timeSince > 10000) return 'Idle'
switch (activity.type) {
case 'drawing': return `Drawing${activity.toolType ? ` with ${activity.toolType}` : ''}`
case 'dragging': return 'Moving element'
case 'connecting': return 'Creating connection'
case 'navigating': return 'Active'
case 'idle': return 'Idle'
default: return 'Active'
}
}
private getConnectionStatus(participant: ParticipantContext): string {
const timeSince = Date.now() - participant.lastSeen
if (timeSince < 5000) return 'online'
if (timeSince < 30000) return 'away'
return 'offline'
}
private handlePresenceChange(type: 'joined' | 'left', participant: ParticipantContext): void {
// Show subtle notification for joins/leaves
this.showPresenceNotification(type, participant)
}
private updateParticipantActivity(participant: ParticipantContext, activity: ParticipantActivity): void {
// Update specific participant's activity indicator without full re-render
const element = this.element.querySelector(`[data-session-id="${participant.sessionId}"]`)
if (element) {
const activityIcon = element.querySelector('.activity-icon')
const activityText = element.querySelector('.activity-text')
if (activityIcon) activityIcon.textContent = this.getActivityIcon(activity)
if (activityText) activityText.textContent = this.getActivityText(activity)
}
}
private setupEventHandlers(): void {
const cursorToggle = this.element.querySelector('.cursor-toggle')
if (cursorToggle) {
cursorToggle.addEventListener('click', () => {
this.toggleCursors()
})
}
}
private toggleCursors(): void {
// Toggle cursor display and update UI
const cursorsEnabled = this.getCursorsEnabled()
this.setCursorsEnabled(!cursorsEnabled)
// Update toggle button appearance
const toggle = this.element.querySelector('.cursor-toggle')
if (toggle) {
toggle.textContent = cursorsEnabled ? '👁️' : '🙈'
toggle.title = cursorsEnabled ? 'Show remote cursors' : 'Hide remote cursors'
}
}
private getCursorsEnabled(): boolean {
// Get cursor preference from user settings or local storage
return localStorage.getItem('showRemoteCursors') === 'true'
}
private setCursorsEnabled(enabled: boolean): void {
localStorage.setItem('showRemoteCursors', enabled.toString())
// Notify cursor system of preference change
window.dispatchEvent(new CustomEvent('cursorsToggled', { detail: { enabled } }))
}
destroy(): void {
this.unsubscribers.forEach(unsubscribe => unsubscribe())
this.element.remove()
}
}
```
### 3. Optional Cursor System Integration
```typescript
// Enhanced cursor coordinator that respects user preferences
export class OptionalCursorCoordinator {
private toolPointerCoordinator: ToolPointerCoordinator
private participantsState: ParticipantsState
private cursorsEnabled: boolean = false
private cursorElements = new Map<string, HTMLElement>()
constructor(participantsState: ParticipantsState, canvas: HTMLCanvasElement) {
this.participantsState = participantsState
this.toolPointerCoordinator = new ToolPointerCoordinator()
this.cursorsEnabled = this.getCursorPreference()
this.setupEventListeners()
this.setupParticipantSubscriptions()
}
private setupEventListeners(): void {
// Listen for cursor toggle events from participant list
window.addEventListener('cursorsToggled', (event: CustomEvent) => {
this.cursorsEnabled = event.detail.enabled
if (!this.cursorsEnabled) {
this.hideAllCursors()
}
})
}
private setupParticipantSubscriptions(): void {
// Clean up cursors when participants leave
this.participantsState.onPresenceChange((type, participant) => {
if (type === 'left') {
this.removeCursor(participant.sessionId)
}
})
}
processPointerUpdate(rawPosition: Point, toolContext: ToolContext): void {
// Only process cursor updates if cursors are enabled
if (!this.cursorsEnabled) return
const pointerState = this.toolPointerCoordinator.processPointerUpdate(
rawPosition,
toolContext
)
if (pointerState) {
this.renderCursor(pointerState)
// Update participant activity in state
this.participantsState.updateParticipantActivity(
toolContext.participantId,
{
type: toolContext.interactionState.isDragging ? 'dragging' :
toolContext.interactionState.isDrawing ? 'drawing' :
toolContext.interactionState.isConnecting ? 'connecting' : 'navigating',
toolType: toolContext.toolType,
timestamp: Date.now(),
elementId: toolContext.draggedElement?.id
}
)
}
}
private renderCursor(pointerState: RemotePointerState): void {
if (!this.cursorsEnabled) return
const participant = this.participantsState.getParticipant(pointerState.participantId)
if (!participant) return
let cursorElement = this.cursorElements.get(pointerState.participantId)
if (!cursorElement) {
cursorElement = this.createCursorElement(participant)
this.cursorElements.set(pointerState.participantId, cursorElement)
}
this.updateCursorPosition(cursorElement, pointerState, participant)
}
private createCursorElement(participant: ParticipantContext): HTMLElement {
const cursor = document.createElement('div')
cursor.className = 'remote-cursor'
cursor.style.cssText = `
position: absolute;
pointer-events: none;
z-index: 1000;
transition: transform 0.1s ease-out;
`
cursor.innerHTML = `
<div class="cursor-pointer" style="
width: 12px;
height: 12px;
background: ${participant.color};
border: 2px solid white;
border-radius: 50%;
transform: translate(-50%, -50%);
"></div>
<div class="cursor-label" style="
position: absolute;
top: 15px;
left: 15px;
background: ${participant.color};
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
">${participant.name}</div>
`
document.body.appendChild(cursor)
return cursor
}
private updateCursorPosition(
element: HTMLElement,
pointerState: RemotePointerState,
participant: ParticipantContext
): void {
const { calculatedPosition } = pointerState
element.style.transform = `translate(${calculatedPosition.x}px, ${calculatedPosition.y}px)`
// Update cursor style based on activity
const pointer = element.querySelector('.cursor-pointer') as HTMLElement
if (pointer) {
pointer.style.transform = pointerState.type === 'element_drag_pointer' ?
'translate(-50%, -50%) scale(1.2)' :
'translate(-50%, -50%) scale(1.0)'
}
}
private hideAllCursors(): void {
this.cursorElements.forEach(element => {
element.style.display = 'none'
})
}
private removeCursor(sessionId: string): void {
const element = this.cursorElements.get(sessionId)
if (element) {
element.remove()
this.cursorElements.delete(sessionId)
}
}
private getCursorPreference(): boolean {
return localStorage.getItem('showRemoteCursors') === 'true'
}
}
```
### 4. Pointer State Interfaces (When Cursors Enabled)
```typescript
// Base interface for tool-aware pointer states
interface RemotePointerState {
readonly type: string
readonly participantId: string
readonly timestamp: number
readonly rawPosition: Point
readonly calculatedPosition: Point
readonly frequency: UpdateFrequency
}
// Tool-specific pointer state extensions
interface ElementDragPointerState extends RemotePointerState {
type: 'element_drag_pointer'
elementId: string
dragOffset: Vector2D
edgePosition: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
}
interface LineToolPointerState extends RemotePointerState {
type: 'line_tool_pointer'
lineStartPoint?: Point
currentSegment?: LineSegment
offsetDirection: Vector2D
showProgress: boolean
}
interface ConnectionPointerState extends RemotePointerState {
type: 'connection_pointer'
sourceElementId: string
sourcePosition: Point
targetElementId?: string
showTargeting: boolean
}
interface NavigationPointerState extends RemotePointerState {
type: 'navigation_pointer'
velocity: Vector2D
isIdle: boolean
}
// Update frequency control
enum UpdateFrequency {
HIGH = 60, // Active drawing/dragging
MEDIUM = 30, // Tool targeting/preview
LOW = 10, // Navigation/idle
ADAPTIVE = 0 // Dynamically determined
}
```
### 2. Tool-Specific Pointer Handlers
```typescript
// Base handler interface
interface PointerHandler<T extends RemotePointerState> {
canHandle(toolType: string, interactionState: InteractionState): boolean
calculatePosition(rawPosition: Point, context: ToolContext): Point
determineFrequency(interactionState: InteractionState): UpdateFrequency
createPointerState(rawPosition: Point, context: ToolContext): T
render(state: T, renderer: PointerRenderer): void
}
// Element drag handler - positions cursor at element edge
class ElementDragPointerHandler implements PointerHandler<ElementDragPointerState> {
canHandle(toolType: string, interactionState: InteractionState): boolean {
return toolType === 'element_tool' && interactionState.isDragging
}
calculatePosition(rawPosition: Point, context: ToolContext): Point {
const element = context.draggedElement
if (!element) return rawPosition
// Position cursor at top-left edge to show ownership without obscuring content
const bounds = element.getBounds()
return {
x: bounds.x - 20, // Offset left of element
y: bounds.y - 10 // Offset above element
}
}
determineFrequency(interactionState: InteractionState): UpdateFrequency {
return interactionState.dragVelocity > 50 ? UpdateFrequency.HIGH : UpdateFrequency.MEDIUM
}
createPointerState(rawPosition: Point, context: ToolContext): ElementDragPointerState {
const calculatedPosition = this.calculatePosition(rawPosition, context)
return {
type: 'element_drag_pointer',
participantId: context.participantId,
timestamp: Date.now(),
rawPosition,
calculatedPosition,
frequency: this.determineFrequency(context.interactionState),
elementId: context.draggedElement.id,
dragOffset: Vector2D.subtract(rawPosition, context.draggedElement.getCenter()),
edgePosition: 'top-left'
}
}
render(state: ElementDragPointerState, renderer: PointerRenderer): void {
const element = renderer.getElement(state.elementId)
// Render cursor at calculated position
renderer.renderCursor(state.calculatedPosition, {
participantId: state.participantId,
style: 'drag',
showTooltip: true
})
// Show drag indicator connecting cursor to element
renderer.renderDragIndicator(state.calculatedPosition, element.getCenter(), {
dashPattern: [5, 5],
opacity: 0.6
})
}
}
// Line tool handler - offsets cursor from line tip
class LineToolPointerHandler implements PointerHandler<LineToolPointerState> {
canHandle(toolType: string, interactionState: InteractionState): boolean {
return toolType === 'line_tool' && interactionState.isDrawing
}
calculatePosition(rawPosition: Point, context: ToolContext): Point {
if (!context.lineStartPoint) return rawPosition
// Calculate offset direction perpendicular to line direction
const lineDirection = Vector2D.subtract(rawPosition, context.lineStartPoint)
const lineLength = Vector2D.magnitude(lineDirection)
if (lineLength < 10) return rawPosition // Too short to offset
// Offset cursor perpendicular to line direction
const normalizedDirection = Vector2D.normalize(lineDirection)
const perpendicularDirection = { x: -normalizedDirection.y, y: normalizedDirection.x }
const offset = Vector2D.multiply(perpendicularDirection, 25) // 25px offset
return Vector2D.add(rawPosition, offset)
}
determineFrequency(interactionState: InteractionState): UpdateFrequency {
// Use medium frequency to avoid overwhelming line drawing
return UpdateFrequency.MEDIUM
}
createPointerState(rawPosition: Point, context: ToolContext): LineToolPointerState {
const calculatedPosition = this.calculatePosition(rawPosition, context)
return {
type: 'line_tool_pointer',
participantId: context.participantId,
timestamp: Date.now(),
rawPosition,
calculatedPosition,
frequency: this.determineFrequency(context.interactionState),
lineStartPoint: context.lineStartPoint,
currentSegment: context.currentLineSegment,
offsetDirection: Vector2D.subtract(calculatedPosition, rawPosition),
showProgress: true
}
}
render(state: LineToolPointerState, renderer: PointerRenderer): void {
// Render cursor at offset position
renderer.renderCursor(state.calculatedPosition, {
participantId: state.participantId,
style: 'line-drawing',
showTooltip: true
})
// Show line progress if available
if (state.showProgress && state.lineStartPoint) {
renderer.renderLineProgress(state.lineStartPoint, state.rawPosition, {
participantId: state.participantId,
style: 'preview',
opacity: 0.7
})
}
}
}
// Connection tool handler - positions at source element
class ConnectionPointerHandler implements PointerHandler<ConnectionPointerState> {
canHandle(toolType: string, interactionState: InteractionState): boolean {
return toolType === 'connection_tool' && interactionState.isConnecting
}
calculatePosition(rawPosition: Point, context: ToolContext): Point {
const sourceElement = context.connectionSource
if (!sourceElement) return rawPosition
// Position cursor at source element edge closest to target
const sourceCenter = sourceElement.getCenter()
const direction = Vector2D.subtract(rawPosition, sourceCenter)
const normalizedDirection = Vector2D.normalize(direction)
const sourceBounds = sourceElement.getBounds()
// Calculate edge point
const edgePoint = this.calculateEdgeIntersection(sourceCenter, normalizedDirection, sourceBounds)
return edgePoint
}
determineFrequency(interactionState: InteractionState): UpdateFrequency {
return interactionState.hasValidTarget ? UpdateFrequency.MEDIUM : UpdateFrequency.LOW
}
createPointerState(rawPosition: Point, context: ToolContext): ConnectionPointerState {
const calculatedPosition = this.calculatePosition(rawPosition, context)
return {
type: 'connection_pointer',
participantId: context.participantId,
timestamp: Date.now(),
rawPosition,
calculatedPosition,
frequency: this.determineFrequency(context.interactionState),
sourceElementId: context.connectionSource.id,
sourcePosition: calculatedPosition,
targetElementId: context.connectionTarget?.id,
showTargeting: context.interactionState.hasValidTarget
}
}
render(state: ConnectionPointerState, renderer: PointerRenderer): void {
// Render cursor at source position
renderer.renderCursor(state.sourcePosition, {
participantId: state.participantId,
style: 'connection-source',
showTooltip: true
})
// Show connection preview
renderer.renderConnectionPreview(state.sourcePosition, state.rawPosition, {
participantId: state.participantId,
validTarget: state.showTargeting,
opacity: 0.6
})
}
private calculateEdgeIntersection(center: Point, direction: Vector2D, bounds: Rectangle): Point {
// Calculate intersection of ray from center in direction with rectangle bounds
// Implementation details for edge intersection calculation
// Returns the point where the ray intersects the rectangle edge
return center // Simplified for brevity
}
}
```
### 3. Frequency Control System
```typescript
// Adaptive frequency control based on tool context and user activity
class FrequencyControlSystem {
private updateTimers = new Map<string, number>()
private lastUpdateTimes = new Map<string, number>()
private activityMonitor: ActivityMonitor
constructor() {
this.activityMonitor = new ActivityMonitor()
}
shouldUpdate(participantId: string, frequency: UpdateFrequency, force: boolean = false): boolean {
if (force) return true
const now = Date.now()
const lastUpdate = this.lastUpdateTimes.get(participantId) || 0
const intervalMs = frequency === UpdateFrequency.ADAPTIVE
? this.calculateAdaptiveInterval(participantId)
: 1000 / frequency
const shouldUpdate = (now - lastUpdate) >= intervalMs
if (shouldUpdate) {
this.lastUpdateTimes.set(participantId, now)
}
return shouldUpdate
}
private calculateAdaptiveInterval(participantId: string): number {
const activity = this.activityMonitor.getActivity(participantId)
// High activity (fast movements, active tool use): higher frequency
if (activity.velocity > 100 || activity.toolActive) {
return 1000 / UpdateFrequency.HIGH
}
// Medium activity (moderate movement, tool targeting): medium frequency
if (activity.velocity > 20 || activity.isTargeting) {
return 1000 / UpdateFrequency.MEDIUM
}
// Low activity (slow movement, navigation): low frequency
return 1000 / UpdateFrequency.LOW
}
registerActivity(participantId: string, activity: ActivityData): void {
this.activityMonitor.recordActivity(participantId, activity)
}
}
interface ActivityData {
velocity: number
toolActive: boolean
isTargeting: boolean
interactionType: 'drawing' | 'dragging' | 'connecting' | 'navigating'
}
class ActivityMonitor {
private activityHistory = new Map<string, ActivityData[]>()
private readonly HISTORY_LIMIT = 10
recordActivity(participantId: string, activity: ActivityData): void {
const history = this.activityHistory.get(participantId) || []
history.push(activity)
if (history.length > this.HISTORY_LIMIT) {
history.shift()
}
this.activityHistory.set(participantId, history)
}
getActivity(participantId: string): ActivityData {
const history = this.activityHistory.get(participantId) || []
if (history.length === 0) {
return { velocity: 0, toolActive: false, isTargeting: false, interactionType: 'navigating' }
}
// Calculate average activity over recent history
const avgVelocity = history.reduce((sum, act) => sum + act.velocity, 0) / history.length
const recentActivity = history[history.length - 1]
return {
velocity: avgVelocity,
toolActive: recentActivity.toolActive,
isTargeting: recentActivity.isTargeting,
interactionType: recentActivity.interactionType
}
}
}
```
### 4. Tool-Aware Pointer Coordinator
```typescript
export class ToolPointerCoordinator {
private handlers = new Map<string, PointerHandler<any>>()
private frequencyControl: FrequencyControlSystem
private currentTool: string = 'navigation'
private interactionState: InteractionState
constructor() {
this.frequencyControl = new FrequencyControlSystem()
this.interactionState = new InteractionState()
this.registerDefaultHandlers()
}
registerHandler<T extends RemotePointerState>(
toolType: string,
handler: PointerHandler<T>
): void {
this.handlers.set(toolType, handler)
}
processPointerUpdate(
rawPosition: Point,
toolContext: ToolContext,
force: boolean = false
): RemotePointerState | null {
const handler = this.getActiveHandler(toolContext.toolType, toolContext.interactionState)
if (!handler) return null
const pointerState = handler.createPointerState(rawPosition, toolContext)
// Check if update should be sent based on frequency control
const shouldUpdate = this.frequencyControl.shouldUpdate(
toolContext.participantId,
pointerState.frequency,
force
)
if (!shouldUpdate) return null
// Record activity for adaptive frequency calculation
this.frequencyControl.registerActivity(toolContext.participantId, {
velocity: toolContext.interactionState.velocity,
toolActive: toolContext.interactionState.isToolActive,
isTargeting: toolContext.interactionState.isTargeting,
interactionType: this.getInteractionType(toolContext.toolType, toolContext.interactionState)
})
return pointerState
}
private getActiveHandler(
toolType: string,
interactionState: InteractionState
): PointerHandler<any> | undefined {
// Find handler that can handle current tool and interaction state
for (const [type, handler] of this.handlers) {
if (handler.canHandle(toolType, interactionState)) {
return handler
}
}
// Fallback to navigation handler
return this.handlers.get('navigation')
}
private registerDefaultHandlers(): void {
this.registerHandler('element_drag', new ElementDragPointerHandler())
this.registerHandler('line_tool', new LineToolPointerHandler())
this.registerHandler('connection_tool', new ConnectionPointerHandler())
this.registerHandler('navigation', new NavigationPointerHandler())
}
private getInteractionType(
toolType: string,
interactionState: InteractionState
): ActivityData['interactionType'] {
if (interactionState.isDragging) return 'dragging'
if (interactionState.isDrawing) return 'drawing'
if (interactionState.isConnecting) return 'connecting'
return 'navigating'
}
}
// Supporting interfaces
interface ToolContext {
participantId: string
toolType: string
interactionState: InteractionState
draggedElement?: DraggableElement
lineStartPoint?: Point
currentLineSegment?: LineSegment
connectionSource?: Element
connectionTarget?: Element
}
class InteractionState {
isDragging: boolean = false
isDrawing: boolean = false
isConnecting: boolean = false
isTargeting: boolean = false
isToolActive: boolean = false
velocity: number = 0
dragVelocity: number = 0
hasValidTarget: boolean = false
updateFromPointerEvent(event: PointerEvent, previousPosition?: Point): void {
if (previousPosition) {
const distance = Vector2D.distance(
{ x: event.clientX, y: event.clientY },
previousPosition
)
this.velocity = distance / 16 // Assume 60fps, so ~16ms per frame
}
this.isToolActive = event.pressure > 0 || event.buttons > 0
}
}
```
### 3. ParticipantsState
```typescript
export class ParticipantsState {
private participants = new Map<string, ParticipantContext>();
private currentSessionId: string;
constructor(sessionId: string) {
this.currentSessionId = sessionId;
}
getCurrentParticipant(): ParticipantContext | undefined {
return this.participants.get(this.currentSessionId);
}
addParticipant(participant: ParticipantContext): void {
this.participants.set(participant.sessionId, participant);
}
removeParticipant(sessionId: string): void {
this.participants.delete(sessionId);
}
updateParticipant(updates: Partial<ParticipantContext>): void {
const current = this.getCurrentParticipant();
if (current) {
this.participants.set(this.currentSessionId, {
...current,
...updates
});
}
}
getAllParticipants(): ParticipantContext[] {
return Array.from(this.participants.values());
}
getParticipant(sessionId: string): ParticipantContext | undefined {
return this.participants.get(sessionId);
}
}
```
### 4. Enhanced Canvas Hook Integration
```typescript
// In canvas-hook.ts setupEnhancedEventHandlers
setupEnhancedEventHandlers(this: PhoenixHookContext, canvas: HTMLCanvasElement, adapters: CanvasAdapters) {
const eventCoordinator = adapters.events as ParticipantAwareEventCoordinator;
// Initialize participant from URL or session
const participantName = this.getParticipantName();
if (participantName) {
eventCoordinator.setParticipantInfo({
name: participantName,
sessionId: this.sessionId,
color: this.getUserColor()
});
}
// Enhanced pointer event handlers
const pointerMoveHandler = (e: PointerEvent) => {
const normalizedEvent = eventCoordinator.normalizePointerEvent(e);
// Send cursor move with participant context
this.pushEvent('cursor_move', {
type: 'cursor_move',
position: {
x: normalizedEvent.canvasPoint.x,
y: normalizedEvent.canvasPoint.y
},
participant: normalizedEvent.participantContext?.name,
sessionId: normalizedEvent.participantContext?.sessionId,
timestamp: normalizedEvent.timestamp
});
};
// ... rest of event handlers
}
```
## Testing Strategy
### 1. Tool-Specific Pointer Handler Tests
```typescript
describe('ElementDragPointerHandler', () => {
let handler: ElementDragPointerHandler
let mockElement: DraggableElement
let mockRenderer: PointerRenderer
beforeEach(() => {
handler = new ElementDragPointerHandler()
mockElement = createMockStickyNote('sticky-1', { x: 100, y: 100, width: 80, height: 60 })
mockRenderer = createMockPointerRenderer()
})
describe('position calculation', () => {
it('should position cursor at element top-left edge during drag', () => {
const rawPosition = { x: 140, y: 130 } // Inside element
const context = createToolContext({
toolType: 'element_tool',
draggedElement: mockElement,
interactionState: { isDragging: true }
})
const calculatedPosition = handler.calculatePosition(rawPosition, context)
// Should be offset from element bounds
expect(calculatedPosition).toEqual({
x: 80, // element.x - 20
y: 90 // element.y - 10
})
})
it('should return raw position when no element is being dragged', () => {
const rawPosition = { x: 200, y: 300 }
const context = createToolContext({
toolType: 'element_tool',
draggedElement: undefined
})
const calculatedPosition = handler.calculatePosition(rawPosition, context)
expect(calculatedPosition).toEqual(rawPosition)
})
})
describe('frequency determination', () => {
it('should use high frequency for fast drag movements', () => {
const interactionState = { isDragging: true, dragVelocity: 80 }
const frequency = handler.determineFrequency(interactionState)
expect(frequency).toBe(UpdateFrequency.HIGH)
})
it('should use medium frequency for slow drag movements', () => {
const interactionState = { isDragging: true, dragVelocity: 30 }
const frequency = handler.determineFrequency(interactionState)
expect(frequency).toBe(UpdateFrequency.MEDIUM)
})
})
describe('rendering', () => {
it('should render cursor and drag indicator', () => {
const state: ElementDragPointerState = {
type: 'element_drag_pointer',
participantId: 'alice',
timestamp: Date.now(),
rawPosition: { x: 140, y: 130 },
calculatedPosition: { x: 80, y: 90 },
frequency: UpdateFrequency.MEDIUM,
elementId: 'sticky-1',
dragOffset: { x: 40, y: 30 },
edgePosition: 'top-left'
}
handler.render(state, mockRenderer)
expect(mockRenderer.renderCursor).toHaveBeenCalledWith(
{ x: 80, y: 90 },
expect.objectContaining({
participantId: 'alice',
style: 'drag',
showTooltip: true
})
)
expect(mockRenderer.renderDragIndicator).toHaveBeenCalledWith(
{ x: 80, y: 90 }, // cursor position
{ x: 140, y: 130 }, // element center
expect.objectContaining({
dashPattern: [5, 5],
opacity: 0.6
})
)
})
})
})
describe('LineToolPointerHandler', () => {
let handler: LineToolPointerHandler
beforeEach(() => {
handler = new LineToolPointerHandler()
})
describe('position calculation', () => {
it('should offset cursor perpendicular to line direction', () => {
const rawPosition = { x: 150, y: 100 }
const context = createToolContext({
toolType: 'line_tool',
lineStartPoint: { x: 100, y: 100 },
interactionState: { isDrawing: true }
})
const calculatedPosition = handler.calculatePosition(rawPosition, context)
// Should be offset perpendicular to horizontal line
expect(calculatedPosition.x).toBe(150) // Same x as raw
expect(calculatedPosition.y).not.toBe(100) // Offset in y direction
expect(Math.abs(calculatedPosition.y - 100)).toBe(25) // 25px offset
})
it('should return raw position for very short lines', () => {
const rawPosition = { x: 105, y: 102 }
const context = createToolContext({
toolType: 'line_tool',
lineStartPoint: { x: 100, y: 100 },
interactionState: { isDrawing: true }
})
const calculatedPosition = handler.calculatePosition(rawPosition, context)
expect(calculatedPosition).toEqual(rawPosition)
})
})
describe('frequency control', () => {
it('should always use medium frequency to avoid overwhelming line drawing', () => {
const interactionState = { isDrawing: true, velocity: 120 }
const frequency = handler.determineFrequency(interactionState)
expect(frequency).toBe(UpdateFrequency.MEDIUM)
})
})
})
```
### 2. Frequency Control System Tests
```typescript
describe('FrequencyControlSystem', () => {
let frequencyControl: FrequencyControlSystem
beforeEach(() => {
frequencyControl = new FrequencyControlSystem()
jest.clearAllTimers()
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
})
describe('update throttling', () => {
it('should allow high frequency updates at correct intervals', () => {
const participantId = 'alice'
// First update should always be allowed
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.HIGH)).toBe(true)
// Immediate second update should be blocked
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.HIGH)).toBe(false)
// After 1000/60 = ~16ms, should allow update
jest.advanceTimersByTime(17)
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.HIGH)).toBe(true)
})
it('should allow forced updates regardless of frequency', () => {
const participantId = 'alice'
frequencyControl.shouldUpdate(participantId, UpdateFrequency.HIGH)
// Forced update should work immediately
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.HIGH, true)).toBe(true)
})
it('should handle different participants independently', () => {
frequencyControl.shouldUpdate('alice', UpdateFrequency.HIGH)
frequencyControl.shouldUpdate('bob', UpdateFrequency.HIGH)
// Both should be able to update independently
jest.advanceTimersByTime(17)
expect(frequencyControl.shouldUpdate('alice', UpdateFrequency.HIGH)).toBe(true)
expect(frequencyControl.shouldUpdate('bob', UpdateFrequency.HIGH)).toBe(true)
})
})
describe('adaptive frequency', () => {
it('should calculate high frequency for active tool use', () => {
const participantId = 'alice'
frequencyControl.registerActivity(participantId, {
velocity: 150,
toolActive: true,
isTargeting: false,
interactionType: 'drawing'
})
// First call to establish timing
frequencyControl.shouldUpdate(participantId, UpdateFrequency.ADAPTIVE)
// Should use high frequency interval
jest.advanceTimersByTime(17) // High frequency interval
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.ADAPTIVE)).toBe(true)
})
it('should calculate low frequency for idle navigation', () => {
const participantId = 'alice'
frequencyControl.registerActivity(participantId, {
velocity: 5,
toolActive: false,
isTargeting: false,
interactionType: 'navigating'
})
frequencyControl.shouldUpdate(participantId, UpdateFrequency.ADAPTIVE)
// Should require low frequency interval
jest.advanceTimersByTime(50) // Less than low frequency interval (100ms)
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.ADAPTIVE)).toBe(false)
jest.advanceTimersByTime(50) // Total 100ms = low frequency interval
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.ADAPTIVE)).toBe(true)
})
})
})
describe('ActivityMonitor', () => {
let monitor: ActivityMonitor
beforeEach(() => {
monitor = new ActivityMonitor()
})
it('should track activity history with rolling window', () => {
const participantId = 'alice'
// Record 15 activities (more than HISTORY_LIMIT of 10)
for (let i = 0; i < 15; i++) {
monitor.recordActivity(participantId, {
velocity: i * 10,
toolActive: i % 2 === 0,
isTargeting: false,
interactionType: 'drawing'
})
}
const activity = monitor.getActivity(participantId)
// Should average only the last 10 entries (velocities 50-140)
expect(activity.velocity).toBe(95) // Average of 50,60,70,80,90,100,110,120,130,140
expect(activity.toolActive).toBe(false) // Last entry (i=14, even)
})
it('should return default activity for unknown participant', () => {
const activity = monitor.getActivity('unknown')
expect(activity).toEqual({
velocity: 0,
toolActive: false,
isTargeting: false,
interactionType: 'navigating'
})
})
})
```
### 3. Integration Tests
```typescript
describe('ToolPointerCoordinator Integration', () => {
let coordinator: ToolPointerCoordinator
let mockRenderer: PointerRenderer
beforeEach(() => {
coordinator = new ToolPointerCoordinator()
mockRenderer = createMockPointerRenderer()
})
describe('handler selection and processing', () => {
it('should select appropriate handler based on tool and interaction state', () => {
const elementDragContext = createToolContext({
toolType: 'element_tool',
interactionState: { isDragging: true },
draggedElement: createMockStickyNote('sticky-1')
})
const pointerState = coordinator.processPointerUpdate(
{ x: 150, y: 200 },
elementDragContext
)
expect(pointerState?.type).toBe('element_drag_pointer')
expect(pointerState?.calculatedPosition).not.toEqual({ x: 150, y: 200 })
})
it('should fallback to navigation handler for unknown tools', () => {
const unknownToolContext = createToolContext({
toolType: 'unknown_tool',
interactionState: { isToolActive: false }
})
const pointerState = coordinator.processPointerUpdate(
{ x: 100, y: 100 },
unknownToolContext
)
expect(pointerState?.type).toBe('navigation_pointer')
})
it('should respect frequency control and suppress updates when appropriate', () => {
const context = createToolContext({
toolType: 'line_tool',
interactionState: { isDrawing: true }
})
// First update should work
const firstUpdate = coordinator.processPointerUpdate({ x: 100, y: 100 }, context)
expect(firstUpdate).not.toBeNull()
// Immediate second update should be suppressed
const secondUpdate = coordinator.processPointerUpdate({ x: 105, y: 105 }, context)
expect(secondUpdate).toBeNull()
})
})
describe('extensibility', () => {
it('should allow registration of custom handlers', () => {
class CustomToolHandler implements PointerHandler<NavigationPointerState> {
canHandle(toolType: string): boolean {
return toolType === 'custom_tool'
}
calculatePosition(rawPosition: Point): Point {
return { x: rawPosition.x + 50, y: rawPosition.y + 50 }
}
determineFrequency(): UpdateFrequency {
return UpdateFrequency.LOW
}
createPointerState(rawPosition: Point, context: ToolContext): NavigationPointerState {
return {
type: 'navigation_pointer',
participantId: context.participantId,
timestamp: Date.now(),
rawPosition,
calculatedPosition: this.calculatePosition(rawPosition),
frequency: UpdateFrequency.LOW,
velocity: { x: 0, y: 0 },
isIdle: false
}
}
render(): void {}
}
coordinator.registerHandler('custom_tool', new CustomToolHandler())
const customContext = createToolContext({
toolType: 'custom_tool'
})
const pointerState = coordinator.processPointerUpdate({ x: 100, y: 100 }, customContext)
expect(pointerState?.calculatedPosition).toEqual({ x: 150, y: 150 })
})
})
})
```
### 2. EventCoordinator Integration Tests
```typescript
describe('ParticipantAwareEventCoordinator', () => {
let coordinator: ParticipantAwareEventCoordinator;
let mockCanvas: HTMLCanvasElement;
beforeEach(() => {
mockCanvas = createMockCanvas();
coordinator = new ParticipantAwareEventCoordinator({
canvasElement: mockCanvas,
sessionId: 'test-session',
phoenixContext: createMockPhoenixContext()
});
});
it('should include participant context in normalized events', () => {
// Set participant info
coordinator.setParticipantInfo({
name: 'TestUser',
sessionId: 'test-session',
color: '#123456'
});
// Create mock pointer event
const pointerEvent = createMockPointerEvent(100, 200);
const normalized = coordinator.normalizePointerEvent(pointerEvent);
expect(normalized.participantContext).toEqual({
name: 'TestUser',
sessionId: 'test-session',
color: '#123456',
joinedAt: expect.any(Number)
});
});
it('should handle participant join/leave events', () => {
const joinEvent: UserJoinedEvent = {
sessionId: 'other-session',
participant: 'OtherUser',
color: '#654321',
timestamp: Date.now()
};
coordinator.handleParticipantJoined(joinEvent);
const participants = coordinator.getParticipantsState().getAllParticipants();
expect(participants).toHaveLength(1);
expect(participants[0].name).toBe('OtherUser');
// Test leave
coordinator.handleParticipantLeft({ sessionId: 'other-session', timestamp: Date.now() });
expect(coordinator.getParticipantsState().getAllParticipants()).toHaveLength(0);
});
});
```
### 3. Mock-Based Scenario Testing
```typescript
describe('Complex Participant Scenarios', () => {
it('should handle rapid participant join/leave', async () => {
const coordinator = createTestCoordinator();
const participants = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'];
// Rapid joins
for (const name of participants) {
coordinator.handleParticipantJoined(createJoinEvent(name));
await delay(10); // Simulate timing
}
expect(coordinator.getParticipantsState().getAllParticipants()).toHaveLength(5);
// Rapid leaves
for (const name of participants.slice(0, 3)) {
coordinator.handleParticipantLeft(createLeaveEvent(name));
await delay(10);
}
expect(coordinator.getParticipantsState().getAllParticipants()).toHaveLength(2);
});
it('should handle participant reconnection with same name', () => {
const coordinator = createTestCoordinator();
// Initial join
coordinator.handleParticipantJoined(createJoinEvent('Alice', 'session-1'));
// Disconnect
coordinator.handleParticipantLeft({ sessionId: 'session-1', timestamp: Date.now() });
// Reconnect with new session
coordinator.handleParticipantJoined(createJoinEvent('Alice', 'session-2'));
const participants = coordinator.getParticipantsState().getAllParticipants();
expect(participants).toHaveLength(1);
expect(participants[0].sessionId).toBe('session-2');
});
});
```
## Migration Path
### 1. Feature Flag Controlled Rollout
```typescript
// In canvas-hook.ts
if (isFeatureEnabled('PARTICIPANT_AWARE_EVENTS')) {
// Use ParticipantAwareEventCoordinator
const coordinator = new ParticipantAwareEventCoordinator(config);
// Enhanced participant handling
} else {
// Use standard EventCoordinator
const coordinator = new DOMEventCoordinator(config);
// Legacy URL parameter handling
}
```
### 2. Backward Compatibility
- Continue supporting `?participant=Name` URL parameters
- Maintain existing cursor_move event structure
- Graceful fallback when participant context is missing
### 3. Progressive Enhancement
```typescript
// Phase 1: Add participant context to events (backward compatible)
// Phase 2: Use participant context for rendering
// Phase 3: Deprecate URL parameter approach
// Phase 4: Remove legacy code
```
## Benefits & Implementation Strategy
### Benefits of Participant-First Approach
1. **Clean Canvas Experience**
- Unobstructed drawing workspace by default
- Collaboration awareness without visual interference
- Users can focus on their primary drawing tasks
2. **Immediate Collaboration Value**
- Participant list provides essential "who's here" information
- Activity indicators show what others are doing
- No complex setup or preferences required
3. **Progressive Enhancement**
- Start with simple, effective participant list
- Add cursor functionality as optional enhancement
- Users can toggle cursors based on preference/context
4. **Simplified Implementation**
- Single `ParticipantsState` foundation for all collaboration features
- Reduced network traffic when cursors disabled
- Easier testing and debugging with clear separation
5. **User Choice**
- Respects user preferences for visual complexity
- Adapts to different use cases (detailed design vs quick sketching)
- Maintains collaboration awareness regardless of cursor preference
### Implementation Phases
#### Phase 1: Participant List Foundation (Essential)
```typescript
// Priority 1: Core collaboration awareness
- Implement ParticipantsState class
- Build ParticipantList UI component
- Connect to Phoenix Presence for real-time updates
- Add activity status indicators
- Remove existing cursor rendering code
```
#### Phase 2: Activity Tracking (Enhanced Awareness)
```typescript
// Priority 2: Rich collaboration context
- Track tool usage and interaction states
- Update participant activity in real-time
- Show meaningful status ("Drawing with line tool", "Moving element")
- Add connection status indicators (online/away/offline)
```
#### Phase 3: Optional Cursors (Progressive Enhancement)
```typescript
// Priority 3: Visual enhancement for power users
- Implement cursor toggle in participant list
- Connect OptionalCursorCoordinator to existing tool system
- Preserve all tool-aware positioning logic
- Add user preference persistence
```
### Migration Strategy
#### Immediate: Replace Cursor-First with List-First
- **Remove**: Existing cursor rendering by default
- **Add**: ParticipantList component as primary collaboration UI
- **Keep**: All cursor logic for optional use
- **Benefit**: Cleaner canvas, better UX immediately
#### Progressive: Enhanced Activity Awareness
- **Leverage**: Existing tool system to track participant activity
- **Enhance**: ParticipantsState with real-time activity updates
- **Display**: Rich status information in participant list
- **Benefit**: Better collaboration context without visual clutter
#### Optional: Power User Features
- **Implement**: Cursor toggle for users who want visual cursors
- **Preserve**: All existing tool-aware cursor positioning
- **Optimize**: Only process cursor updates when enabled
- **Benefit**: Best of both worlds - clean by default, rich when desired
## Implementation Checklist
### Phase 1: Participant List Foundation
- [x] Implement ParticipantsState class with subscription system ✅ (assets/js/domain/participants-state.ts)
- [x] Create ParticipantList UI component with activity indicators ✅ (assets/js/components/participant-list.ts)
- [x] Connect ParticipantsState to Phoenix Presence events ✅ (canvas-hook.ts integration)
- [x] Add participant activity tracking interfaces ✅ (ParticipantActivity interface implemented)
- [ ] Remove existing cursor rendering from default experience ⚠️ (Multiple cursor systems active - causing flickering)
- [x] Create unit tests for ParticipantsState and ParticipantList ✅ (assets/js/__tests__/participants-state.test.ts)
- [x] Update E2E tests to use participant list for collaboration verification ✅ (simple-presence-list.spec.ts working)
### Phase 2: Activity Enhancement
- [x] Implement real-time activity status updates ✅ (updateParticipantActivity in ParticipantsState)
- [x] Add tool-specific activity indicators (drawing, dragging, connecting) ✅ (getActivityIcon/Text methods)
- [x] Create connection status tracking (online/away/offline) ✅ (getConnectionStatus method)
- [x] Add subtle presence notifications for joins/leaves ✅ (showPresenceNotification in ParticipantList)
- [ ] Test activity update performance with multiple participants ⚠️ (E2E tests needed)
### Phase 3: Optional Cursor System
- [x] Implement cursor toggle in participant list header ✅ (renderCursorToggle method implemented)
- [ ] Create OptionalCursorCoordinator with preference handling ⚠️ (Currently disabled: enableCursorToggle: false)
- [ ] Connect cursor system to tool-aware positioning logic
- [ ] Add user preference persistence (localStorage) ✅ (getCursorsEnabled/setCursorsEnabled implemented)
- [ ] Preserve all existing tool-specific cursor handlers ⚠️ (Multiple cursor systems causing conflicts)
- [ ] Test cursor toggle performance and user experience
## 🚨 Current Implementation Issues (2025-07-14)
### Cursor Flickering Root Cause
**Issue**: Multiple cursor systems active simultaneously:
1. **canvas-hook.ts**: Direct cursor management with `createCursorElement()` and `data-cursor-*` attributes
2. **dom-cursor-manager.ts**: Adapter-based cursor system with similar functionality
**Impact**: Both systems are creating and updating cursors, causing flickering and conflicts.
**Current Status**:
- ParticipantList cursor toggle is **disabled** (`enableCursorToggle: false`)
- Old cursor presence E2E tests are **skipped** (cursor-presence.spec.ts, simple-presence.spec.ts)
- Active debug tests should be removed (debug-presence.spec.ts, final-presence-debug.spec.ts)
- Working: Participant list with presence notifications (simple-presence-list.spec.ts)
### Feature Flag Gap
**Issue**: No specific feature flags for presence functionality migration
- Current flags focus on canvas adapters, not presence systems
- Need flags for: `PARTICIPANT_FIRST_PRESENCE`, `LEGACY_CURSOR_SYSTEM`, `OPTIONAL_CURSOR_TOGGLE`
### Immediate Action Items
1. **🔥 Fix Flickering**: Disable one of the cursor systems or add proper feature flag coordination
2. **🧹 Clean Tests**: Remove debug E2E tests, replace skipped cursor tests with participant-first tests
3. **⚙️ Feature Flags**: Add presence-specific feature flags for controlled rollout
4. **🎯 Default Experience**: Enable participant-first design (list-only, no cursors by default)
### Phase 4: Polish & Performance
- [ ] Optimize ParticipantsState subscription system
- [ ] Add graceful degradation for network issues
- [ ] Implement participant list scroll handling for many users
- [ ] Add accessibility features (screen reader support, keyboard navigation)
- [ ] Performance testing with 10+ concurrent participants
## Future Enhancements
1. **Rich Participant Profiles**
- Avatar support
- Role-based permissions
- Custom metadata
2. **Participant Groups**
- Team-based cursors
- Group color schemes
- Collaborative spaces
3. **Analytics Integration**
- Track participant interactions
- Measure collaboration patterns
- Performance metrics per participant
## Conclusion
This participant-first design represents a fundamental shift from cursor-centric to list-centric collaboration awareness. By prioritizing a clean, unobstructed drawing experience while maintaining rich collaboration context through the participant list, we create a system that serves both focused individual work and active collaboration scenarios.
### Key Design Insights
1. **Essential vs Optional**: Participant presence is essential; cursor visualization is optional enhancement
2. **Clean Canvas**: Drawing applications benefit from uncluttered workspace with sidebar collaboration awareness
3. **Progressive Enhancement**: Users get immediate value from participant list with option for richer cursor feedback
4. **User Choice**: Respecting user preferences for visual complexity improves overall experience
### Architectural Benefits
The `ParticipantsState` foundation provides a single source of truth for all collaboration features, whether that's the participant list, optional cursors, or future enhancements like activity notifications or team features. The modular cursor system remains available for users who want it, preserving all the tool-aware positioning logic while making it truly optional.
This approach transforms collaboration from a potential distraction into a helpful, unobtrusive presence that enhances the drawing experience without interfering with it.