docster/participant-pointers.md

59 KiB

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

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

// 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

// 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

// 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)

// 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

// 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

// 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

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

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

// 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

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

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

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

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

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

// 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

// 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)

// 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)

// 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)

// 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

  • Implement ParticipantsState class with subscription system (assets/js/domain/participants-state.ts)
  • Create ParticipantList UI component with activity indicators (assets/js/components/participant-list.ts)
  • Connect ParticipantsState to Phoenix Presence events (canvas-hook.ts integration)
  • Add participant activity tracking interfaces (ParticipantActivity interface implemented)
  • Remove existing cursor rendering from default experience ⚠️ (Multiple cursor systems active - causing flickering)
  • Create unit tests for ParticipantsState and ParticipantList (assets/js/tests/participants-state.test.ts)
  • Update E2E tests to use participant list for collaboration verification (simple-presence-list.spec.ts working)

Phase 2: Activity Enhancement

  • Implement real-time activity status updates (updateParticipantActivity in ParticipantsState)
  • Add tool-specific activity indicators (drawing, dragging, connecting) (getActivityIcon/Text methods)
  • Create connection status tracking (online/away/offline) (getConnectionStatus method)
  • 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

  • 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.