Files
refboard-ayon/docs/plans/2026-03-10-annotations-voting-design.md
Hiren Kangad 3a6cfd59b5 docs: annotations & voting system design
Thread-centric model: one pin = one thread with flat replies.
Server-authoritative real-time sync (all clients receive canonical
events). Soft Review Mode for pin visibility without locking editing.
Denormalized thread summaries for efficient panel queries.
Public boards: read-only feedback, auth required to participate.
Phased: comments first, then canvas pins, then voting.
2026-03-10 17:17:38 +05:30

11 KiB

Annotations & Voting System — Design

Goal

Add thread-centric comments (object-level and region-pinned) and per-object upvoting to refboard, turning it into a collaborative feedback tool for game dev art review and mood board curation.

Core Decisions

Decision Choice
Comment anchoring Both object-level and region-pinned (anchor_type + nullable pinX/pinY)
Pin coordinates Relative 0-1 range (survives transforms naturally)
Structure Thread-centric: one pin = one thread, flat replies inside
Reply depth Flat (v1) — root comment + flat replies, no nested threading
Thread status Enum: open, resolved, archived (with resolved_by, resolved_at)
Voting Simple upvote per object, one vote per user, toggle on/off
Real-time sync Everything live via socket events, server-authoritative
Socket broadcast Include sender — canonical event is the single codepath for all clients
Canvas UX Pin markers + side panel + Review Mode (soft, not modal)
Object delete Soft orphan — comments hidden from canvas, visible in panel
Object duplicate Comments do not copy
Object ID stability Scene object UUIDs must remain stable across saves, sync, and reloads

Critical Assumption

object_id in threads/votes references scene object UUIDs inside canvas_state. These IDs must remain stable across saves, sync, and board reloads. This is already the case in the current SceneManager (objects get UUIDs at creation, preserved through all operations), but it is a hard dependency for this feature.


Data Model

comment_threads table

Thread is the anchor — one pin = one thread. Carries the spatial/object binding.

CREATE TABLE IF NOT EXISTS comment_threads (
  id TEXT PRIMARY KEY,
  board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
  object_id TEXT NOT NULL,              -- scene object UUID (stable across saves)
  anchor_type TEXT NOT NULL DEFAULT 'object',  -- 'object' | 'point' (future: 'rect')
  pin_x REAL,                           -- null for anchor_type='object', 0-1 for 'point'
  pin_y REAL,
  status TEXT NOT NULL DEFAULT 'open',  -- 'open' | 'resolved' | 'archived'
  resolved_by TEXT REFERENCES users(id),
  resolved_at TEXT,
  comment_count INTEGER NOT NULL DEFAULT 1,  -- denormalized, includes root comment
  last_commented_at TEXT,                    -- denormalized, updated on each reply
  last_commented_by TEXT,                    -- denormalized, user_id of last commenter
  created_by TEXT NOT NULL REFERENCES users(id),
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_threads_board ON comment_threads(board_id);
CREATE INDEX IF NOT EXISTS idx_threads_object ON comment_threads(board_id, object_id);

comments table

Flat replies inside threads. No anchor fields — positioning belongs to the thread. No parent_id in v1 — all replies are flat, ordered by created_at.

CREATE TABLE IF NOT EXISTS comments (
  id TEXT PRIMARY KEY,
  thread_id TEXT NOT NULL REFERENCES comment_threads(id) ON DELETE CASCADE,
  user_id TEXT NOT NULL REFERENCES users(id),
  author_name TEXT NOT NULL,            -- snapshot at creation time
  author_color TEXT,                    -- user color snapshot for avatar
  content TEXT NOT NULL,
  edited_at TEXT,                       -- set on edit, null if never edited
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id);

object_votes table

CREATE TABLE IF NOT EXISTS object_votes (
  board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
  object_id TEXT NOT NULL,
  user_id TEXT NOT NULL REFERENCES users(id),
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  PRIMARY KEY (board_id, object_id, user_id)
);

CREATE INDEX IF NOT EXISTS idx_votes_object ON object_votes(board_id, object_id);

Object lifecycle

Transform: Pins derive position from object bounds each frame. Relative coords (0-1) survive move/scale/rotate automatically.

Delete: Don't hard-delete threads. Threads whose object_id no longer exists in canvas_state are "orphaned" — hidden from canvas pins but visible in panel under a "Deleted items" section. Review history matters.

Duplicate: Comments do not copy with the object. Clean slate for the duplicate.


API Endpoints

Threads & Comments

GET    /api/boards/:boardId/threads                          — all threads + comments for board
POST   /api/boards/:boardId/threads                          — create thread + first comment
                                                               { object_id, anchor_type, pin_x?, pin_y?, content }
PATCH  /api/boards/:boardId/threads/:threadId                — update status { status: 'open'|'resolved'|'archived' }
DELETE /api/boards/:boardId/threads/:threadId                 — delete thread + all comments

POST   /api/boards/:boardId/threads/:threadId/comments       — add reply { content }
PUT    /api/boards/:boardId/threads/:threadId/comments/:id   — edit own comment { content }
DELETE /api/boards/:boardId/threads/:threadId/comments/:id   — delete own comment

Votes

GET    /api/boards/:boardId/votes                            — all votes for board
POST   /api/boards/:boardId/votes                            — toggle vote { object_id }

Permissions

  • Public (unauthenticated): read-only — can view threads and vote counts, cannot comment or vote
  • Viewer (collection member): read threads, add threads/comments, vote
  • Editor: all viewer + resolve/archive threads
  • Owner: all editor + delete any thread/comment

Public boards expose feedback as read-only. Participating in the review loop requires authentication and collection membership.


Socket Events

Server-authoritative: client sends REST request → server writes DB → server broadcasts canonical event to all clients in the room including sender. The REST response returns minimal confirmation (id, timestamp); the socket event is the single codepath that updates local state for everyone.

Event Payload
thread:add { boardId, thread, comment }
thread:status { boardId, threadId, status, resolvedBy, resolvedAt }
thread:delete { boardId, threadId }
comment:add { boardId, threadId, comment }
comment:update { boardId, threadId, commentId, content, editedAt }
comment:delete { boardId, threadId, commentId }
vote:toggle { boardId, objectId, userId, active }

All broadcast to board:${boardId} room.


Canvas UX — Pin Markers

Pin appearance

  • Small circle (16-20px at 1x zoom, scale-independent)
  • Unresolved: red fill with white thread count
  • Resolved: gray fill, dimmed (hidden by default, toggle to show)
  • Region pins: positioned at (obj.x + pinX * obj.w, obj.y + pinY * obj.h) in world space
  • Object-level pins: top-right corner of object bounding box
  • Do NOT render comment text on canvas — pins are minimal dots only

Vote badge

  • Small upvote arrow + count pill at bottom-right of object
  • Only visible when count > 0 or in Review Mode

Interaction

  • Click pin → opens panel scrolled to that thread
  • Hover pin → tooltip: author + first line of content
  • Pins and vote badges visible in Review Mode

Review Mode

Soft toggle in toolbar — not a separate locked mode. When enabled:

  • Comment pins and vote badges become visible on canvas
  • Editing still works — selection, move, resize all functional
  • A "Place comment" subtool activates pin placement (crosshair cursor, click on object to place)
  • Panel auto-opens to feedback view
  • Exiting the subtool returns to normal select behavior

When disabled:

  • Pins and badges hidden — clean canvas for arranging/editing
  • Panel can still be opened manually but defaults to layer view

This keeps Review Mode useful without trapping users in a separate workflow.


Panel UX — Feedback Panel

Layout

  • Right-side panel, 280px wide, same pattern as LayerPanel
  • Toggled via toolbar button (comment icon) or auto-opens in Review Mode
  • Tabs or toggle to switch between Layer view and Feedback view

Filters (top of panel)

  • Unresolved only (default on)
  • Mine only
  • Selected object
  • Most voted

Sort options

  • Newest activity
  • Unresolved first
  • By vote count

Views

1. Overview (no selection)

  • List of objects with active threads, grouped by object
  • Each row: object name, unresolved thread count badge, vote count + vote button
  • "Top voted" section at top for lightweight poll results
  • "Deleted items" collapsible section at bottom for orphaned threads

2. Object detail (object selected or pin clicked)

  • Object name + vote button + count at top
  • List of threads for that object
  • Each thread: pin indicator (object/point), author, timestamp, first comment preview, reply count, status badge
  • Click to expand

3. Thread expanded

  • Root comment + flat replies in chronological order
  • Reply input at bottom
  • Resolve/archive button (editor+)
  • "Edited" label on modified comments
  • Delete button on own comments
  • "Jump to object" button — pans/zooms viewport to center on the target object

Frontend State

Loaded on board open

  • GET /api/boards/:boardId/threadsMap<string, Thread> with nested comments
  • GET /api/boards/:boardId/votesMap<string, Set<string>> (objectId → userIds)

Socket updates (single codepath for all clients including sender)

  • thread:add → insert thread + comment, add pin to overlay
  • thread:status → update status + resolved_by/at, re-render pin
  • thread:delete → remove thread, remove pin
  • comment:add → append to thread's comment list
  • comment:update → update comment content + edited_at
  • comment:delete → remove from thread's comment list
  • vote:toggle → add/remove userId from vote set, update badge

Pin overlay

  • Separate PixiJS Container on viewport (above objects, below selection handles)
  • Re-derives positions from object world bounds on viewport change
  • Only rendered when Review Mode is active

Implementation Order

Ship comments first, then votes, then review mode polish.

Phase 1: Comments (core value)

  1. Backend: DB schema (comment_threads, comments tables + indexes)
  2. Backend: REST endpoints for threads and comments
  3. Backend: Socket event relay in board-room.js
  4. Frontend: State management (load on board open, socket handlers)
  5. Frontend: Feedback panel (overview, object detail, thread views)

Phase 2: Canvas Pins

  1. Frontend: Review Mode toggle in toolbar
  2. Frontend: Pin overlay rendering (PixiJS container)
  3. Frontend: Pin placement subtool (click-to-place region pins)
  4. Frontend: Jump-to-object from thread

Phase 3: Voting

  1. Backend: object_votes table + REST endpoints + socket events
  2. Frontend: Vote button in panel + badge on canvas
  3. Frontend: "Top voted" section in panel overview

Phase 4: Polish

  1. Frontend: Orphan detection (deleted objects, show in panel)
  2. Frontend: Filters and sort options in panel
  3. Frontend: "Edited" labels, timestamps, author snapshots