AGENTS.md

  1<!--
  2SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  3
  4SPDX-License-Identifier: CC0-1.0
  5-->
  6
  7# AGENTS.md
  8
  9Guide for AI agents working in this codebase.
 10
 11## Project Overview
 12
 13Sift is a collaborative list application with real-time voting and ranking. Users create or join rooms via an ID, then collaboratively add items and vote (upvote, downvote, or veto). Items are automatically sorted by score, with vetoed items pushed to the bottom and dimmed. Rooms can optionally have custom titles.
 14
 15Tech stack:
 16- Runtime: Deno (TypeScript)
 17- Backend: WebSocket server with SQLite database
 18- Frontend: Vanilla JavaScript ES modules (no frameworks)
 19- Database: SQLite3 via https://deno.land/x/sqlite3@0.11.1/mod.ts
 20- Server: Deno std HTTP server https://deno.land/std@0.208.0/http/server.ts
 21
 22## Essential Commands
 23
 24Run the server
 25```bash
 26deno run --allow-net=:8294 --allow-read=./static/,./lists.db,$HOME/.cache/deno/plug --allow-write=./lists.db,$HOME/.cache/deno/plug --allow-env --allow-ffi server.ts
 27```
 28
 29CLI options:
 30- `--host <hostname>`: Bind to specific interface (default: `localhost`, use `0.0.0.0` for all interfaces)
 31- `--port <port>`: Listen on specific port (default: `8294`)
 32
 33Example with custom host/port:
 34```bash
 35deno run --allow-net=:3000 --allow-read=./static/,./lists.db,$HOME/.cache/deno/plug --allow-write=./lists.db,$HOME/.cache/deno/plug --allow-env --allow-ffi server.ts --host 0.0.0.0 --port 3000
 36```
 37
 38Required permissions:
 39- --allow-net=:PORT: HTTP server and WebSocket connections (update PORT to match --port flag)
 40- --allow-read: Serve static files from ./static/, read database, cache SQLite library
 41- --allow-write: SQLite database operations and cache directory for native library
 42- --allow-env: SQLite library reads environment variables (unavoidable for sqlite3 FFI)
 43- --allow-ffi: SQLite3 native library access (unavoidable for sqlite3)
 44
 45Server listens on http://HOST:PORT (defaults to http://localhost:8294)
 46
 47Database inspection
 48```bash
 49sqlite3 lists.db ".schema"
 50sqlite3 lists.db "SELECT * FROM rooms;"
 51sqlite3 lists.db "SELECT * FROM items;"
 52sqlite3 lists.db "SELECT * FROM votes;"
 53```
 54
 55## Architecture
 56
 57### Data Flow
 58
 59```
 60User creates/joins room β†’ WebSocket connection β†’ Server sends current state (including room title)
 61User adds items β†’ Client sends 'add_items' β†’ Server inserts to DB β†’ Broadcasts to all clients in room
 62User votes β†’ Client sends 'vote' β†’ Server upserts vote β†’ Broadcasts vote change
 63User deletes item β†’ Client sends 'delete_item' β†’ Server cascades deletion (and explicitly deletes votes) β†’ Broadcasts deletion
 64User sets room title β†’ Client sends 'set_title' β†’ Server updates room β†’ Broadcasts title change
 65Top tie present β†’ Client may send 'break_tie' β†’ Server randomly selects a winner among tied top items β†’ Broadcasts 'tie_broken'
 66```
 67
 68### Component Structure
 69
 70- server.ts
 71  - HTTP request handler serving static files and API endpoints
 72  - WebSocket connection manager with room-based broadcasting
 73  - Validation of message size, payload types, and room/item membership
 74  - Schedules daily cleanup of inactive rooms (see db.deleteInactiveRooms)
 75- db.ts
 76  - SQLite database initialization, foreign keys, schema migration (v0β†’v1β†’v2β†’v3)
 77  - Query helpers for state assembly, item/vote CRUD, room title updates
 78  - Inactive room cleanup by last activity timestamp (created_at of rooms/items/votes)
 79- static/index.html
 80  - Two-screen UI: start screen (create/join) and list screen
 81  - Connection status indicators (dot + text + last sync time)
 82  - Header actions (set title, copy link, copy items, reset votes, leave)
 83  - Help modal markup and keyboard shortcut reference
 84  - Input methods: single-item input and bulk textarea
 85  - Inline SVG icons embedded in buttons
 86  - Loads ES module script with `<script type="module" src="/app.js"></script>`
 87- static/app.js (ES module entry)
 88  - WebSocket client with message queuing before connection ready
 89  - State management for items, votes, selected item, room title
 90  - URL deep-linking and history updates
 91  - Delegates rendering/utilities to render.js and keyboard/help UX to ui.js
 92- static/render.js
 93  - Sorting, escaping, connection status, last-sync display, and enabled/disabled UI toggling
 94- static/ui.js
 95  - Keyboard shortcuts (j/k nav, 1/2/3 votes, Enter upvote, e edit, Del delete, Ctrl/Cmd+Z undo, ? help)
 96  - Help modal open/close and minimal press-lock for buttons
 97- static/style.css, static/palette.css
 98  - CSS custom properties, light/dark theme, responsive layout, reduced motion support
 99
100### Database Schema (schema_version = 3)
101
102rooms
103- code (TEXT PRIMARY KEY NOT NULL)
104- created_at (INTEGER, Unix timestamp)
105- title (TEXT, nullable, added in v3)
106
107items
108- id (TEXT PRIMARY KEY NOT NULL)
109- room_code (TEXT NOT NULL) β†’ FOREIGN KEY rooms(code) ON DELETE CASCADE
110- text (TEXT NOT NULL) CHECK(LENGTH(text) BETWEEN 1 AND 200)
111- created_at (INTEGER, Unix timestamp)
112- Index: idx_items_room_code on room_code
113
114votes
115- item_id (TEXT NOT NULL) β†’ FOREIGN KEY items(id) ON DELETE CASCADE
116- user_id (TEXT NOT NULL)
117- vote_type (TEXT NOT NULL) CHECK(vote_type IN ('up','down','veto'))
118- created_at (INTEGER, Unix timestamp)
119- PRIMARY KEY (item_id, user_id)
120- Indexes: idx_votes_item_id on item_id, idx_votes_user_id on user_id
121
122Constraints
123- PRAGMA foreign_keys = ON
124- CASCADE deletion: deleting a room deletes all items and votes; deleting an item deletes all votes (code also deletes votes explicitly before deleting an item)
125- NOT NULL validation at DB level
126- Text length enforced 1–200 characters
127- Vote type enforced: 'up' | 'down' | 'veto'
128
129Migration
130- On startup, migrates legacy data to latest schema when needed
131- From v0: creates new tables, sanitizes/truncates item text to 200 chars, filters vote types; sets user_version = 2
132- From v1: rebuilds rooms table (removes last_activity and length constraint); sets user_version = 2
133- From v2: adds title column to rooms; sets user_version = 3
134- For fresh databases: creates tables with schema v3 directly
135- Transactional (BEGIN/COMMIT with rollback on failure)
136
137### WebSocket Protocol
138
139Server β†’ Client messages
140```js
141{ type: 'state', items: [...], roomTitle: string|null, userId: '...' }
142{ type: 'items_added', items: [{id, text, votes}, ...] }
143{ type: 'vote_changed', itemId, userId, voteType }
144{ type: 'vote_removed', itemId, userId }
145{ type: 'item_edited', itemId, text }
146{ type: 'item_deleted', itemId }
147{ type: 'votes_reset' }
148{ type: 'title_changed', title: string|null }
149{ type: 'tie_broken', itemId, text }
150```
151
152Client β†’ Server messages
153```js
154{ type: 'add_items', items: ['text1', 'text2', ...] }
155{ type: 'vote', itemId, voteType }    // voteType: 'up'|'down'|'veto'
156{ type: 'unvote', itemId }
157{ type: 'edit_item', itemId, text }
158{ type: 'delete_item', itemId }
159{ type: 'reset_votes' }
160{ type: 'set_title', title: string|null }  // null to clear title
161{ type: 'break_tie' } // request server to randomly choose among tied top items
162```
163
164### State Synchronization & Maintenance
165
166Connection lifecycle
1671. Client connects with ?room=CODE&user=UUID query params
1682. Server verifies room exists, upgrades to WebSocket
1693. Server sends full state via 'state' message (items, roomTitle, userId)
1704. Client stores userId from server (may differ from localStorage if not provided)
1715. Client sets isReady=true and enables UI
1726. Subsequent changes broadcast to all clients in room (sender included)
173
174Client-side optimism: None β€” server is authoritative. Client waits for broadcast confirmation.
175
176Room cleanup
177- Socket close: connection is removed from the in-memory clients Map
178- Inactive rooms: daily job deletes rooms with no activity for 30 days (based on max of room/items/votes created_at)
179
180Connection status: Client tracks connection state (connecting/connected/disconnected) with visual indicators and last sync timestamp, updated every second.
181
182### Rendering Logic
183
184Client-side sort
1851. Vetoed items always appear last
1862. Within non-vetoed and vetoed groups, sort by score (descending)
1873. Score = count(upvotes) - count(downvotes), vetoes don't affect score
188
189Visual states
190- Vetoed items: 40% opacity, max-height constraint, hidden overflow
191- Active vote buttons: darker background (press-lock to avoid flicker)
192- Selected item: outline with accent color
193- Items display current score with +/- prefix
194- Highest-scoring non-vetoed items get a left "top-check" indicator
195- Reordering uses FLIP animation
196
197Tie breaker
198- When a top score tie exists, a "Break the tie" UI appears
199- Clicking it sends 'break_tie'; server broadcasts 'tie_broken' with the chosen item for display
200
201Room title
202- Displayed in header when set
203- Hidden when null/empty
204- Editable via set-title-btn (pencil icon)
205- Sanitized using same rules as item text (1-200 chars)
206
207### Server-Side Validation
208
209Limits (server.ts)
210- MAX_ITEM_TEXT_LEN: 200 characters
211- MAX_BULK_ITEMS: 100 items per add_items request
212- MAX_WS_MESSAGE_BYTES: 32768 bytes (32KB)
213
214Message validation
215- Messages exceeding MAX_WS_MESSAGE_BYTES close the WebSocket with code 1009
216- Invalid JSON is logged and ignored
217- add_items: items must be array length 1–100; each text sanitized (trim, normalize CR/LF); texts outside 1–200 chars are rejected
218- vote/unvote/delete_item/edit_item: itemId must be string and belong to current room
219- vote: voteType must be one of 'up'|'down'|'veto'
220- edit_item: text sanitized and length-checked (1–200)
221- reset_votes: clears all votes for items in the room
222- set_title: title can be null/undefined (clears title) or string; if string, sanitized and length-checked (1–200)
223- break_tie: server computes tie among top non-vetoed items and broadcasts a random winner
224
225Validation helpers
226- safeParseMessage(): size check + JSON parse with error handling
227- sanitizeItemText(): trim, normalize line endings; returns null if length invalid
228- isValidVoteType(): type guard for vote types
229- itemBelongsToRoom(): prevents cross-room item manipulation
230
231Invalid messages
232- Logged with descriptive warnings
233- Silently ignored (no error response)
234- Never throw exceptions that crash the server
235
236## Code Patterns
237
238Naming conventions
239- Variables: camelCase (roomCode, userId, messageQueue, roomTitle)
240- Constant limits: UPPER_SNAKE_CASE (MAX_ITEM_TEXT_LEN, MAX_BULK_ITEMS, MAX_WS_MESSAGE_BYTES)
241- Other constants/instances: camelCase (db, clients)
242- Database columns: snake_case (room_code, created_at, vote_type)
243- CSS custom properties: kebab-case with -- prefix (--bg-page, --accent-hover)
244
245TypeScript usage
246- Minimal typing, relying on inference
247- Explicit types for function parameters when needed
248- Type assertions for DB results (e.g., as Array<{...}>)
249- Type guard for vote types
250
251Error handling
252- Server WebSocket messages: try/catch around message handling
253- Server: no error responses for malformed WebSocket messages
254- Client WebSocket: error handler logs to console
255- Client close: alert on unexpected close, reset to start screen
256- Clipboard actions: try/catch with console log
257- Delete confirmation: browser confirm() dialog
258
259Security notes
260- No authentication: rooms protected only by knowing the room ID
261- User IDs: client-generated UUIDs in localStorage (spoofable)
262- XSS prevention: escapeHtml() used when rendering item text and room title
263- Input validation: server-side sanitization and length limits
264- Cross-room protection: server validates item-room membership
265- Message size limit: WebSocket messages limited to 32KB
266- Foreign keys: enabled with CASCADE deletion
267
268## Gotchas
269
2701) Vote uniqueness: DB enforces one vote per user per item via compound PK and UPSERT. Clicking the same vote button twice removes the vote.
2712) Message queuing: Client queues messages sent before WebSocket opens and flushes them on connection.
2723) Undo is limited: Only stores last vote action; doesn't cover add/delete operations.
2734) Broadcast 'except' param unused by callers: all clients, including sender, receive broadcasts.
2745) Item-room validation enforced: prevents cross-room vote/edit/delete.
2756) Port is hardcoded: server always runs on port 8294.
2767) Static files served via dedicated routes; icons are inline in HTML. An /icons/ route exists for optional SVGs if added under static/icons/.
2778) No build step: Deno runs TypeScript directly.
2789) Database file location: lists.db is created in the project root.
27910) Deep linking works: Opening a URL with ?room=CODE auto-joins that room.
28011) UI disabled while connecting: Inputs/buttons disabled until 'state' message received (isReady flag).
28112) Room title is optional: null/empty titles are hidden; title uses same text sanitization as items.
28213) Position restoration after voting: Client remembers position in sorted list and re-selects item at same position after vote-triggered re-sort.
28314) ES modules: index.html loads app.js as type=module which imports render.js and ui.js.
28415) Cleanup: a daily job deletes rooms with no activity for 30 days; this is irreversible.
285
286## Testing Approach
287
288No automated tests. Manual checks:
289
290Core functionality
2911. Multi-client testing: open multiple tabs to the same room
2922. Vote synchronization: vote in one client; others update
2933. Bulk add: paste multi-line text; verify all items added
2944. Edit item: edit an item; verify all clients update (item_edited)
2955. Reset votes: use the reset button; verify votes clear (votes_reset)
2966. Set room title: use set title button; verify all clients see updated title
2977. Veto sorting: veto an item; verify it moves to bottom and dims
2988. Disconnection: stop server; clients show alert and reset
2999. Room joining: joining a non-existent room returns 404
30010. Deep linking: open URL with ?room=CODE; verify auto-join
30111. Connection status: watch status dot change and last sync time update
30212. Keyboard shortcuts: test j/k navigation, 1/2/3 voting, e edit, Del delete, ? help
30313. Top tie: when multiple non-vetoed items share the highest score, "Break the tie" appears; clicking it shows a "Winner: ..." message (tie_broken)
30414. Noscript fallback: with JS disabled, verify noscript message displays
305
306Validation testing (check server logs)
3071. Empty item: whitespace-only text β†’ ignored, logged
3082. Long item: >200 characters β†’ rejected, logged (not truncated at runtime)
3093. Bulk limit: >100 items in one request β†’ rejected, logged
3104. Invalid vote type: crafted invalid voteType β†’ ignored, logged
3115. Cross-room manipulation: vote/edit/delete for an item in another room β†’ rejected, logged
3126. Large message: >32KB WebSocket message β†’ socket closed with code 1009
3137. DB constraints: sqlite3 lists.db ".schema" β†’ verify CHECKs, NOT NULL, indexes
3148. Migration: start with old-format data; verify migration to v3 runs and data preserved
3159. Title validation: set title >200 chars β†’ rejected, logged; set null title β†’ clears title
316
317## Development Workflow
318
319Making changes
3201. Edit source files (server.ts, db.ts, or static/*)
3212. Restart Deno server (no hot reload)
3223. Hard refresh browser (Ctrl+Shift+R) to bypass cache
3234. Test in multiple tabs for WebSocket sync
324
325Adding new vote types
3261. Update DB schema CHECK constraint for vote_type in CREATE TABLE votes (and migrations)
3272. Update isValidVoteType in server.ts
3283. Update vote aggregation/rendering logic on client if needed
3294. Add button in static/index.html
3305. Add handling in static/app.js vote() and render()
3316. Add CSS styling for the new vote type
332
333Adding new message types
3341. Add a case to the switch in handleWebSocket() in server.ts
3352. Implement DB operations and broadcast() as needed
3363. Add a case to handleMessage() in static/app.js
3374. Update client state and trigger render()
338
339## File Organization
340
341```
342/
343β”œβ”€β”€ server.ts          # Deno HTTP/WebSocket server
344β”œβ”€β”€ db.ts              # SQLite schema, migrations, queries, cleanup
345β”œβ”€β”€ lists.db           # SQLite database (created on first run)
346└── static/
347    β”œβ”€β”€ index.html     # HTML structure
348    β”œβ”€β”€ app.js         # Entry module; WS client and state
349    β”œβ”€β”€ render.js      # Rendering/util helpers
350    β”œβ”€β”€ ui.js          # Keyboard/help UI logic
351    β”œβ”€β”€ style.css      # Styles with light/dark theme
352    └── palette.css    # Color tokens and CSS variables
353```
354
355No build artifacts, no dependencies beyond Deno stdlib and sqlite3. An optional static/icons/ directory may be added if serving SVGs via /icons/.
356
357## Important Constants
358
359- Room ID generation: crypto.randomUUID()
360- Default server host: localhost (configurable via --host)
361- Default server port: 8294 (configurable via --port)
362- WebSocket path: /ws
363- API endpoints: /api/create (POST, returns room code)
364- Default userId: generated via crypto.randomUUID() and stored in localStorage
365
366Validation limits
367- MAX_ITEM_TEXT_LEN: 200 characters (applies to both items and room titles)
368- MAX_BULK_ITEMS: 100
369- MAX_WS_MESSAGE_BYTES: 32768 (32KB)
370
371## Dependencies
372
373All dependencies are imported via HTTPS URLs from deno.land:
374- https://deno.land/std@0.208.0/http/server.ts β€” HTTP server
375- https://deno.land/x/sqlite3@0.11.1/mod.ts β€” SQLite database
376
377No package.json, no npm, no node_modules.