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.
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/threads→Map<string, Thread>with nested commentsGET /api/boards/:boardId/votes→Map<string, Set<string>>(objectId → userIds)
Socket updates (single codepath for all clients including sender)
thread:add→ insert thread + comment, add pin to overlaythread:status→ update status + resolved_by/at, re-render pinthread:delete→ remove thread, remove pincomment:add→ append to thread's comment listcomment:update→ update comment content + edited_atcomment:delete→ remove from thread's comment listvote: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)
- Backend: DB schema (comment_threads, comments tables + indexes)
- Backend: REST endpoints for threads and comments
- Backend: Socket event relay in board-room.js
- Frontend: State management (load on board open, socket handlers)
- Frontend: Feedback panel (overview, object detail, thread views)
Phase 2: Canvas Pins
- Frontend: Review Mode toggle in toolbar
- Frontend: Pin overlay rendering (PixiJS container)
- Frontend: Pin placement subtool (click-to-place region pins)
- Frontend: Jump-to-object from thread
Phase 3: Voting
- Backend: object_votes table + REST endpoints + socket events
- Frontend: Vote button in panel + badge on canvas
- Frontend: "Top voted" section in panel overview
Phase 4: Polish
- Frontend: Orphan detection (deleted objects, show in panel)
- Frontend: Filters and sort options in panel
- Frontend: "Edited" labels, timestamps, author snapshots