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

285 lines
11 KiB
Markdown

# 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.
```sql
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.
```sql
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
```sql
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 comments
- `GET /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 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
6. Frontend: Review Mode toggle in toolbar
7. Frontend: Pin overlay rendering (PixiJS container)
8. Frontend: Pin placement subtool (click-to-place region pins)
9. Frontend: Jump-to-object from thread
### Phase 3: Voting
10. Backend: object_votes table + REST endpoints + socket events
11. Frontend: Vote button in panel + badge on canvas
12. Frontend: "Top voted" section in panel overview
### Phase 4: Polish
13. Frontend: Orphan detection (deleted objects, show in panel)
14. Frontend: Filters and sort options in panel
15. Frontend: "Edited" labels, timestamps, author snapshots