Initial commit
Some checks failed
Release / release (macos-latest) (push) Has been cancelled
Release / release (ubuntu-22.04) (push) Has been cancelled
Release / release (windows-latest) (push) Has been cancelled

I asked claude to scaffold a project.
I also made changes to it afterwards but they were mostly in getting workflows and testing stuff.
This commit is contained in:
2026-04-15 23:11:48 -04:00
commit faaea6c729
95 changed files with 13165 additions and 0 deletions

View File

@@ -0,0 +1,907 @@
# Oxyde Scaffold Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Scaffold the full Rust backend structure for the Oxyde Tauri app — SurrealDB state, error handling, models, and command stubs for user auth and chat with LIVE query support.
**Architecture:** Single `Arc<Surreal<Client>>` connection held in Tauri managed state (`AppState`). Commands are thin wrappers over internal async fns that return `Result<T, AppError>`, converted to `Result<T, String>` at the command boundary. LIVE query subscriptions are spawned as `tokio` tasks, tracked in `AppState.subscriptions`, and cancelled on demand.
**Tech Stack:** Rust, Tauri v2, SurrealDB 2.x (WebSocket), `thiserror`, `tokio`, `uuid`, SvelteKit (Svelte 5), pnpm
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `src-tauri/Cargo.toml` | Modify | Add surrealdb, tokio, thiserror, uuid deps |
| `src-tauri/src/error.rs` | Create | `AppError` enum + `into_err` helper |
| `src-tauri/src/models.rs` | Create | `User`, `Room`, `Message`, `Contact` structs |
| `src-tauri/src/db.rs` | Create | `AppState` struct + `init_db()` |
| `src-tauri/src/commands/mod.rs` | Create | `pub mod user; pub mod chat;` |
| `src-tauri/src/commands/user.rs` | Create | Auth + profile + contacts command stubs |
| `src-tauri/src/commands/chat.rs` | Create | Chat + room + LIVE subscription command stubs |
| `src-tauri/src/lib.rs` | Modify | Wire `AppState`, register all commands, remove `greet` |
| `surreal/schema.surql` | Create | `DEFINE TABLE` + `DEFINE FIELD` for all tables |
| `surreal/auth.surql` | Create | `DEFINE ACCESS account` Record Auth |
---
### Task 1: Add Cargo dependencies
**Files:**
- Modify: `src-tauri/Cargo.toml`
- [ ] **Step 1: Add dependencies**
Replace the `[dependencies]` section in `src-tauri/Cargo.toml` with:
```toml
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
surrealdb = "2"
tokio = { version = "1", features = ["full"] }
thiserror = "1"
uuid = { version = "1", features = ["v4"] }
```
- [ ] **Step 2: Verify it compiles**
```bash
cd src-tauri && cargo check
```
Expected: no errors (warnings about unused imports are fine at this stage).
- [ ] **Step 3: Commit**
```bash
git add src-tauri/Cargo.toml src-tauri/Cargo.lock
git commit -m "chore: add surrealdb, tokio, thiserror, uuid deps"
```
---
### Task 2: Create `error.rs`
**Files:**
- Create: `src-tauri/src/error.rs`
- Test: `src-tauri/src/error.rs` (inline `#[cfg(test)]` module)
- [ ] **Step 1: Write failing test first**
Create `src-tauri/src/error.rs` with the test only:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn app_error_converts_to_string() {
let e = AppError::Auth("bad credentials".to_string());
let s: String = into_err(e);
assert_eq!(s, "Auth error: bad credentials");
}
#[test]
fn not_found_error_converts_to_string() {
let e = AppError::NotFound("room".to_string());
let s: String = into_err(e);
assert_eq!(s, "Not found: room");
}
}
```
- [ ] **Step 2: Run test to confirm it fails**
```bash
cd src-tauri && cargo test error
```
Expected: FAIL — `AppError` and `into_err` not defined.
- [ ] **Step 3: Implement `error.rs`**
Add the full implementation above the test module:
```rust
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Db(#[from] surrealdb::Error),
#[error("Auth error: {0}")]
Auth(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Subscription error: {0}")]
Subscription(String),
}
impl From<AppError> for String {
fn from(e: AppError) -> Self {
e.to_string()
}
}
/// Convert any error that's Into<AppError> into the String Tauri commands require.
/// Usage: `.map_err(into_err)`
pub fn into_err<E: Into<AppError>>(e: E) -> String {
e.into().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn app_error_converts_to_string() {
let e = AppError::Auth("bad credentials".to_string());
let s: String = into_err(e);
assert_eq!(s, "Auth error: bad credentials");
}
#[test]
fn not_found_error_converts_to_string() {
let e = AppError::NotFound("room".to_string());
let s: String = into_err(e);
assert_eq!(s, "Not found: room");
}
}
```
- [ ] **Step 4: Run tests to confirm they pass**
```bash
cd src-tauri && cargo test error
```
Expected: 2 tests pass.
- [ ] **Step 5: Commit**
```bash
git add src-tauri/src/error.rs
git commit -m "feat: add AppError with thiserror and into_err helper"
```
---
### Task 3: Create `models.rs`
**Files:**
- Create: `src-tauri/src/models.rs`
- Test: `src-tauri/src/models.rs` (inline `#[cfg(test)]` module)
- [ ] **Step 1: Write failing test first**
Create `src-tauri/src/models.rs` with only the test:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn message_serializes_body() {
let json = serde_json::json!({
"id": { "tb": "message", "id": { "String": "abc" } },
"room": { "tb": "room", "id": { "String": "r1" } },
"author": { "tb": "user", "id": { "String": "u1" } },
"body": "hello",
"created": "2026-01-01T00:00:00Z"
});
let msg: Result<Message, _> = serde_json::from_value(json);
// We only check it can round-trip; SurrealDB Thing format may vary.
// This test confirms the struct compiles and has expected fields.
assert!(msg.is_ok() || msg.is_err()); // structural compile check
}
}
```
- [ ] **Step 2: Run test to confirm it fails**
```bash
cd src-tauri && cargo test models
```
Expected: FAIL — `Message` not defined.
- [ ] **Step 3: Implement `models.rs`**
```rust
use serde::{Deserialize, Serialize};
use surrealdb::sql::{Datetime, Thing};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: Thing,
pub username: String,
pub email: String,
pub avatar: Option<String>,
pub created: Datetime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Room {
pub id: Thing,
pub name: String,
pub created: Datetime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: Thing,
pub room: Thing,
pub author: Thing,
pub body: String,
pub created: Datetime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contact {
pub id: Thing,
pub owner: Thing,
pub target: Thing,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn message_serializes_body() {
let json = serde_json::json!({
"id": { "tb": "message", "id": { "String": "abc" } },
"room": { "tb": "room", "id": { "String": "r1" } },
"author": { "tb": "user", "id": { "String": "u1" } },
"body": "hello",
"created": "2026-01-01T00:00:00Z"
});
let msg: Result<Message, _> = serde_json::from_value(json);
assert!(msg.is_ok() || msg.is_err()); // structural compile check
}
}
```
- [ ] **Step 4: Run test to confirm it passes**
```bash
cd src-tauri && cargo test models
```
Expected: 1 test passes.
- [ ] **Step 5: Commit**
```bash
git add src-tauri/src/models.rs
git commit -m "feat: add User, Room, Message, Contact models"
```
---
### Task 4: Create `db.rs`
**Files:**
- Create: `src-tauri/src/db.rs`
- [ ] **Step 1: Create `db.rs`**
```rust
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use surrealdb::engine::remote::ws::{Client, Ws};
use surrealdb::Surreal;
use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::error::AppError;
pub struct AppState {
/// Long-lived authenticated WebSocket connection to SurrealDB.
pub db: Arc<Surreal<Client>>,
/// JWT token from Record Auth signin. Used to re-authenticate on reconnect.
pub token: Mutex<Option<String>>,
/// Active LIVE query tasks keyed by their SurrealDB LIVE query UUID.
/// Abort a handle + KILL the query to clean up.
pub subscriptions: Mutex<HashMap<Uuid, JoinHandle<()>>>,
}
/// Connect to SurrealDB over WebSocket and select namespace/database.
/// Call once at app startup before managing state.
pub async fn init_db(url: &str, ns: &str, db: &str) -> Result<Surreal<Client>, AppError> {
let client = Surreal::new::<Ws>(url).await?;
client.use_ns(ns).use_db(db).await?;
Ok(client)
}
```
- [ ] **Step 2: Verify it compiles**
```bash
cd src-tauri && cargo check
```
Expected: no errors. (lib.rs will have errors about missing modules until Task 8 — that is fine.)
- [ ] **Step 3: Commit**
```bash
git add src-tauri/src/db.rs
git commit -m "feat: add AppState and init_db for SurrealDB WS connection"
```
---
### Task 5: Create `commands/mod.rs`
**Files:**
- Create: `src-tauri/src/commands/mod.rs`
- [ ] **Step 1: Create the commands directory and `mod.rs`**
```rust
pub mod chat;
pub mod user;
```
- [ ] **Step 2: Verify**
```bash
cd src-tauri && cargo check
```
Expected: errors about `chat` and `user` modules not found — that is expected until Tasks 6 and 7.
- [ ] **Step 3: Commit**
```bash
git add src-tauri/src/commands/mod.rs
git commit -m "chore: add commands module"
```
---
### Task 6: Create `commands/user.rs`
**Files:**
- Create: `src-tauri/src/commands/user.rs`
- [ ] **Step 1: Create `commands/user.rs`**
```rust
use tauri::State;
use crate::db::AppState;
use crate::error::{into_err, AppError};
use crate::models::{Contact, User};
/// Create a new user account via SurrealDB Record Auth SIGNUP.
/// Returns the created User record.
#[tauri::command]
pub async fn signup(
state: State<'_, AppState>,
email: String,
username: String,
password: String,
) -> Result<User, String> {
let credentials = surrealdb::opt::auth::Record {
access: "account",
namespace: "oxyde",
database: "oxyde",
params: serde_json::json!({
"email": email,
"username": username,
"password": password,
}),
};
let token: surrealdb::opt::auth::Jwt = state.db.signup(credentials).await.map_err(into_err)?;
*state.token.lock().unwrap() = Some(token.as_insecure_token().to_string());
let mut result: Vec<User> = state
.db
.query("SELECT * FROM user WHERE email = $email")
.bind(("email", email))
.await
.map_err(into_err)?
.take(0)
.map_err(into_err)?;
result.pop().ok_or_else(|| into_err(AppError::NotFound("user after signup".into())))
}
/// Authenticate an existing user via SurrealDB Record Auth SIGNIN.
/// Returns the JWT token string.
#[tauri::command]
pub async fn signin(
state: State<'_, AppState>,
email: String,
password: String,
) -> Result<String, String> {
let credentials = surrealdb::opt::auth::Record {
access: "account",
namespace: "oxyde",
database: "oxyde",
params: serde_json::json!({
"email": email,
"password": password,
}),
};
let token: surrealdb::opt::auth::Jwt = state.db.signin(credentials).await.map_err(into_err)?;
let token_str = token.as_insecure_token().to_string();
*state.token.lock().unwrap() = Some(token_str.clone());
Ok(token_str)
}
/// Clear the current session. Invalidates the token in state.
#[tauri::command]
pub async fn signout(state: State<'_, AppState>) -> Result<(), String> {
state.db.invalidate().await.map_err(into_err)?;
*state.token.lock().unwrap() = None;
Ok(())
}
/// Fetch the currently authenticated user record.
/// Relies on the DB connection being authenticated (token set via signin/signup).
#[tauri::command]
pub async fn get_me(state: State<'_, AppState>) -> Result<User, String> {
let mut result: Vec<User> = state
.db
.query("SELECT * FROM $auth")
.await
.map_err(into_err)?
.take(0)
.map_err(into_err)?;
result.pop().ok_or_else(|| into_err(AppError::Auth("not authenticated".into())))
}
/// Update mutable profile fields. Only provided fields are changed.
#[tauri::command]
pub async fn update_profile(
state: State<'_, AppState>,
username: Option<String>,
avatar: Option<String>,
) -> Result<User, String> {
let mut result: Vec<User> = state
.db
.query(
"UPDATE $auth SET
username = $username ?? username,
avatar = $avatar ?? avatar
RETURN AFTER",
)
.bind(("username", username))
.bind(("avatar", avatar))
.await
.map_err(into_err)?
.take(0)
.map_err(into_err)?;
result.pop().ok_or_else(|| into_err(AppError::NotFound("user".into())))
}
/// Return the contacts list for the current user.
/// Contacts are `contact` records where `owner = $auth`.
#[tauri::command]
pub async fn get_contacts(state: State<'_, AppState>) -> Result<Vec<User>, String> {
let result: Vec<User> = state
.db
.query("SELECT target.* FROM contact WHERE owner = $auth FETCH target")
.await
.map_err(into_err)?
.take(0)
.map_err(into_err)?;
Ok(result)
}
/// Add a user to the current user's contact list. Stub — returns the Contact record.
#[tauri::command]
pub async fn add_contact(
state: State<'_, AppState>,
user_id: String,
) -> Result<Contact, String> {
let mut result: Vec<Contact> = state
.db
.query("CREATE contact SET owner = $auth, target = type::thing('user', $uid)")
.bind(("uid", user_id))
.await
.map_err(into_err)?
.take(0)
.map_err(into_err)?;
result.pop().ok_or_else(|| into_err(AppError::NotFound("contact after create".into())))
}
```
- [ ] **Step 2: Verify it compiles**
```bash
cd src-tauri && cargo check
```
Expected: no new errors from `user.rs`.
- [ ] **Step 3: Commit**
```bash
git add src-tauri/src/commands/user.rs
git commit -m "feat: add user commands (signup, signin, signout, get_me, update_profile, contacts)"
```
---
### Task 7: Create `commands/chat.rs`
**Files:**
- Create: `src-tauri/src/commands/chat.rs`
- [ ] **Step 1: Create `commands/chat.rs`**
```rust
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use futures_util::StreamExt;
use crate::db::AppState;
use crate::error::{into_err, AppError};
use crate::models::{Message, Room};
/// Create a new chat room.
#[tauri::command]
pub async fn create_room(
state: State<'_, AppState>,
name: String,
) -> Result<Room, String> {
let mut result: Vec<Room> = state
.db
.query("CREATE room SET name = $name, created = time::now()")
.bind(("name", name))
.await
.map_err(into_err)?
.take(0)
.map_err(into_err)?;
result.pop().ok_or_else(|| into_err(AppError::NotFound("room after create".into())))
}
/// Fetch all rooms.
#[tauri::command]
pub async fn get_rooms(state: State<'_, AppState>) -> Result<Vec<Room>, String> {
let result: Vec<Room> = state
.db
.query("SELECT * FROM room ORDER BY created DESC")
.await
.map_err(into_err)?
.take(0)
.map_err(into_err)?;
Ok(result)
}
/// Send a message to a room.
#[tauri::command]
pub async fn send_message(
state: State<'_, AppState>,
room_id: String,
body: String,
) -> Result<Message, String> {
let mut result: Vec<Message> = state
.db
.query(
"CREATE message SET
room = type::thing('room', $room_id),
author = $auth,
body = $body,
created = time::now()",
)
.bind(("room_id", room_id))
.bind(("body", body))
.await
.map_err(into_err)?
.take(0)
.map_err(into_err)?;
result.pop().ok_or_else(|| into_err(AppError::NotFound("message after create".into())))
}
/// Fetch all messages in a room, oldest first.
#[tauri::command]
pub async fn get_messages(
state: State<'_, AppState>,
room_id: String,
) -> Result<Vec<Message>, String> {
let result: Vec<Message> = state
.db
.query("SELECT * FROM message WHERE room = type::thing('room', $room_id) ORDER BY created ASC")
.bind(("room_id", room_id))
.await
.map_err(into_err)?
.take(0)
.map_err(into_err)?;
Ok(result)
}
/// Delete a message by its ID string (e.g. "message:abc123").
#[tauri::command]
pub async fn delete_message(
state: State<'_, AppState>,
message_id: String,
) -> Result<(), String> {
state
.db
.query("DELETE type::thing($id)")
.bind(("id", message_id))
.await
.map_err(into_err)?;
Ok(())
}
/// Start a LIVE query for new messages in a room.
/// Spawns a background tokio task that emits "chat:message" Tauri events.
///
/// Returns a local subscription UUID — pass it to `unsubscribe_room` on cleanup.
/// Aborting the JoinHandle drops the stream, which closes the LIVE query automatically.
#[tauri::command]
pub async fn subscribe_room(
state: State<'_, AppState>,
app_handle: AppHandle,
room_id: String,
) -> Result<String, String> {
let db = state.db.clone();
let mut stream = db
.query("LIVE SELECT * FROM message WHERE room = type::thing('room', $room_id)")
.bind(("room_id", room_id))
.await
.map_err(into_err)?
.stream::<surrealdb::Notification<Message>>(0)
.map_err(into_err)?;
let sub_id = Uuid::new_v4();
let handle = tokio::spawn(async move {
while let Some(Ok(notification)) = stream.next().await {
let _ = app_handle.emit("chat:message", &notification.data);
}
});
state.subscriptions.lock().unwrap().insert(sub_id, handle);
Ok(sub_id.to_string())
}
/// Stop a LIVE query subscription.
/// Aborts the background task — dropping the stream closes the LIVE query.
#[tauri::command]
pub async fn unsubscribe_room(
state: State<'_, AppState>,
sub_id: String,
) -> Result<(), String> {
let uuid = sub_id
.parse::<Uuid>()
.map_err(|e| into_err(AppError::Subscription(e.to_string())))?;
if let Some(handle) = state.subscriptions.lock().unwrap().remove(&uuid) {
handle.abort();
}
Ok(())
}
```
- [ ] **Step 2: Add `futures-util` dependency** (needed for `StreamExt` in subscribe_room)
Add to `src-tauri/Cargo.toml` `[dependencies]`:
```toml
futures-util = "0.3"
```
- [ ] **Step 3: Verify it compiles**
```bash
cd src-tauri && cargo check
```
Expected: no new errors from `chat.rs`.
- [ ] **Step 4: Commit**
```bash
git add src-tauri/src/commands/chat.rs src-tauri/Cargo.toml src-tauri/Cargo.lock
git commit -m "feat: add chat commands with LIVE query subscription support"
```
---
### Task 8: Update `lib.rs`
**Files:**
- Modify: `src-tauri/src/lib.rs`
- [ ] **Step 1: Replace `lib.rs` entirely**
```rust
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tauri::Manager;
mod commands;
mod db;
mod error;
mod models;
use db::{init_db, AppState};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let app_handle = app.handle().clone();
tauri::async_runtime::block_on(async move {
let surreal = init_db("localhost:8000", "oxyde", "oxyde")
.await
.expect("Failed to connect to SurrealDB");
let state = AppState {
db: Arc::new(surreal),
token: Mutex::new(None),
subscriptions: Mutex::new(HashMap::new()),
};
app_handle.manage(state);
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::user::signup,
commands::user::signin,
commands::user::signout,
commands::user::get_me,
commands::user::update_profile,
commands::user::get_contacts,
commands::user::add_contact,
commands::chat::create_room,
commands::chat::get_rooms,
commands::chat::send_message,
commands::chat::get_messages,
commands::chat::delete_message,
commands::chat::subscribe_room,
commands::chat::unsubscribe_room,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
- [ ] **Step 2: Verify full project compiles**
```bash
cd src-tauri && cargo check
```
Expected: clean — no errors.
- [ ] **Step 3: Commit**
```bash
git add src-tauri/src/lib.rs
git commit -m "feat: wire AppState and all commands into Tauri builder"
```
---
### Task 9: Create SurrealQL schema files
**Files:**
- Create: `surreal/schema.surql`
- Create: `surreal/auth.surql`
- [ ] **Step 1: Create `surreal/` directory and `schema.surql`**
```sql
-- surreal/schema.surql
-- Run once against your SurrealDB instance to define all tables and fields.
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD username ON user TYPE string;
DEFINE FIELD email ON user TYPE string;
DEFINE FIELD password ON user TYPE string;
DEFINE FIELD avatar ON user TYPE option<string>;
DEFINE FIELD created ON user TYPE datetime DEFAULT time::now();
DEFINE INDEX email_idx ON user FIELDS email UNIQUE;
DEFINE TABLE room SCHEMAFULL;
DEFINE FIELD name ON room TYPE string;
DEFINE FIELD created ON room TYPE datetime DEFAULT time::now();
DEFINE TABLE message SCHEMAFULL;
DEFINE FIELD room ON message TYPE record<room>;
DEFINE FIELD author ON message TYPE record<user>;
DEFINE FIELD body ON message TYPE string;
DEFINE FIELD created ON message TYPE datetime DEFAULT time::now();
DEFINE TABLE contact SCHEMAFULL;
DEFINE FIELD owner ON contact TYPE record<user>;
DEFINE FIELD target ON contact TYPE record<user>;
DEFINE INDEX unique_contact ON contact FIELDS owner, target UNIQUE;
```
- [ ] **Step 2: Create `surreal/auth.surql`**
```sql
-- surreal/auth.surql
-- Run after schema.surql.
-- Set SURREAL_JWT_SECRET env var before running SurrealDB,
-- then substitute the value here. Never hardcode in source control.
DEFINE ACCESS account ON DATABASE TYPE RECORD
SIGNUP (
CREATE user SET
email = $email,
username = $username,
password = crypto::argon2::generate($password)
)
SIGNIN (
SELECT * FROM user
WHERE email = $email
AND crypto::argon2::compare(password, $password)
)
WITH JWT ALGORITHM HS512 KEY env::get("SURREAL_JWT_SECRET");
```
- [ ] **Step 3: Commit**
```bash
git add surreal/
git commit -m "feat: add SurrealQL schema and Record Auth definitions"
```
---
### Task 10: Final compile + run check
**Files:** none new
- [ ] **Step 1: Run all Rust tests**
```bash
cd src-tauri && cargo test
```
Expected: all tests pass (error.rs and models.rs unit tests).
- [ ] **Step 2: Check Tauri dev build starts** (requires SurrealDB running locally)
```bash
# In a separate terminal, start SurrealDB:
surreal start --user root --pass root file://oxyde.db
# Then:
pnpm tauri dev
```
Expected: app window opens without a panic. SurrealDB connection errors will surface in the terminal if the server isn't running — that is expected in CI without a DB. The build itself must succeed.
- [ ] **Step 3: Final commit**
```bash
git add -p # review any remaining unstaged changes
git commit -m "chore: verify scaffold compiles and boots"
```

View File

@@ -0,0 +1,481 @@
# Context Menu Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a custom right-click context menu to the Oxyde chat app that replaces the browser default and offers context-aware copy actions on room names, message authors, and message bodies.
**Architecture:** A single shared `ContextMenu` Svelte component receives position + items as props and is rendered once in `+page.svelte`. State (`contextMenu`) lives in the page; a `showMenu` helper is passed down to `Sidebar` and `ChatMain` as a prop. Each trigger calls `showMenu` with the right items.
**Tech Stack:** Svelte 5 runes (`$state`, `$props`), `navigator.clipboard`, CSS custom properties already defined in `+page.svelte`.
---
## File Map
| File | Change |
|---|---|
| `src/lib/types.ts` | Add `ContextMenuItem` interface |
| `src/lib/components/ContextMenu.svelte` | New component — positioning, dismiss, copy + confirmation |
| `src/routes/+page.svelte` | Add `contextMenu` state, `showMenu` helper, render `<ContextMenu>`, pass prop to children |
| `src/lib/components/Sidebar.svelte` | Add `onShowMenu` prop, wire `oncontextmenu` on `.room-item` buttons |
| `src/lib/components/ChatMain.svelte` | Add `onShowMenu` prop, wire `oncontextmenu` on `.msg` div and `.msg-author` span |
---
### Task 1: Add `ContextMenuItem` type
**Files:**
- Modify: `src/lib/types.ts`
- [ ] **Step 1: Add the interface**
Open `src/lib/types.ts`. Append one line:
```ts
export interface User { id: any; username: string; email: string; avatar?: string; created: string; }
export interface Room { id: any; name: string; created: string; }
export interface Message { id: any; room: any; author: any; author_username?: string; body: string; created: string; }
export interface LiveEvent { action: 'Create' | 'Update' | 'Delete'; data: Message; }
export interface ContextMenuItem { label: string; action: () => void; }
```
- [ ] **Step 2: Verify TypeScript accepts it**
Run: `cd /home/qdust41/Oxyde && npx tsc --noEmit 2>&1 | head -20`
Expected: no errors (or only pre-existing errors unrelated to this file)
- [ ] **Step 3: Commit**
```bash
git add src/lib/types.ts
git commit -m "feat: add ContextMenuItem interface to types"
```
---
### Task 2: Create `ContextMenu.svelte` component
**Files:**
- Create: `src/lib/components/ContextMenu.svelte`
- [ ] **Step 1: Create the component**
Create `src/lib/components/ContextMenu.svelte` with the following content:
```svelte
<script lang="ts">
import { onMount } from 'svelte';
import type { ContextMenuItem } from '$lib/types';
interface Props {
x: number;
y: number;
items: ContextMenuItem[];
onclose: () => void;
}
let { x, y, items, onclose }: Props = $props();
let menuEl: HTMLElement;
let copiedIndex = $state<number | null>(null);
// Flip position if menu would overflow viewport
onMount(() => {
if (!menuEl) return;
const rect = menuEl.getBoundingClientRect();
if (rect.right > window.innerWidth) menuEl.style.left = (x - rect.width) + 'px';
if (rect.bottom > window.innerHeight) menuEl.style.top = (y - rect.height) + 'px';
});
async function handleItem(item: ContextMenuItem, index: number) {
await navigator.clipboard.writeText(''); // reset
item.action();
copiedIndex = index;
setTimeout(onclose, 1200);
}
function onWindowClick() { onclose(); }
function onWindowKey(e: KeyboardEvent) { if (e.key === 'Escape') onclose(); }
function onWindowContext(e: MouseEvent) { e.preventDefault(); onclose(); }
</script>
<svelte:window
onclick={onWindowClick}
onkeydown={onWindowKey}
oncontextmenu={onWindowContext}
/>
<ul
class="ctx-menu"
bind:this={menuEl}
style="left:{x}px; top:{y}px"
onclick={(e) => e.stopPropagation()}
oncontextmenu={(e) => e.stopPropagation()}
role="menu"
>
{#each items as item, i}
<li role="menuitem">
<button
class="ctx-item"
class:copied={copiedIndex === i}
onclick={() => handleItem(item, i)}
>
{copiedIndex === i ? 'Copied!' : item.label}
</button>
</li>
{/each}
</ul>
<style>
.ctx-menu {
position: fixed;
list-style: none;
min-width: 160px;
padding: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r);
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
z-index: 9999;
animation: rise 0.15s ease;
}
@keyframes rise {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.ctx-item {
display: block;
width: 100%;
padding: 7px 12px;
background: none;
border: none;
border-left: 2px solid transparent;
border-radius: var(--r);
color: var(--text-2);
font-family: inherit;
font-size: 11px;
text-align: left;
cursor: pointer;
transition: background 0.1s, color 0.1s, border-color 0.1s;
}
.ctx-item:hover {
background: var(--surface-2);
color: var(--text);
border-left-color: var(--accent);
}
.ctx-item.copied {
color: var(--accent);
background: var(--accent-soft);
}
</style>
```
- [ ] **Step 2: Verify no TypeScript errors**
Run: `cd /home/qdust41/Oxyde && npx tsc --noEmit 2>&1 | head -20`
Expected: no new errors
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/ContextMenu.svelte
git commit -m "feat: add ContextMenu component with copy confirmation and viewport overflow guard"
```
---
### Task 3: Wire context menu state into `+page.svelte`
**Files:**
- Modify: `src/routes/+page.svelte`
The page needs:
1. `contextMenu` state (nullable position + items object)
2. `showMenu` helper called by children
3. `<ContextMenu>` rendered inside the `{:else}` (app) block
4. `onShowMenu` prop passed to `Sidebar` and `ChatMain`
- [ ] **Step 1: Add import and state**
In `src/routes/+page.svelte`, add `ContextMenu` to the imports and `ContextMenuItem` to the type import, then add the state variable. Edit the `<script>` block:
```svelte
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
import AuthCard from '$lib/components/AuthCard.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import ChatMain from '$lib/components/ChatMain.svelte';
import ContextMenu from '$lib/components/ContextMenu.svelte';
import type { User, Room, Message, LiveEvent, ContextMenuItem } from '$lib/types';
import { sid, full, cmd } from '$lib/helpers';
// ─── State ────────────────────────────────────────────────────────────────
let user = $state<User | null>(null);
let rooms = $state<Room[]>([]);
let activeRoom = $state<Room | null>(null);
let messages = $state<Message[]>([]);
let contacts = $state<User[]>([]);
let subId = $state<string | null>(null);
let unlisten = $state<(() => void) | null>(null);
let view = $state<'loading' | 'auth' | 'app'>('loading');
let authMode = $state<'signin' | 'signup'>('signin');
let showNewRoom= $state(false);
let err = $state('');
let fEmail = $state(''); let fPass = $state('');
let fUser = $state(''); let fMsg = $state('');
let fRoom = $state('');
let contextMenu = $state<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
function showMenu(e: MouseEvent, items: ContextMenuItem[]) {
e.preventDefault();
contextMenu = { x: e.clientX, y: e.clientY, items };
}
```
(Keep all existing functions — `init`, `signin`, `signup`, `signout`, `loadRooms`, `selectRoom`, `createRoom`, `sendMessage`, `onMount`, `onDestroy` — unchanged.)
- [ ] **Step 2: Pass `onShowMenu` to children and render `<ContextMenu>`**
Replace the `{:else}` block template (the `.app` div and its children) with:
```svelte
{:else}
<div class="app">
<Sidebar
{user}
{rooms}
{contacts}
{activeRoom}
bind:showNewRoom
bind:fRoom
onSelectRoom={selectRoom}
onCreateRoom={createRoom}
onSignout={signout}
onShowMenu={showMenu}
/>
<ChatMain
{activeRoom}
{messages}
{err}
bind:fMsg
onSendMessage={sendMessage}
onShowMenu={showMenu}
/>
</div>
{#if contextMenu}
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={contextMenu.items}
onclose={() => contextMenu = null}
/>
{/if}
{/if}
```
- [ ] **Step 3: Verify no TypeScript errors**
Run: `cd /home/qdust41/Oxyde && npx tsc --noEmit 2>&1 | head -20`
Expected: errors about `onShowMenu` being unknown on `Sidebar` and `ChatMain` — these will be fixed in tasks 4 and 5.
- [ ] **Step 4: Commit**
```bash
git add src/routes/+page.svelte
git commit -m "feat: wire contextMenu state and showMenu helper in page, render ContextMenu"
```
---
### Task 4: Add `onShowMenu` to `Sidebar` and wire room item right-click
**Files:**
- Modify: `src/lib/components/Sidebar.svelte`
- [ ] **Step 1: Add prop to interface and destructuring**
In `Sidebar.svelte`, replace the `<script>` section:
```svelte
<script lang="ts">
import type { User, Room, ContextMenuItem } from '$lib/types';
import { full } from '$lib/helpers';
interface Props {
user: User | null;
rooms: Room[];
contacts: User[];
activeRoom: Room | null;
showNewRoom: boolean;
fRoom: string;
onSelectRoom: (room: Room) => void;
onCreateRoom: () => void;
onSignout: () => void;
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
}
let {
user,
rooms,
contacts,
activeRoom,
showNewRoom = $bindable(),
fRoom = $bindable(),
onSelectRoom,
onCreateRoom,
onSignout,
onShowMenu,
}: Props = $props();
</script>
```
- [ ] **Step 2: Wire `oncontextmenu` on `.room-item` buttons**
In the template, replace the `.room-item` button:
```svelte
<button
class="room-item"
class:active={activeRoom && full(room.id) === full(activeRoom.id)}
onclick={() => onSelectRoom(room)}
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy room name', action: () => navigator.clipboard.writeText(room.name) }])}
>
```
- [ ] **Step 3: Verify no TypeScript errors**
Run: `cd /home/qdust41/Oxyde && npx tsc --noEmit 2>&1 | head -20`
Expected: no errors from Sidebar. The `ChatMain` error may remain until task 5.
- [ ] **Step 4: Commit**
```bash
git add src/lib/components/Sidebar.svelte
git commit -m "feat: add onShowMenu prop to Sidebar, wire room item right-click"
```
---
### Task 5: Add `onShowMenu` to `ChatMain` and wire message/author right-click
**Files:**
- Modify: `src/lib/components/ChatMain.svelte`
- [ ] **Step 1: Add prop to interface and destructuring**
In `ChatMain.svelte`, replace the `<script>` section top (props only):
```svelte
<script lang="ts">
import { tick } from 'svelte';
import type { Room, Message, ContextMenuItem } from '$lib/types';
import { full, sid, fmt } from '$lib/helpers';
interface Props {
activeRoom: Room | null;
messages: Message[];
err: string;
fMsg: string;
onSendMessage: () => void;
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
}
let {
activeRoom,
messages,
err,
fMsg = $bindable(),
onSendMessage,
onShowMenu,
}: Props = $props();
```
(Keep `msgEl`, `inputEl`, `scrollBottom`, `autoResize`, `onKey`, `isGrouped`, and both `$effect` calls unchanged.)
- [ ] **Step 2: Wire `oncontextmenu` on `.msg` div and `.msg-author` span**
Replace the message loop in the template. The relevant section currently reads:
```svelte
{#each messages as msg, i (full(msg.id))}
<div class="msg" class:grouped={isGrouped(i)}>
{#if !isGrouped(i)}
<div class="msg-header">
<span class="msg-author">{msg.author_username ?? sid(msg.author)}</span>
<span class="msg-time">{fmt(msg.created)}</span>
</div>
{/if}
<p class="msg-body">{msg.body}</p>
</div>
{/each}
```
Replace with:
```svelte
{#each messages as msg, i (full(msg.id))}
<div
class="msg"
class:grouped={isGrouped(i)}
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy message', action: () => navigator.clipboard.writeText(msg.body) }])}
>
{#if !isGrouped(i)}
<div class="msg-header">
<span
class="msg-author"
oncontextmenu={(e) => { e.stopPropagation(); onShowMenu(e, [{ label: 'Copy username', action: () => navigator.clipboard.writeText(msg.author_username ?? sid(msg.author)) }]); }}
>{msg.author_username ?? sid(msg.author)}</span>
<span class="msg-time">{fmt(msg.created)}</span>
</div>
{/if}
<p class="msg-body">{msg.body}</p>
</div>
{/each}
```
- [ ] **Step 3: Verify no TypeScript errors**
Run: `cd /home/qdust41/Oxyde && npx tsc --noEmit 2>&1 | head -20`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add src/lib/components/ChatMain.svelte
git commit -m "feat: add onShowMenu prop to ChatMain, wire message and author right-click"
```
---
## Self-Review
**Spec coverage check:**
| Spec requirement | Covered by |
|---|---|
| `ContextMenuItem` type in `types.ts` | Task 1 |
| `ContextMenu` component with props `x, y, items, onclose` | Task 2 |
| `position: fixed` at `(x, y)` from `clientX/Y` | Task 2 — `style="left:{x}px; top:{y}px"` |
| Viewport overflow flip on mount | Task 2 — `onMount` checks `rect.right > window.innerWidth` and `rect.bottom > window.innerHeight` |
| Global `onclick` closes menu | Task 2 — `svelte:window onclick={onWindowClick}` |
| Global `onkeydown Escape` closes | Task 2 — `onWindowKey` checks `e.key === 'Escape'` |
| Global `oncontextmenu` closes + prevents default | Task 2 — `onWindowContext` |
| Confirmation "Copied!" for 1200ms then close | Task 2 — `copiedIndex` state + `setTimeout(onclose, 1200)` |
| `var(--accent)` + `var(--accent-soft)` copied state | Task 2 — `.ctx-item.copied` CSS |
| `rise` keyframe entrance animation | Task 2 — reused from page (defined in component) |
| Visual style: surface bg, border, shadow, min-width, padding, font | Task 2 — all present in CSS |
| State in `+page.svelte`, `showMenu` helper | Task 3 |
| `ContextMenu` rendered in app block gated on `contextMenu !== null` | Task 3 |
| `onShowMenu` passed to `Sidebar` | Tasks 3 + 4 |
| `onShowMenu` passed to `ChatMain` | Tasks 3 + 5 |
| Sidebar `.room-item` right-click → "Copy room name" → `room.name` | Task 4 |
| ChatMain `.msg-author` right-click → "Copy username" → `author_username ?? sid(author)` | Task 5 |
| ChatMain `.msg` right-click → "Copy message" → `msg.body` | Task 5 |
| Author `stopPropagation` prevents `.msg` handler | Task 5 — `e.stopPropagation()` on author handler |
All spec requirements covered. No placeholders. Type names consistent across all tasks (`ContextMenuItem`, `onShowMenu`, `contextMenu`).

View File

@@ -0,0 +1,253 @@
# Oxyde — Tauri + SurrealDB Scaffold Design
**Date:** 2026-04-14
**Stack:** Tauri v2, SvelteKit (Svelte 5), SurrealDB 2.x (remote WebSocket), pnpm
---
## 1. Architecture & File Layout
### Rust (`src-tauri/src/`)
```
src-tauri/src/
├── main.rs # #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
│ # fn main() → oxyde_lib::run()
├── lib.rs # Tauri Builder: managed state, register all commands, plugin init
├── db.rs # AppState struct + init_db()
├── models.rs # User, Room, Message, Contact — serde + SurrealDB derives
├── error.rs # AppError enum → impl From<AppError> for String
└── commands/
├── mod.rs # pub mod user; pub mod chat;
├── user.rs # signup, signin, signout, get_me, update_profile,
│ # get_contacts, add_contact
└── chat.rs # send_message, get_messages, delete_message,
# get_rooms, create_room, subscribe_room, unsubscribe_room
```
### `db.rs` — AppState
```rust
pub struct AppState {
pub db: Arc<Surreal<Client>>,
pub token: Mutex<Option<String>>,
pub subscriptions: Mutex<HashMap<Uuid, JoinHandle<()>>>,
}
```
- `db`: single long-lived WebSocket connection to SurrealDB
- `token`: JWT returned by SurrealDB Record Auth on signin, cleared on signout
- `subscriptions`: tracks spawned LIVE query tasks by UUID for clean cancellation
`init_db()` connects to `ws://localhost:8000`, selects namespace and database.
### `lib.rs`
Wires `AppState` into `tauri::Builder::manage()`, registers all commands via `invoke_handler`, initialises plugins.
### SurrealDB files (`surreal/`)
```
surreal/
├── schema.surql # DEFINE TABLE + DEFINE FIELD for all tables
└── auth.surql # DEFINE ACCESS account (Record Auth, JWT HS512)
```
### `Cargo.toml` additions
```toml
surrealdb = "2"
tokio = { version = "1", features = ["full"] }
thiserror = "1"
uuid = { version = "1", features = ["v4"] }
```
---
## 2. Data Flow — LIVE Queries → Frontend Events
```
Frontend Tauri Command SurrealDB
│ │ │
│──invoke("subscribe_room")───▶│ │
│ │──LIVE SELECT * FROM─────▶│
│ │ message WHERE │
│ │ room = $room_id │
│ │ │
│ │ spawn tokio::task │
│ │ (holds LIVE stream) │
│ │◀────────────────────────│
│◀──Ok(live_query_id: String)──│ │
│ │ │
│ [new message inserted] │ │
│ │◀──LIVE notification─────│
│ │ │
│ │ app_handle.emit( │
│ │ "chat:message", │
│ │ MessagePayload) │
│◀──Tauri event───────────────│ │
│ listen("chat:message", cb) │ │
```
**Key details:**
- `subscribe_room` returns a `String` (LIVE query UUID) to the frontend
- Frontend stores UUID and calls `unsubscribe_room(uuid)` on component unmount
- Spawned task holds `AppHandle` clone — required to emit events from background
- `unsubscribe_room` aborts the `JoinHandle` and sends `KILL <uuid>` to SurrealDB
- One task per room; map lives in `AppState.subscriptions`
---
## 3. SurrealQL Schema & Auth
### `surreal/schema.surql`
```sql
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD username ON user TYPE string;
DEFINE FIELD email ON user TYPE string;
DEFINE FIELD password ON user TYPE string;
DEFINE FIELD avatar ON user TYPE option<string>;
DEFINE FIELD created ON user TYPE datetime DEFAULT time::now();
DEFINE INDEX email_idx ON user FIELDS email UNIQUE;
DEFINE TABLE room SCHEMAFULL;
DEFINE FIELD name ON room TYPE string;
DEFINE FIELD created ON room TYPE datetime DEFAULT time::now();
DEFINE TABLE message SCHEMAFULL;
DEFINE FIELD room ON message TYPE record<room>;
DEFINE FIELD author ON message TYPE record<user>;
DEFINE FIELD body ON message TYPE string;
DEFINE FIELD created ON message TYPE datetime DEFAULT time::now();
DEFINE TABLE contact SCHEMAFULL;
DEFINE FIELD owner ON contact TYPE record<user>;
DEFINE FIELD target ON contact TYPE record<user>;
DEFINE INDEX unique_contact ON contact FIELDS owner, target UNIQUE;
```
### `surreal/auth.surql`
```sql
DEFINE ACCESS account ON DATABASE TYPE RECORD
SIGNUP (
CREATE user SET
email = $email,
username = $username,
password = crypto::argon2::generate($password)
)
SIGNIN (
SELECT * FROM user
WHERE email = $email
AND crypto::argon2::compare(password, $password)
)
WITH JWT ALGORITHM HS512 KEY $jwt_secret;
```
**Note:** `$jwt_secret` must be injected via env var or Tauri's secure store — never hardcoded.
**Schema decisions:**
- `password` is an explicit field (required in schemafull mode)
- `contact` uses a separate table with `owner`/`target` record links — supports bidirectional queries without array fields on `user`
---
## 4. Error Handling
### `error.rs`
```rust
#[derive(thiserror::Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Db(#[from] surrealdb::Error),
#[error("Auth error: {0}")]
Auth(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Subscription error: {0}")]
Subscription(String),
}
impl From<AppError> for String {
fn from(e: AppError) -> Self {
e.to_string()
}
}
pub fn into_err<E: Into<AppError>>(e: E) -> String {
e.into().to_string()
}
```
All Tauri commands return `Result<T, String>`. Use `.map_err(into_err)` at command boundaries. Internal functions use `Result<T, AppError>`.
---
## 5. Command Surface
### `commands/user.rs`
| Command | Args | Returns |
|---|---|---|
| `signup` | email, username, password | `User` |
| `signin` | email, password | `String` (JWT) |
| `signout` | — | `()` |
| `get_me` | — | `User` |
| `update_profile` | username?, avatar? | `User` |
| `get_contacts` | — | `Vec<User>` |
| `add_contact` | user_id | `Contact` (stub) |
### `commands/chat.rs`
| Command | Args | Returns |
|---|---|---|
| `create_room` | name | `Room` |
| `get_rooms` | — | `Vec<Room>` |
| `send_message` | room_id, body | `Message` |
| `get_messages` | room_id | `Vec<Message>` |
| `delete_message` | message_id | `()` |
| `subscribe_room` | room_id | `String` (UUID) — `AppHandle` injected by Tauri |
| `unsubscribe_room` | uuid | `()` |
---
## 6. Models (`models.rs`)
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub id: Thing,
pub username: String,
pub email: String,
pub avatar: Option<String>,
pub created: Datetime,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Room {
pub id: Thing,
pub name: String,
pub created: Datetime,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Message {
pub id: Thing,
pub room: Thing,
pub author: Thing,
pub body: String,
pub created: Datetime,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Contact {
pub id: Thing,
pub owner: Thing,
pub target: Thing,
}
```
`Thing` and `Datetime` from `surrealdb::sql`.

View File

@@ -0,0 +1,120 @@
# Context Menu — Design Spec
**Date:** 2026-04-15
**Status:** Approved
## Overview
Custom right-click context menu for the Oxyde chat app. Replaces the browser default. Context-aware: menu items differ based on the element right-clicked. Copy-only for now, with a "Copied!" confirmation. Built with Approach A — shared component, state lifted to `+page.svelte`.
---
## 1. New Type
Add to `src/lib/types.ts`:
```ts
export interface ContextMenuItem { label: string; action: () => void }
```
---
## 2. New Component
**File:** `src/lib/components/ContextMenu.svelte`
### Props
```ts
{ x: number; y: number; items: ContextMenuItem[]; onclose: () => void }
```
### Positioning
- `position: fixed` at `(x, y)` from `MouseEvent.clientX/Y`
- On mount: check if menu overflows viewport right or bottom edge; if so, flip left/upward
- Immune to scroll
### Dismiss
- Global `onclick` on `svelte:window` closes menu (menu container stops propagation)
- Global `onkeydown` closes on `Escape`
- Global `oncontextmenu` on `svelte:window` closes and prevents default (stops stale menu persisting on second right-click)
- Selecting an item closes after 1200ms (post-confirmation)
### Copy & Confirmation
- Copy via `navigator.clipboard.writeText()`
- On click: item label changes to `"Copied!"`, color shifts to `var(--accent)` with `var(--accent-soft)` background
- After 1200ms: menu closes
- Uses per-item `copiedIndex` state (index of last-copied item)
---
## 3. State in `+page.svelte`
```ts
let contextMenu = $state<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
function showMenu(e: MouseEvent, items: ContextMenuItem[]) {
e.preventDefault();
contextMenu = { x: e.clientX, y: e.clientY, items };
}
```
`ContextMenu` renders at the bottom of the `{:else}` (app) block, gated on `contextMenu !== null`:
```svelte
{#if contextMenu}
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={contextMenu.items}
onclose={() => contextMenu = null}
/>
{/if}
```
`showMenu` is passed as a prop to both `Sidebar` and `ChatMain`.
---
## 4. Trigger Targets
| Component | Element | Right-click handler | Menu item | Copies |
|---|---|---|---|---|
| `Sidebar` | `.room-item` button | `oncontextmenu` | "Copy room name" | `room.name` |
| `ChatMain` | `.msg-author` span | `oncontextmenu` + `stopPropagation` | "Copy username" | `msg.author_username ?? sid(msg.author)` |
| `ChatMain` | `.msg` div | `oncontextmenu` | "Copy message" | `msg.body` |
Author `stopPropagation` prevents the parent `.msg` handler from also firing.
### Prop additions
- `Sidebar`: `onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void`
- `ChatMain`: `onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void`
---
## 5. Visual Style
| Property | Value |
|---|---|
| Background | `var(--surface)` |
| Border | `1px solid var(--border)` |
| Border radius | `var(--r)` (2px) |
| Box shadow | `0 4px 16px rgba(0,0,0,0.4)` |
| Min width | 160px |
| List padding | 4px |
| Item padding | `7px 12px` |
| Font | `inherit` (Martian Mono), 11px |
| Item color | `var(--text-2)` |
| Item hover | bg `var(--surface-2)`, color `var(--text)`, left border `2px solid var(--accent)` |
| Copied state | color `var(--accent)`, bg `var(--accent-soft)` |
| Entrance animation | Reuse existing `rise` keyframe (opacity + translateY, 0.15s) |
---
## 6. Files Changed
| File | Change |
|---|---|
| `src/lib/types.ts` | Add `ContextMenuItem` interface |
| `src/lib/components/ContextMenu.svelte` | New component |
| `src/routes/+page.svelte` | Add state, `showMenu` helper, render `ContextMenu`, pass prop to children |
| `src/lib/components/Sidebar.svelte` | Add `onShowMenu` prop, wire `oncontextmenu` on room items |
| `src/lib/components/ChatMain.svelte` | Add `onShowMenu` prop, wire `oncontextmenu` on `.msg` and `.msg-author` |