https://checkmate.social
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial commit

jcalabro 56a8219a

+4946
+34
.gitignore
··· 1 + # Dependencies 2 + node_modules/ 3 + 4 + # Build output 5 + dist/ 6 + *.wasm 7 + 8 + # Environment 9 + .env.local 10 + .env.*.local 11 + 12 + # Editor 13 + .vscode/ 14 + .idea/ 15 + *.swp 16 + *.swo 17 + *~ 18 + 19 + # OS 20 + .DS_Store 21 + Thumbs.db 22 + 23 + # SpacetimeDB generated bindings (regenerated from module) 24 + client/src/module_bindings/ 25 + 26 + # SpacetimeDB local server 27 + .spacetime.pid 28 + .spacetime.log 29 + 30 + # Logs 31 + *.log 32 + 33 + # Test coverage 34 + coverage/
+766
AGENTS.md
··· 1 + # SpacetimeDB Rules (All Languages) 2 + 3 + ## Migrating from 1.0 to 2.0? 4 + 5 + **If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules. 6 + 7 + --- 8 + 9 + ## Language-Specific Rules 10 + 11 + | Language | Rule File | 12 + |----------|-----------| 13 + | **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) | 14 + | **Rust** | `spacetimedb-rust.mdc` (MANDATORY) | 15 + | **C#** | `spacetimedb-csharp.mdc` (MANDATORY) | 16 + | **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` | 17 + 18 + --- 19 + 20 + ## Core Concepts 21 + 22 + 1. **Reducers are transactional** — they do not return data to callers 23 + 2. **Reducers must be deterministic** — no filesystem, network, timers, or random 24 + 3. **Read data via tables/subscriptions** — not reducer return values 25 + 4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering 26 + 5. **`ctx.sender` is the authenticated principal** — never trust identity args 27 + 28 + --- 29 + 30 + ## Feature Implementation Checklist 31 + 32 + When implementing a feature that spans backend and client: 33 + 34 + 1. **Backend:** Define table(s) to store the data 35 + 2. **Backend:** Define reducer(s) to mutate the data 36 + 3. **Client:** Subscribe to the table(s) 37 + 4. **Client:** Call the reducer(s) from UI — **don't forget this step!** 38 + 5. **Client:** Render the data from the table(s) 39 + 40 + **Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them. 41 + 42 + --- 43 + 44 + ## Index System 45 + 46 + SpacetimeDB automatically creates indexes for: 47 + - Primary key columns 48 + - Columns marked as unique 49 + 50 + You can add explicit indexes on non-unique columns for query performance. 51 + 52 + **Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error. 53 + 54 + **Schema ↔ Code coupling:** 55 + - Your query code references indexes by name 56 + - If you add/remove/rename an index in the schema, update all code that uses it 57 + - Removing an index without updating queries causes runtime errors 58 + 59 + --- 60 + 61 + ## Commands 62 + 63 + ```bash 64 + # Login to allow remote database deployment e.g. to maincloud 65 + spacetime login 66 + 67 + # Start local SpacetimeDB 68 + spacetime start 69 + 70 + # Publish module 71 + spacetime publish <db-name> --module-path <module-path> 72 + 73 + # Clear and republish 74 + spacetime publish <db-name> --clear-database -y --module-path <module-path> 75 + 76 + # Generate client bindings 77 + spacetime generate --lang <lang> --out-dir <out> --module-path <module-path> 78 + 79 + # View logs 80 + spacetime logs <db-name> 81 + ``` 82 + 83 + --- 84 + 85 + ## Deployment 86 + 87 + - Maincloud is the spacetimedb hosted cloud and the default location for module publishing 88 + - The default server marked by *** in `spacetime server list` should be used when publishing 89 + - If the default server is maincloud you should publish to maincloud 90 + - Publishing to maincloud is free of charge 91 + - When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name> 92 + - The database owner can view utilization and performance metrics on the dashboard 93 + 94 + --- 95 + 96 + ## Debugging Checklist 97 + 98 + 1. Is SpacetimeDB server running? (`spacetime start`) 99 + 2. Is the module published? (`spacetime publish`) 100 + 3. Are client bindings generated? (`spacetime generate`) 101 + 4. Check server logs for errors (`spacetime logs <db-name>`) 102 + 5. **Is the reducer actually being called from the client?** 103 + 104 + --- 105 + 106 + ## Editing Behavior 107 + 108 + - Make the smallest change necessary 109 + - Do NOT touch unrelated files, configs, or dependencies 110 + - Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo 111 + - Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users 112 + 113 + 114 + # SpacetimeDB TypeScript SDK 115 + 116 + ## ⛔ HALLUCINATED APIs — DO NOT USE 117 + 118 + **These APIs DO NOT EXIST. LLMs frequently hallucinate them.** 119 + 120 + ```typescript 121 + // ❌ WRONG PACKAGE — does not exist 122 + import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk"; 123 + 124 + // ❌ WRONG — these methods don't exist 125 + SpacetimeDBClient.connect(...); 126 + SpacetimeDBClient.call("reducer_name", [...]); 127 + connection.call("reducer_name", [arg1, arg2]); 128 + 129 + // ❌ WRONG — positional reducer arguments 130 + conn.reducers.doSomething("value"); // WRONG! 131 + 132 + // ❌ WRONG — static methods on generated types don't exist 133 + User.filterByName('alice'); 134 + Message.findById(123n); 135 + tables.user.filter(u => u.name === 'alice'); // No .filter() on tables object! 136 + ``` 137 + 138 + ### ✅ CORRECT PATTERNS: 139 + 140 + ```typescript 141 + // ✅ CORRECT IMPORTS 142 + import { DbConnection, tables } from './module_bindings'; // Generated! 143 + import { SpacetimeDBProvider, useTable, Identity } from 'spacetimedb/react'; 144 + 145 + // ✅ CORRECT REDUCER CALLS — object syntax, not positional! 146 + conn.reducers.doSomething({ value: 'test' }); 147 + conn.reducers.updateItem({ itemId: 1n, newValue: 42 }); 148 + 149 + // ✅ CORRECT DATA ACCESS — useTable returns [rows, isLoading] 150 + const [items, isLoading] = useTable(tables.item); 151 + ``` 152 + 153 + ### ⛔ DO NOT: 154 + - **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)` 155 + - **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings` 156 + 157 + --- 158 + 159 + ## 1) Common Mistakes Table 160 + 161 + ### Server-side errors 162 + 163 + | Wrong | Right | Error | 164 + |-------|-------|-------| 165 + | Missing `package.json` | Create `package.json` | "could not detect language" | 166 + | Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" | 167 + | Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle | 168 + | `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) | "reading 'tag'" error | 169 + | Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error | 170 + | `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" | 171 + | `.filter()` on unique column | `.find()` on unique column | TypeError | 172 + | `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" | 173 + | `const id = table.insert(...)` | `const row = table.insert(...)` | `.insert()` returns ROW, not ID | 174 + | `.unique()` + explicit index | Just use `.unique()` | "name is used for multiple entities" | 175 + | Index on `.primaryKey()` column | Don't — already indexed | "name is used for multiple entities" | 176 + | Same index name in multiple tables | Prefix with table name | "name is used for multiple entities" | 177 + | `.indexName.filter()` after removing index | Use `.iter()` + manual filter | "Cannot read properties of undefined" | 178 + | Import spacetimedb from index.ts | Import from schema.ts | "Cannot access before initialization" | 179 + | Multi-column index `.filter()` | **⚠️ BROKEN** — use single-column | PANIC or silent empty results | 180 + | `JSON.stringify({ id: row.id })` | Convert BigInt first: `{ id: row.id.toString() }` | "Do not know how to serialize a BigInt" | 181 + | `ScheduleAt.Time(timestamp)` | `ScheduleAt.time(timestamp)` (lowercase) | "ScheduleAt.Time is not a function" | 182 + | `ctx.db.foo.myIndexName.filter()` | Use exact name: `ctx.db.foo.my_index_name.filter()` | "Cannot read properties of undefined" | 183 + | `.iter()` in views | Use index lookups | Severe performance issues (re-evaluates on any change) | 184 + | `ctx.db` in procedures | `ctx.withTx(tx => tx.db...)` | Procedures need explicit transactions | 185 + | `ctx.myTable` in procedure tx | `tx.db.myTable` | Wrong context variable | 186 + 187 + ### Client-side errors 188 + 189 + | Wrong | Right | Error | 190 + |-------|-------|-------| 191 + | `@spacetimedb/sdk` | `spacetimedb` | 404 / missing subpath | 192 + | `conn.reducers.foo("val")` | `conn.reducers.foo({ param: "val" })` | Wrong reducer syntax | 193 + | Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render | 194 + | `const rows = useTable(table)` | `const [rows, isLoading] = useTable(table)` | Tuple destructuring | 195 + | Optimistic UI updates | Let subscriptions drive state | Desync issues | 196 + | `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name | 197 + 198 + --- 199 + 200 + ## 2) Table Definition (CRITICAL) 201 + 202 + **`table()` takes TWO arguments: `table(OPTIONS, COLUMNS)`** 203 + 204 + ```typescript 205 + import { schema, table, t } from 'spacetimedb/server'; 206 + 207 + // ❌ WRONG — indexes in COLUMNS causes "reading 'tag'" error 208 + export const Task = table({ name: 'task' }, { 209 + id: t.u64().primaryKey().autoInc(), 210 + ownerId: t.identity(), 211 + indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }] // ❌ WRONG! 212 + }); 213 + 214 + // ✅ RIGHT — indexes in OPTIONS (first argument) 215 + export const Task = table({ 216 + name: 'task', 217 + public: true, 218 + indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }] 219 + }, { 220 + id: t.u64().primaryKey().autoInc(), 221 + ownerId: t.identity(), 222 + title: t.string(), 223 + createdAt: t.timestamp(), 224 + }); 225 + ``` 226 + 227 + ### Column types 228 + ```typescript 229 + t.identity() // User identity (primary key for per-user tables) 230 + t.u64() // Unsigned 64-bit integer (use for IDs) 231 + t.string() // Text 232 + t.bool() // Boolean 233 + t.timestamp() // Timestamp (use ctx.timestamp for current time) 234 + t.scheduleAt() // For scheduled tables only 235 + 236 + // Product types (nested objects) — use t.object, NOT t.struct 237 + const Point = t.object('Point', { x: t.i32(), y: t.i32() }); 238 + 239 + // Sum types (tagged unions) — use t.enum, NOT t.sum 240 + const Shape = t.enum('Shape', { circle: t.i32(), rectangle: Point }); 241 + // Values use { tag: 'circle', value: 10 } or { tag: 'rectangle', value: { x: 1, y: 2 } } 242 + 243 + // Modifiers 244 + t.string().optional() // Nullable 245 + t.u64().primaryKey() // Primary key 246 + t.u64().primaryKey().autoInc() // Auto-increment primary key 247 + ``` 248 + 249 + > ⚠️ **BIGINT SYNTAX:** All `u64`, `i64`, and ID fields use JavaScript BigInt. 250 + > - Literals: `0n`, `1n`, `100n` (NOT `0`, `1`, `100`) 251 + > - Comparisons: `row.id === 5n` (NOT `row.id === 5`) 252 + > - Arithmetic: `row.count + 1n` (NOT `row.count + 1`) 253 + 254 + ### Auto-increment placeholder 255 + ```typescript 256 + // ✅ MUST provide 0n placeholder for auto-inc fields 257 + ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title: 'New', createdAt: ctx.timestamp }); 258 + ``` 259 + 260 + ### Insert returns ROW, not ID 261 + ```typescript 262 + // ❌ WRONG 263 + const id = ctx.db.task.insert({ ... }); 264 + 265 + // ✅ RIGHT 266 + const row = ctx.db.task.insert({ ... }); 267 + const newId = row.id; // Extract .id from returned row 268 + ``` 269 + 270 + ### Schema export (CRITICAL) 271 + ```typescript 272 + // At end of schema.ts — schema() takes exactly ONE argument: an object 273 + const spacetimedb = schema({ table1, table2, table3 }); 274 + export default spacetimedb; 275 + 276 + // ❌ WRONG — never pass tables directly or as multiple args 277 + schema(myTable); // WRONG! 278 + schema(t1, t2, t3); // WRONG! 279 + ``` 280 + 281 + --- 282 + 283 + ## 3) Index Access 284 + 285 + ### TypeScript Query Patterns 286 + 287 + ```typescript 288 + // 1. PRIMARY KEY — use .pkColumn.find() 289 + const user = ctx.db.user.identity.find(ctx.sender); 290 + const msg = ctx.db.message.id.find(messageId); 291 + 292 + // 2. EXPLICIT INDEX — use .indexName.filter(value) 293 + const msgs = [...ctx.db.message.message_room_id.filter(roomId)]; 294 + 295 + // 3. NO INDEX — use .iter() + manual filter 296 + for (const m of ctx.db.roomMember.iter()) { 297 + if (m.roomId === roomId) { /* ... */ } 298 + } 299 + ``` 300 + 301 + ### Index Definition Syntax 302 + 303 + ```typescript 304 + // In table OPTIONS (first argument), not columns 305 + export const Message = table({ 306 + name: 'message', 307 + public: true, 308 + indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }] 309 + }, { 310 + id: t.u64().primaryKey().autoInc(), 311 + roomId: t.u64(), 312 + // ... 313 + }); 314 + ``` 315 + 316 + ### Naming conventions 317 + 318 + **Table names — automatic transformation:** 319 + - Schema: `table({ name: 'my_messages' })` 320 + - Access: `ctx.db.myMessages` (automatic snake_case → camelCase) 321 + 322 + **Index names — NO transformation, use EXACTLY as defined:** 323 + ```typescript 324 + // Schema definition 325 + indexes: [{ name: 'canvas_member_canvas_id', algorithm: 'btree', columns: ['canvasId'] }] 326 + 327 + // ❌ WRONG — don't assume camelCase transformation 328 + ctx.db.canvasMember.canvasMember_canvas_id.filter(...) // WRONG! 329 + ctx.db.canvasMember.canvasMemberCanvasId.filter(...) // WRONG! 330 + 331 + // ✅ RIGHT — use exact name from schema 332 + ctx.db.canvasMember.canvas_member_canvas_id.filter(...) 333 + ``` 334 + 335 + > ⚠️ **Index names are used VERBATIM** — pick a convention (snake_case or camelCase) and stick with it. 336 + 337 + **Index naming pattern — use `{tableName}_{columnName}`:** 338 + ```typescript 339 + // ✅ GOOD — unique names across entire module 340 + indexes: [{ name: 'message_room_id', algorithm: 'btree', columns: ['roomId'] }] 341 + indexes: [{ name: 'reaction_message_id', algorithm: 'btree', columns: ['messageId'] }] 342 + 343 + // ❌ BAD — will collide if multiple tables use same index name 344 + indexes: [{ name: 'by_owner', ... }] // in Task table 345 + indexes: [{ name: 'by_owner', ... }] // in Note table — CONFLICT! 346 + ``` 347 + 348 + **Client-side table names:** 349 + - Check generated `module_bindings/index.ts` for exact export names 350 + - Usage: `useTable(tables.MyMessages)` or `tables.myMessages` (varies by SDK version) 351 + 352 + ### Filter vs Find 353 + ```typescript 354 + // Filter takes VALUE directly, not object — returns iterator 355 + const rows = [...ctx.db.task.by_owner.filter(ownerId)]; 356 + 357 + // Unique columns use .find() — returns single row or undefined 358 + const row = ctx.db.player.identity.find(ctx.sender); 359 + ``` 360 + 361 + ### ⚠️ Multi-column indexes are BROKEN 362 + ```typescript 363 + // ❌ DON'T — causes PANIC 364 + ctx.db.scores.by_player_level.filter(playerId); 365 + 366 + // ✅ DO — use single-column index + manual filter 367 + for (const row of ctx.db.scores.by_player.filter(playerId)) { 368 + if (row.level === targetLevel) { /* ... */ } 369 + } 370 + ``` 371 + 372 + --- 373 + 374 + ## 4) Reducers 375 + 376 + ### Definition syntax (CRITICAL) 377 + **Reducer name comes from the export — NOT from a string argument.** Use `reducer(params, fn)` or `reducer(fn)`. 378 + 379 + ```typescript 380 + import spacetimedb from './schema'; 381 + import { t, SenderError } from 'spacetimedb/server'; 382 + 383 + // ✅ CORRECT — export const name = spacetimedb.reducer(params, fn) 384 + export const reducer_name = spacetimedb.reducer({ param1: t.string(), param2: t.u64() }, (ctx, { param1, param2 }) => { 385 + // Validation 386 + if (!param1) throw new SenderError('param1 required'); 387 + 388 + // Access tables via ctx.db 389 + const row = ctx.db.myTable.primaryKey.find(param2); 390 + 391 + // Mutations 392 + ctx.db.myTable.insert({ ... }); 393 + ctx.db.myTable.primaryKey.update({ ...row, newField: value }); 394 + ctx.db.myTable.primaryKey.delete(param2); 395 + }); 396 + 397 + // No params: export const init = spacetimedb.reducer((ctx) => { ... }); 398 + ``` 399 + 400 + ```typescript 401 + // ❌ WRONG — reducer('name', params, fn) does NOT exist 402 + spacetimedb.reducer('reducer_name', { param1: t.string() }, (ctx, { param1 }) => { ... }); 403 + ``` 404 + 405 + ### Update pattern (CRITICAL) 406 + ```typescript 407 + // ✅ CORRECT — spread existing row, override specific fields 408 + const existing = ctx.db.task.id.find(taskId); 409 + if (!existing) throw new SenderError('Task not found'); 410 + ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp }); 411 + 412 + // ❌ WRONG — partial update nulls out other fields! 413 + ctx.db.task.id.update({ id: taskId, title: newTitle }); 414 + ``` 415 + 416 + ### Delete pattern 417 + ```typescript 418 + // Delete by primary key VALUE (not row object) 419 + ctx.db.task.id.delete(taskId); // taskId is the u64 value 420 + ctx.db.player.identity.delete(ctx.sender); // delete by identity 421 + ``` 422 + 423 + ### Lifecycle hooks 424 + ```typescript 425 + spacetimedb.clientConnected((ctx) => { 426 + // ctx.sender is the connecting identity 427 + // Create/update user record, set online status, etc. 428 + }); 429 + 430 + spacetimedb.clientDisconnected((ctx) => { 431 + // Clean up: set offline status, remove ephemeral data, etc. 432 + }); 433 + ``` 434 + 435 + ### Snake_case to camelCase conversion 436 + - Server: `export const do_something = spacetimedb.reducer(...)` — name from export 437 + - Client: `conn.reducers.doSomething({ ... })` 438 + 439 + ### Object syntax required 440 + ```typescript 441 + // ❌ WRONG - positional 442 + conn.reducers.doSomething('value'); 443 + 444 + // ✅ RIGHT - object 445 + conn.reducers.doSomething({ param: 'value' }); 446 + ``` 447 + 448 + --- 449 + 450 + ## 5) Scheduled Tables 451 + 452 + ```typescript 453 + // 1. Define table first (scheduled: () => reducer — pass the exported reducer) 454 + export const CleanupJob = table({ 455 + name: 'cleanup_job', 456 + scheduled: () => run_cleanup // reducer defined below 457 + }, { 458 + scheduledId: t.u64().primaryKey().autoInc(), 459 + scheduledAt: t.scheduleAt(), 460 + targetId: t.u64(), // Your custom data 461 + }); 462 + 463 + // 2. Define scheduled reducer (receives full row as arg) 464 + export const run_cleanup = spacetimedb.reducer({ arg: CleanupJob.rowType }, (ctx, { arg }) => { 465 + // arg.scheduledId, arg.targetId available 466 + // Row is auto-deleted after reducer completes 467 + }); 468 + 469 + // Schedule a job 470 + import { ScheduleAt } from 'spacetimedb'; 471 + const futureTime = ctx.timestamp.microsSinceUnixEpoch + 60_000_000n; // 60 seconds 472 + ctx.db.cleanupJob.insert({ 473 + scheduledId: 0n, 474 + scheduledAt: ScheduleAt.time(futureTime), 475 + targetId: someId 476 + }); 477 + 478 + // Cancel a job by deleting the row 479 + ctx.db.cleanupJob.scheduledId.delete(jobId); 480 + ``` 481 + 482 + --- 483 + 484 + ## 6) Timestamps 485 + 486 + ### Server-side 487 + ```typescript 488 + import { Timestamp, ScheduleAt } from 'spacetimedb'; 489 + 490 + // Current time 491 + ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp }); 492 + 493 + // Future time (add microseconds) 494 + const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n; // 5 minutes 495 + ``` 496 + 497 + ### Client-side (CRITICAL) 498 + **Timestamps are objects, not numbers:** 499 + ```typescript 500 + // ❌ WRONG 501 + const date = new Date(row.createdAt); 502 + const date = new Date(Number(row.createdAt / 1000n)); 503 + 504 + // ✅ RIGHT 505 + const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n)); 506 + ``` 507 + 508 + ### ScheduleAt on client 509 + ```typescript 510 + // ScheduleAt is a tagged union 511 + if (scheduleAt.tag === 'Time') { 512 + const date = new Date(Number(scheduleAt.value.microsSinceUnixEpoch / 1000n)); 513 + } 514 + ``` 515 + 516 + --- 517 + 518 + ## 7) Data Visibility & Subscriptions 519 + 520 + **`public: true` exposes ALL rows to ALL clients.** 521 + 522 + | Scenario | Pattern | 523 + |----------|---------| 524 + | Everyone sees all rows | `public: true` | 525 + | Users see only their data | Private table + filtered subscription | 526 + 527 + ### Subscription patterns (client-side) 528 + ```typescript 529 + // Subscribe to ALL public tables (simplest) 530 + conn.subscriptionBuilder().subscribeToAll(); 531 + 532 + // Subscribe to specific tables with SQL 533 + conn.subscriptionBuilder().subscribe([ 534 + 'SELECT * FROM message', 535 + 'SELECT * FROM room WHERE is_public = true', 536 + ]); 537 + 538 + // Handle subscription lifecycle 539 + conn.subscriptionBuilder() 540 + .onApplied(() => console.log('Initial data loaded')) 541 + .onError((e) => console.error('Subscription failed:', e)) 542 + .subscribeToAll(); 543 + ``` 544 + 545 + ### Private table + view pattern (RECOMMENDED) 546 + 547 + **Views are the recommended approach** for controlling data visibility. They provide: 548 + - Server-side filtering (reduces network traffic) 549 + - Real-time updates when underlying data changes 550 + - Full control over what data clients can access 551 + 552 + > ⚠️ **Do NOT use Row Level Security (RLS)** — it is deprecated. 553 + 554 + > ⚠️ **CRITICAL:** Procedural views (views that compute results in code) can ONLY access data via index lookups, NOT `.iter()`. 555 + > If you need a view that scans/filters across many rows (including the entire table), return a **query** built with the query builder (`ctx.from...`). 556 + 557 + ```typescript 558 + // Private table with index on ownerId 559 + export const PrivateData = table( 560 + { name: 'private_data', 561 + indexes: [{ name: 'by_owner', algorithm: 'btree', columns: ['ownerId'] }] 562 + }, 563 + { 564 + id: t.u64().primaryKey().autoInc(), 565 + ownerId: t.identity(), 566 + secret: t.string() 567 + } 568 + ); 569 + 570 + // ❌ BAD — .iter() causes performance issues (re-evaluates on ANY row change) 571 + spacetimedb.view( 572 + { name: 'my_data_slow', public: true }, 573 + t.array(PrivateData.rowType), 574 + (ctx) => [...ctx.db.privateData.iter()] // Works but VERY slow at scale 575 + ); 576 + 577 + // ✅ GOOD — index lookup enables targeted invalidation 578 + spacetimedb.view( 579 + { name: 'my_data', public: true }, 580 + t.array(PrivateData.rowType), 581 + (ctx) => [...ctx.db.privateData.by_owner.filter(ctx.sender)] 582 + ); 583 + ``` 584 + 585 + ### Query builder view pattern (can scan) 586 + 587 + ```typescript 588 + // Query-builder views return a query; the SQL engine maintains the result incrementally. 589 + // This can scan the whole table if needed (e.g. leaderboard-style queries). 590 + spacetimedb.anonymousView( 591 + { name: 'top_players', public: true }, 592 + t.array(Player.rowType), 593 + (ctx) => 594 + ctx.from.player 595 + .where(p => p.score.gt(1000)) 596 + ); 597 + ``` 598 + 599 + ### ViewContext vs AnonymousViewContext 600 + ```typescript 601 + // ViewContext — has ctx.sender, result varies per user (computed per-subscriber) 602 + spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => { 603 + return [...ctx.db.item.by_owner.filter(ctx.sender)]; 604 + }); 605 + 606 + // AnonymousViewContext — no ctx.sender, same result for everyone (shared, better perf) 607 + spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(LeaderboardRow), (ctx) => { 608 + return [...ctx.db.player.by_score.filter(/* top scores */)]; 609 + }); 610 + ``` 611 + 612 + **Views require explicit subscription:** 613 + ```typescript 614 + conn.subscriptionBuilder().subscribe([ 615 + 'SELECT * FROM public_table', 616 + 'SELECT * FROM my_data', // Views need explicit SQL! 617 + ]); 618 + ``` 619 + 620 + --- 621 + 622 + ## 8) React Integration 623 + 624 + ### Key patterns 625 + ```typescript 626 + // Memoize connectionBuilder to prevent reconnects on re-render 627 + const builder = useMemo(() => 628 + DbConnection.builder() 629 + .withUri(SPACETIMEDB_URI) 630 + .withDatabaseName(MODULE_NAME) 631 + .withToken(localStorage.getItem('auth_token') || undefined) 632 + .onConnect(onConnect) 633 + .onConnectError(onConnectError), 634 + [] // Empty deps - only create once 635 + ); 636 + 637 + // useTable returns tuple [rows, isLoading] 638 + const [rows, isLoading] = useTable(tables.myTable); 639 + 640 + // Compare identities using toHexString() 641 + const isOwner = row.ownerId.toHexString() === myIdentity.toHexString(); 642 + ``` 643 + 644 + --- 645 + 646 + ## 9) Procedures (Beta) 647 + 648 + **Procedures are for side effects (HTTP requests, etc.) that reducers can't do.** 649 + 650 + ⚠️ Procedures are currently in beta. API may change. 651 + 652 + ### Defining a procedure 653 + **Procedure name comes from the export — NOT from a string argument.** Use `procedure(params, ret, fn)` or `procedure(ret, fn)`. 654 + 655 + ```typescript 656 + // ✅ CORRECT — export const name = spacetimedb.procedure(params, ret, fn) 657 + export const fetch_external_data = spacetimedb.procedure( 658 + { url: t.string() }, 659 + t.string(), // return type 660 + (ctx, { url }) => { 661 + const response = ctx.http.fetch(url); 662 + return response.text(); 663 + } 664 + ); 665 + ``` 666 + 667 + ### Database access in procedures 668 + 669 + ⚠️ **CRITICAL: Procedures don't have `ctx.db`. Use `ctx.withTx()` for database access.** 670 + 671 + ```typescript 672 + spacetimedb.procedure({ url: t.string() }, t.unit(), (ctx, { url }) => { 673 + // Fetch external data (outside transaction) 674 + const response = ctx.http.fetch(url); 675 + const data = response.text(); 676 + 677 + // ❌ WRONG — ctx.db doesn't exist in procedures 678 + ctx.db.myTable.insert({ ... }); 679 + 680 + // ✅ RIGHT — use ctx.withTx() for database access 681 + ctx.withTx(tx => { 682 + tx.db.myTable.insert({ 683 + id: 0n, 684 + content: data, 685 + fetchedAt: tx.timestamp, 686 + fetchedBy: tx.sender, 687 + }); 688 + }); 689 + 690 + return {}; 691 + }); 692 + ``` 693 + 694 + ### Key differences from reducers 695 + | Reducers | Procedures | 696 + |----------|------------| 697 + | `ctx.db` available directly | Must use `ctx.withTx(tx => tx.db...)` | 698 + | Automatic transaction | Manual transaction management | 699 + | No HTTP/network | `ctx.http.fetch()` available | 700 + | No return values to caller | Can return data to caller | 701 + 702 + --- 703 + 704 + ## 10) Project Structure 705 + 706 + ### Server (`backend/spacetimedb/`) 707 + ``` 708 + src/schema.ts → Tables, export spacetimedb 709 + src/index.ts → Reducers, lifecycle, import schema 710 + package.json → { "type": "module", "dependencies": { "spacetimedb": "^1.11.0" } } 711 + tsconfig.json → Standard config 712 + ``` 713 + 714 + ### Avoiding circular imports 715 + ``` 716 + schema.ts → defines tables AND exports spacetimedb 717 + index.ts → imports spacetimedb from ./schema, defines reducers 718 + ``` 719 + 720 + ### Client (`client/`) 721 + ``` 722 + src/module_bindings/ → Generated (spacetime generate) 723 + src/main.tsx → Provider, connection setup 724 + src/App.tsx → UI components 725 + src/config.ts → MODULE_NAME, SPACETIMEDB_URI 726 + ``` 727 + 728 + --- 729 + 730 + ## 11) Commands 731 + 732 + ```bash 733 + # Start local server 734 + spacetime start 735 + 736 + # Publish module 737 + spacetime publish <module-name> --module-path <backend-dir> 738 + 739 + # Clear database and republish 740 + spacetime publish <module-name> --clear-database -y --module-path <backend-dir> 741 + 742 + # Generate bindings 743 + spacetime generate --lang typescript --out-dir <client>/src/module_bindings --module-path <backend-dir> 744 + 745 + # View logs 746 + spacetime logs <module-name> 747 + ``` 748 + 749 + --- 750 + 751 + ## 12) Hard Requirements 752 + 753 + **TypeScript-specific:** 754 + 755 + 1. **`schema({ table })`** — takes exactly one object; never `schema(table)` or `schema(t1, t2, t3)` 756 + 2. **Reducer/procedure names from exports** — `export const name = spacetimedb.reducer(params, fn)`; never `reducer('name', ...)` 757 + 3. **Reducer calls use object syntax** — `{ param: 'value' }` not positional args 758 + 4. **Import `DbConnection` from `./module_bindings`** — not from `spacetimedb` 759 + 5. **DO NOT edit generated bindings** — regenerate with `spacetime generate` 760 + 6. **Indexes go in OPTIONS (1st arg)** — not in COLUMNS (2nd arg) of `table()` 761 + 7. **Use BigInt for u64/i64 fields** — `0n`, `1n`, not `0`, `1` 762 + 8. **Reducers are transactional** — they do not return data 763 + 9. **Reducers must be deterministic** — no filesystem, network, timers, random 764 + 10. **Views should use index lookups** — `.iter()` causes severe performance issues 765 + 11. **Procedures need `ctx.withTx()`** — `ctx.db` doesn't exist in procedures 766 + 12. **Sum type values** — use `{ tag: 'variant', value: payload }` not `{ variant: payload }`
+89
CLAUDE.md
··· 1 + # Checkmate 2 + 3 + Real-time chess on the AT Protocol, powered by SpacetimeDB. 4 + 5 + ## Architecture 6 + 7 + - **`server/`** — SpacetimeDB TypeScript module (tables + reducers) 8 + - **`client/`** — React 19 + Vite frontend 9 + 10 + SpacetimeDB replaces the traditional API server entirely. The browser connects via WebSocket, subscribes to game state tables, and calls reducers to mutate state. Both players see moves instantly via real-time subscription updates. 11 + 12 + ## Development 13 + 14 + ### Prerequisites 15 + 16 + - [SpacetimeDB CLI](https://spacetimedb.com/docs) (`spacetime` v2.1+) 17 + - [Bun](https://bun.sh/) (package manager) 18 + - Node.js 22+ 19 + 20 + ### Quick Start 21 + 22 + ```bash 23 + # Install dependencies 24 + cd server && bun install && cd .. 25 + cd client && bun install && cd .. 26 + 27 + # Start SpacetimeDB locally 28 + spacetime start 29 + 30 + # In another terminal — publish the module 31 + spacetime publish checkmate --module-path ./server --server local --delete-data 32 + 33 + # Generate client bindings 34 + spacetime generate --lang typescript --out-dir client/src/module_bindings --module-path server 35 + 36 + # Start the client dev server 37 + cd client && bun run dev 38 + ``` 39 + 40 + ### Running with `spacetime dev` (recommended) 41 + 42 + ```bash 43 + spacetime dev 44 + ``` 45 + 46 + This starts SpacetimeDB + Vite with hot reload and auto-regenerates bindings on server changes. 47 + 48 + ### Testing 49 + 50 + ```bash 51 + cd client && bun run test # Vitest watch mode 52 + cd client && bun run test:ci # Single run, CI mode 53 + ``` 54 + 55 + ### Type Checking 56 + 57 + ```bash 58 + cd client && npx tsc --noEmit 59 + ``` 60 + 61 + ## Key Files 62 + 63 + | File | Purpose | 64 + |------|---------| 65 + | `server/src/index.ts` | SpacetimeDB module — all tables and reducers | 66 + | `client/src/main.tsx` | Entry point — providers setup | 67 + | `client/src/App.tsx` | State-based routing | 68 + | `client/src/hooks/useGame.ts` | Game state hook (SpacetimeDB subscriptions) | 69 + | `client/src/hooks/useAuth.ts` | atproto OAuth hook | 70 + | `client/src/components/game/ChessBoard.tsx` | Chess board wrapper (react-chessboard) | 71 + | `client/src/lib/oauth.ts` | atproto OAuth client config | 72 + | `client/src/lib/spacetime.ts` | SpacetimeDB connection config | 73 + 74 + ## SpacetimeDB Conventions 75 + 76 + Follow the patterns in the template's built-in CLAUDE.md (copied to `.cursor/` and `AGENTS.md`): 77 + 78 + - Tables: `table(OPTIONS, COLUMNS)` — indexes go in OPTIONS 79 + - Reducers: `export const name = spacetimedb.reducer(params, fn)` — name from export 80 + - Client reducer calls: `conn.reducers.foo({ param: 'value' })` — always object syntax 81 + - Use `BigInt` (`0n`, `1n`) for all `u64`/`i64` fields 82 + - `useTable(tables.foo)` returns `[rows, isLoading]` tuple 83 + - Never edit files in `client/src/module_bindings/` — they are auto-generated 84 + 85 + ## Domain 86 + 87 + Production: `checkmate.social` 88 + OAuth client metadata: `https://checkmate.social/client-metadata.json` 89 + Local dev: `http://127.0.0.1:5173`
+202
LICENSE
··· 1 + Apache License 2 + Version 2.0, January 2004 3 + http://www.apache.org/licenses/ 4 + 5 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 + 7 + 1. Definitions. 8 + 9 + "License" shall mean the terms and conditions for use, reproduction, 10 + and distribution as defined by Sections 1 through 9 of this document. 11 + 12 + "Licensor" shall mean the copyright owner or entity authorized by 13 + the copyright owner that is granting the License. 14 + 15 + "Legal Entity" shall mean the union of the acting entity and all 16 + other entities that control, are controlled by, or are under common 17 + control with that entity. For the purposes of this definition, 18 + "control" means (i) the power, direct or indirect, to cause the 19 + direction or management of such entity, whether by contract or 20 + otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 + outstanding shares, or (iii) beneficial ownership of such entity. 22 + 23 + "You" (or "Your") shall mean an individual or Legal Entity 24 + exercising permissions granted by this License. 25 + 26 + "Source" form shall mean the preferred form for making modifications, 27 + including but not limited to software source code, documentation 28 + source, and configuration files. 29 + 30 + "Object" form shall mean any form resulting from mechanical 31 + transformation or translation of a Source form, including but 32 + not limited to compiled object code, generated documentation, 33 + and conversions to other media types. 34 + 35 + "Work" shall mean the work of authorship, whether in Source or 36 + Object form, made available under the License, as indicated by a 37 + copyright notice that is included in or attached to the work 38 + (an example is provided in the Appendix below). 39 + 40 + "Derivative Works" shall mean any work, whether in Source or Object 41 + form, that is based on (or derived from) the Work and for which the 42 + editorial revisions, annotations, elaborations, or other modifications 43 + represent, as a whole, an original work of authorship. For the purposes 44 + of this License, Derivative Works shall not include works that remain 45 + separable from, or merely link (or bind by name) to the interfaces of, 46 + the Work and Derivative Works thereof. 47 + 48 + "Contribution" shall mean any work of authorship, including 49 + the original version of the Work and any modifications or additions 50 + to that Work or Derivative Works thereof, that is intentionally 51 + submitted to Licensor for inclusion in the Work by the copyright owner 52 + or by an individual or Legal Entity authorized to submit on behalf of 53 + the copyright owner. For the purposes of this definition, "submitted" 54 + means any form of electronic, verbal, or written communication sent 55 + to the Licensor or its representatives, including but not limited to 56 + communication on electronic mailing lists, source code control systems, 57 + and issue tracking systems that are managed by, or on behalf of, the 58 + Licensor for the purpose of discussing and improving the Work, but 59 + excluding communication that is conspicuously marked or otherwise 60 + designated in writing by the copyright owner as "Not a Contribution." 61 + 62 + "Contributor" shall mean Licensor and any individual or Legal Entity 63 + on behalf of whom a Contribution has been received by Licensor and 64 + subsequently incorporated within the Work. 65 + 66 + 2. Grant of Copyright License. Subject to the terms and conditions of 67 + this License, each Contributor hereby grants to You a perpetual, 68 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 + copyright license to reproduce, prepare Derivative Works of, 70 + publicly display, publicly perform, sublicense, and distribute the 71 + Work and such Derivative Works in Source or Object form. 72 + 73 + 3. Grant of Patent License. Subject to the terms and conditions of 74 + this License, each Contributor hereby grants to You a perpetual, 75 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 + (except as stated in this section) patent license to make, have made, 77 + use, offer to sell, sell, import, and otherwise transfer the Work, 78 + where such license applies only to those patent claims licensable 79 + by such Contributor that are necessarily infringed by their 80 + Contribution(s) alone or by combination of their Contribution(s) 81 + with the Work to which such Contribution(s) was submitted. If You 82 + institute patent litigation against any entity (including a 83 + cross-claim or counterclaim in a lawsuit) alleging that the Work 84 + or a Contribution incorporated within the Work constitutes direct 85 + or contributory patent infringement, then any patent licenses 86 + granted to You under this License for that Work shall terminate 87 + as of the date such litigation is filed. 88 + 89 + 4. Redistribution. You may reproduce and distribute copies of the 90 + Work or Derivative Works thereof in any medium, with or without 91 + modifications, and in Source or Object form, provided that You 92 + meet the following conditions: 93 + 94 + (a) You must give any other recipients of the Work or 95 + Derivative Works a copy of this License; and 96 + 97 + (b) You must cause any modified files to carry prominent notices 98 + stating that You changed the files; and 99 + 100 + (c) You must retain, in the Source form of any Derivative Works 101 + that You distribute, all copyright, patent, trademark, and 102 + attribution notices from the Source form of the Work, 103 + excluding those notices that do not pertain to any part of 104 + the Derivative Works; and 105 + 106 + (d) If the Work includes a "NOTICE" text file as part of its 107 + distribution, then any Derivative Works that You distribute must 108 + include a readable copy of the attribution notices contained 109 + within such NOTICE file, excluding those notices that do not 110 + pertain to any part of the Derivative Works, in at least one 111 + of the following places: within a NOTICE text file distributed 112 + as part of the Derivative Works; within the Source form or 113 + documentation, if provided along with the Derivative Works; or, 114 + within a display generated by the Derivative Works, if and 115 + wherever such third-party notices normally appear. The contents 116 + of the NOTICE file are for informational purposes only and 117 + do not modify the License. You may add Your own attribution 118 + notices within Derivative Works that You distribute, alongside 119 + or as an addendum to the NOTICE text from the Work, provided 120 + that such additional attribution notices cannot be construed 121 + as modifying the License. 122 + 123 + You may add Your own copyright statement to Your modifications and 124 + may provide additional or different license terms and conditions 125 + for use, reproduction, or distribution of Your modifications, or 126 + for any such Derivative Works as a whole, provided Your use, 127 + reproduction, and distribution of the Work otherwise complies with 128 + the conditions stated in this License. 129 + 130 + 5. Submission of Contributions. Unless You explicitly state otherwise, 131 + any Contribution intentionally submitted for inclusion in the Work 132 + by You to the Licensor shall be under the terms and conditions of 133 + this License, without any additional terms or conditions. 134 + Notwithstanding the above, nothing herein shall supersede or modify 135 + the terms of any separate license agreement you may have executed 136 + with Licensor regarding such Contributions. 137 + 138 + 6. Trademarks. This License does not grant permission to use the trade 139 + names, trademarks, service marks, or product names of the Licensor, 140 + except as required for reasonable and customary use in describing the 141 + origin of the Work and reproducing the content of the NOTICE file. 142 + 143 + 7. Disclaimer of Warranty. Unless required by applicable law or 144 + agreed to in writing, Licensor provides the Work (and each 145 + Contributor provides its Contributions) on an "AS IS" BASIS, 146 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 + implied, including, without limitation, any warranties or conditions 148 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 + PARTICULAR PURPOSE. You are solely responsible for determining the 150 + appropriateness of using or redistributing the Work and assume any 151 + risks associated with Your exercise of permissions under this License. 152 + 153 + 8. Limitation of Liability. In no event and under no legal theory, 154 + whether in tort (including negligence), contract, or otherwise, 155 + unless required by applicable law (such as deliberate and grossly 156 + negligent acts) or agreed to in writing, shall any Contributor be 157 + liable to You for damages, including any direct, indirect, special, 158 + incidental, or consequential damages of any character arising as a 159 + result of this License or out of the use or inability to use the 160 + Work (including but not limited to damages for loss of goodwill, 161 + work stoppage, computer failure or malfunction, or any and all 162 + other commercial damages or losses), even if such Contributor 163 + has been advised of the possibility of such damages. 164 + 165 + 9. Accepting Warranty or Additional Liability. While redistributing 166 + the Work or Derivative Works thereof, You may choose to offer, 167 + and charge a fee for, acceptance of support, warranty, indemnity, 168 + or other liability obligations and/or rights consistent with this 169 + License. However, in accepting such obligations, You may act only 170 + on Your own behalf and on Your sole responsibility, not on behalf 171 + of any other Contributor, and only if You agree to indemnify, 172 + defend, and hold each Contributor harmless for any liability 173 + incurred by, or claims asserted against, such Contributor by reason 174 + of your accepting any such warranty or additional liability. 175 + 176 + END OF TERMS AND CONDITIONS 177 + 178 + APPENDIX: How to apply the Apache License to your work. 179 + 180 + To apply the Apache License to your work, attach the following 181 + boilerplate notice, with the fields enclosed by brackets "[]" 182 + replaced with your own identifying information. (Don't include 183 + the brackets!) The text should be enclosed in the appropriate 184 + comment syntax for the file format. We also recommend that a 185 + file or class name and description of purpose be included on the 186 + same "printed page" as the copyright notice for easier 187 + identification within third-party archives. 188 + 189 + Copyright 2025 Clockwork Labs, Inc 190 + 191 + Licensed under the Apache License, Version 2.0 (the "License"); 192 + you may not use this file except in compliance with the License. 193 + You may obtain a copy of the License at 194 + 195 + http://www.apache.org/licenses/LICENSE-2.0 196 + 197 + Unless required by applicable law or agreed to in writing, software 198 + distributed under the License is distributed on an "AS IS" BASIS, 199 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 + See the License for the specific language governing permissions and 201 + limitations under the License. 202 +
+634
client/bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "checkmate-client", 7 + "dependencies": { 8 + "@atproto/api": "^0.13.0", 9 + "@atproto/oauth-client-browser": "^0.3.0", 10 + "chess.js": "^1.4.0", 11 + "react": "^19.1.0", 12 + "react-chessboard": "^5.1.0", 13 + "react-dom": "^19.1.0", 14 + "spacetimedb": "^2.1.0", 15 + }, 16 + "devDependencies": { 17 + "@tailwindcss/vite": "^4.1.4", 18 + "@testing-library/jest-dom": "^6.6.3", 19 + "@testing-library/react": "^16.3.0", 20 + "@types/react": "^19.1.0", 21 + "@types/react-dom": "^19.1.0", 22 + "@vitejs/plugin-react": "^4.3.4", 23 + "jsdom": "^26.1.0", 24 + "tailwindcss": "^4.1.4", 25 + "typescript": "~5.6.2", 26 + "vite": "^6.3.2", 27 + "vitest": "^3.1.1", 28 + }, 29 + }, 30 + }, 31 + "packages": { 32 + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], 33 + 34 + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], 35 + 36 + "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.6", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg=="], 37 + 38 + "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 39 + 40 + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], 41 + 42 + "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver": "0.3.6" } }, "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg=="], 43 + 44 + "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], 45 + 46 + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], 47 + 48 + "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 49 + 50 + "@atproto/api": ["@atproto/api@0.13.35", "", { "dependencies": { "@atproto/common-web": "^0.4.0", "@atproto/lexicon": "^0.4.6", "@atproto/syntax": "^0.3.2", "@atproto/xrpc": "^0.6.8", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g=="], 51 + 52 + "@atproto/common-web": ["@atproto/common-web@0.4.21", "", { "dependencies": { "@atproto/lex-data": "^0.0.15", "@atproto/lex-json": "^0.0.16", "@atproto/syntax": "^0.5.4", "zod": "^3.23.8" } }, "sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw=="], 53 + 54 + "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], 55 + 56 + "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 57 + 58 + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], 59 + 60 + "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 61 + 62 + "@atproto/lex-data": ["@atproto/lex-data@0.0.15", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw=="], 63 + 64 + "@atproto/lex-json": ["@atproto/lex-json@0.0.16", "", { "dependencies": { "@atproto/lex-data": "^0.0.15", "tslib": "^2.8.1" } }, "sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg=="], 65 + 66 + "@atproto/lexicon": ["@atproto/lexicon@0.4.14", "", { "dependencies": { "@atproto/common-web": "^0.4.2", "@atproto/syntax": "^0.4.0", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ=="], 67 + 68 + "@atproto/oauth-client": ["@atproto/oauth-client@0.6.0", "", { "dependencies": { "@atproto-labs/did-resolver": "^0.2.6", "@atproto-labs/fetch": "^0.2.3", "@atproto-labs/handle-resolver": "^0.3.6", "@atproto-labs/identity-resolver": "^0.3.6", "@atproto-labs/simple-store": "^0.3.0", "@atproto-labs/simple-store-memory": "^0.1.4", "@atproto/did": "^0.3.0", "@atproto/jwk": "^0.6.0", "@atproto/oauth-types": "^0.6.3", "@atproto/xrpc": "^0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q=="], 69 + 70 + "@atproto/oauth-client-browser": ["@atproto/oauth-client-browser@0.3.41", "", { "dependencies": { "@atproto-labs/did-resolver": "^0.2.6", "@atproto-labs/handle-resolver": "^0.3.6", "@atproto-labs/simple-store": "^0.3.0", "@atproto/did": "^0.3.0", "@atproto/jwk": "^0.6.0", "@atproto/jwk-webcrypto": "^0.2.0", "@atproto/oauth-client": "^0.6.0", "@atproto/oauth-types": "^0.6.3", "core-js": "^3" } }, "sha512-4QTm8zPgm08vl53flrVmL+MS5IOhvWWctNZmEnPbvQ2t1ISw9Q5m815m2Sszi5ULMFjOqvT7lhKB7zQUn5gq5g=="], 71 + 72 + "@atproto/oauth-types": ["@atproto/oauth-types@0.6.3", "", { "dependencies": { "@atproto/did": "^0.3.0", "@atproto/jwk": "^0.6.0", "zod": "^3.23.8" } }, "sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng=="], 73 + 74 + "@atproto/syntax": ["@atproto/syntax@0.3.4", "", {}, "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg=="], 75 + 76 + "@atproto/xrpc": ["@atproto/xrpc@0.6.12", "", { "dependencies": { "@atproto/lexicon": "^0.4.10", "zod": "^3.23.8" } }, "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w=="], 77 + 78 + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], 79 + 80 + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], 81 + 82 + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], 83 + 84 + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], 85 + 86 + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], 87 + 88 + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], 89 + 90 + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], 91 + 92 + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], 93 + 94 + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], 95 + 96 + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], 97 + 98 + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], 99 + 100 + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], 101 + 102 + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], 103 + 104 + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], 105 + 106 + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], 107 + 108 + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], 109 + 110 + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], 111 + 112 + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], 113 + 114 + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], 115 + 116 + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], 117 + 118 + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], 119 + 120 + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], 121 + 122 + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], 123 + 124 + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], 125 + 126 + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], 127 + 128 + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], 129 + 130 + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], 131 + 132 + "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], 133 + 134 + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], 135 + 136 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], 137 + 138 + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], 139 + 140 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], 141 + 142 + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], 143 + 144 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], 145 + 146 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], 147 + 148 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], 149 + 150 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], 151 + 152 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], 153 + 154 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], 155 + 156 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], 157 + 158 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], 159 + 160 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], 161 + 162 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], 163 + 164 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], 165 + 166 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], 167 + 168 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], 169 + 170 + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], 171 + 172 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], 173 + 174 + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], 175 + 176 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], 177 + 178 + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], 179 + 180 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], 181 + 182 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], 183 + 184 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], 185 + 186 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], 187 + 188 + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], 189 + 190 + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], 191 + 192 + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 193 + 194 + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 195 + 196 + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], 197 + 198 + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], 199 + 200 + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], 201 + 202 + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], 203 + 204 + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], 205 + 206 + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], 207 + 208 + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], 209 + 210 + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], 211 + 212 + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], 213 + 214 + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], 215 + 216 + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], 217 + 218 + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], 219 + 220 + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], 221 + 222 + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], 223 + 224 + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], 225 + 226 + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], 227 + 228 + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], 229 + 230 + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], 231 + 232 + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], 233 + 234 + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], 235 + 236 + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], 237 + 238 + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], 239 + 240 + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], 241 + 242 + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], 243 + 244 + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], 245 + 246 + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], 247 + 248 + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], 249 + 250 + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], 251 + 252 + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], 253 + 254 + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], 255 + 256 + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], 257 + 258 + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], 259 + 260 + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], 261 + 262 + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], 263 + 264 + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], 265 + 266 + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], 267 + 268 + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], 269 + 270 + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], 271 + 272 + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], 273 + 274 + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], 275 + 276 + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], 277 + 278 + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], 279 + 280 + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], 281 + 282 + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], 283 + 284 + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], 285 + 286 + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], 287 + 288 + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], 289 + 290 + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], 291 + 292 + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], 293 + 294 + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], 295 + 296 + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], 297 + 298 + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], 299 + 300 + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], 301 + 302 + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], 303 + 304 + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], 305 + 306 + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], 307 + 308 + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], 309 + 310 + "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], 311 + 312 + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], 313 + 314 + "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], 315 + 316 + "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], 317 + 318 + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], 319 + 320 + "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], 321 + 322 + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], 323 + 324 + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 325 + 326 + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], 327 + 328 + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], 329 + 330 + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], 331 + 332 + "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 333 + 334 + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], 335 + 336 + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g=="], 337 + 338 + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], 339 + 340 + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], 341 + 342 + "caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="], 343 + 344 + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], 345 + 346 + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], 347 + 348 + "chess.js": ["chess.js@1.4.0", "", {}, "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw=="], 349 + 350 + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 351 + 352 + "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], 353 + 354 + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], 355 + 356 + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], 357 + 358 + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], 359 + 360 + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], 361 + 362 + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 363 + 364 + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], 365 + 366 + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], 367 + 368 + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], 369 + 370 + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 371 + 372 + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], 373 + 374 + "electron-to-chromium": ["electron-to-chromium@1.5.337", "", {}, "sha512-15gKW9mRUNP9RdzhedJNypFUxtYWSXohFz2nTLzM272xbRXHws68kNDzyATG3qej+vUj/7Sn9hf5XTDh0XK6/w=="], 375 + 376 + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], 377 + 378 + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], 379 + 380 + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], 381 + 382 + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], 383 + 384 + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 385 + 386 + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], 387 + 388 + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], 389 + 390 + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], 391 + 392 + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 393 + 394 + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], 395 + 396 + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], 397 + 398 + "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], 399 + 400 + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], 401 + 402 + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], 403 + 404 + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], 405 + 406 + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], 407 + 408 + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], 409 + 410 + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], 411 + 412 + "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], 413 + 414 + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 415 + 416 + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 417 + 418 + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 419 + 420 + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], 421 + 422 + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], 423 + 424 + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], 425 + 426 + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], 427 + 428 + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], 429 + 430 + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], 431 + 432 + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], 433 + 434 + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], 435 + 436 + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], 437 + 438 + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], 439 + 440 + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], 441 + 442 + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], 443 + 444 + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], 445 + 446 + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], 447 + 448 + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], 449 + 450 + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], 451 + 452 + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], 453 + 454 + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], 455 + 456 + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], 457 + 458 + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], 459 + 460 + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 461 + 462 + "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 463 + 464 + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], 465 + 466 + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], 467 + 468 + "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], 469 + 470 + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], 471 + 472 + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], 473 + 474 + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 475 + 476 + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], 477 + 478 + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 479 + 480 + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], 481 + 482 + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], 483 + 484 + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], 485 + 486 + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], 487 + 488 + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 489 + 490 + "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], 491 + 492 + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], 493 + 494 + "react-chessboard": ["react-chessboard@5.10.0", "", { "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-Y3PgaCVhnDG3IaQfu86OzTSEIEAUtuU5XwmHWnx3tcFOX7lSoAq81ZFX3MBj6y5a6FzDMTczMVmkkrV2CzTrIw=="], 495 + 496 + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], 497 + 498 + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], 499 + 500 + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], 501 + 502 + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], 503 + 504 + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], 505 + 506 + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], 507 + 508 + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], 509 + 510 + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], 511 + 512 + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], 513 + 514 + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], 515 + 516 + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], 517 + 518 + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], 519 + 520 + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 521 + 522 + "spacetimedb": ["spacetimedb@2.1.0", "", { "dependencies": { "base64-js": "^1.5.1", "headers-polyfill": "^4.0.3", "object-inspect": "^1.13.4", "prettier": "^3.3.3", "pure-rand": "^7.0.1", "safe-stable-stringify": "^2.5.0", "statuses": "^2.0.2", "url-polyfill": "^1.1.14" }, "peerDependencies": { "@angular/core": ">=17.0.0", "@tanstack/react-query": "^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0", "svelte": "^4.0.0 || ^5.0.0", "undici": "^6.19.2", "vue": "^3.3.0" }, "optionalPeers": ["@angular/core", "@tanstack/react-query", "react", "svelte", "undici", "vue"] }, "sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA=="], 523 + 524 + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], 525 + 526 + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], 527 + 528 + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], 529 + 530 + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], 531 + 532 + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], 533 + 534 + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], 535 + 536 + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], 537 + 538 + "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], 539 + 540 + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], 541 + 542 + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], 543 + 544 + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], 545 + 546 + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], 547 + 548 + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], 549 + 550 + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], 551 + 552 + "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], 553 + 554 + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], 555 + 556 + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], 557 + 558 + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], 559 + 560 + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], 561 + 562 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 563 + 564 + "typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], 565 + 566 + "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 567 + 568 + "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], 569 + 570 + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], 571 + 572 + "url-polyfill": ["url-polyfill@1.1.14", "", {}, "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ=="], 573 + 574 + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], 575 + 576 + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], 577 + 578 + "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], 579 + 580 + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], 581 + 582 + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], 583 + 584 + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], 585 + 586 + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], 587 + 588 + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], 589 + 590 + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], 591 + 592 + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], 593 + 594 + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], 595 + 596 + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], 597 + 598 + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 599 + 600 + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 601 + 602 + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], 603 + 604 + "@atproto-labs/simple-store-memory/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], 605 + 606 + "@atproto/common-web/@atproto/syntax": ["@atproto/syntax@0.5.4", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw=="], 607 + 608 + "@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 609 + 610 + "@atproto/oauth-client/@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], 611 + 612 + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], 613 + 614 + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], 615 + 616 + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], 617 + 618 + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], 619 + 620 + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], 621 + 622 + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 623 + 624 + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], 625 + 626 + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], 627 + 628 + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], 629 + 630 + "@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon": ["@atproto/lexicon@0.6.2", "", { "dependencies": { "@atproto/common-web": "^0.4.18", "@atproto/syntax": "^0.5.0", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw=="], 631 + 632 + "@atproto/oauth-client/@atproto/xrpc/@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.5.4", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw=="], 633 + } 634 + }
+14
client/index.html
··· 1 + <!doctype html> 2 + <html lang="en" class="dark"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <meta name="description" content="Real-time chess on the AT Protocol, powered by SpacetimeDB" /> 7 + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>♟</text></svg>" /> 8 + <title>Checkmate</title> 9 + </head> 10 + <body class="bg-neutral-950 text-neutral-100 antialiased"> 11 + <div id="root"></div> 12 + <script type="module" src="/src/main.tsx"></script> 13 + </body> 14 + </html>
+36
client/package.json
··· 1 + { 2 + "name": "checkmate-client", 3 + "private": true, 4 + "version": "0.1.0", 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite", 8 + "build": "tsc -b && vite build", 9 + "preview": "vite preview", 10 + "test": "vitest", 11 + "test:ci": "vitest run", 12 + "generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path ../server" 13 + }, 14 + "dependencies": { 15 + "spacetimedb": "^2.1.0", 16 + "react": "^19.1.0", 17 + "react-dom": "^19.1.0", 18 + "react-chessboard": "^5.1.0", 19 + "chess.js": "^1.4.0", 20 + "@atproto/oauth-client-browser": "^0.3.0", 21 + "@atproto/api": "^0.13.0" 22 + }, 23 + "devDependencies": { 24 + "@types/react": "^19.1.0", 25 + "@types/react-dom": "^19.1.0", 26 + "@vitejs/plugin-react": "^4.3.4", 27 + "typescript": "~5.6.2", 28 + "vite": "^6.3.2", 29 + "vitest": "^3.1.1", 30 + "@testing-library/react": "^16.3.0", 31 + "@testing-library/jest-dom": "^6.6.3", 32 + "jsdom": "^26.1.0", 33 + "tailwindcss": "^4.1.4", 34 + "@tailwindcss/vite": "^4.1.4" 35 + } 36 + }
+15
client/public/client-metadata.json
··· 1 + { 2 + "client_id": "https://checkmate.social/client-metadata.json", 3 + "client_name": "Checkmate", 4 + "client_uri": "https://checkmate.social", 5 + "logo_uri": "https://checkmate.social/logo.png", 6 + "tos_uri": "https://checkmate.social/tos", 7 + "policy_uri": "https://checkmate.social/privacy", 8 + "redirect_uris": ["https://checkmate.social/callback"], 9 + "scope": "atproto transition:generic", 10 + "grant_types": ["authorization_code", "refresh_token"], 11 + "response_types": ["code"], 12 + "token_endpoint_auth_method": "none", 13 + "application_type": "web", 14 + "dpop_bound_access_tokens": true 15 + }
+65
client/src/App.tsx
··· 1 + /** 2 + * App — root component with state-based routing. 3 + * 4 + * Routing is simple and state-driven (no react-router needed for MVP): 5 + * - Not logged in -> LoginScreen 6 + * - Logged in, no game -> LobbyScreen 7 + * - Active game found -> GameScreen 8 + * 9 + * The app also handles the SpacetimeDB player registration bridge: 10 + * after OAuth login succeeds, it calls the registerPlayer reducer to 11 + * link the atproto DID to the SpacetimeDB identity. 12 + */ 13 + 14 + import { useEffect, useRef } from 'react'; 15 + import { useAuth } from './hooks/useAuth'; 16 + import { useGame } from './hooks/useGame'; 17 + import { useGamePublisher } from './hooks/useGamePublisher'; 18 + import { LoginScreen } from './components/auth/LoginScreen'; 19 + import { LobbyScreen } from './components/lobby/LobbyScreen'; 20 + import { GameScreen } from './components/game/GameScreen'; 21 + import { Header } from './components/layout/Header'; 22 + 23 + export default function App() { 24 + const auth = useAuth(); 25 + const game = useGame(); 26 + const registrationDone = useRef(false); 27 + 28 + // Bridge: register the atproto identity with SpacetimeDB after login 29 + useEffect(() => { 30 + if ( 31 + !auth.did || 32 + !auth.handle || 33 + !game.connected || 34 + registrationDone.current 35 + ) { 36 + return; 37 + } 38 + 39 + registrationDone.current = true; 40 + game.registerPlayer({ 41 + did: auth.did, 42 + handle: auth.handle, 43 + displayName: auth.displayName ?? auth.handle, 44 + avatarUrl: auth.avatarUrl ?? '', 45 + }); 46 + }, [auth.did, auth.handle, auth.displayName, auth.avatarUrl, game.connected, game.registerPlayer]); 47 + 48 + // Publish completed games to atproto 49 + useGamePublisher(); 50 + 51 + // Not logged in 52 + if (!auth.did) { 53 + return <LoginScreen />; 54 + } 55 + 56 + // Logged in — show header + content 57 + return ( 58 + <div className="flex min-h-screen flex-col"> 59 + <Header /> 60 + <main className="flex flex-1 flex-col"> 61 + {game.activeGame ? <GameScreen /> : <LobbyScreen />} 62 + </main> 63 + </div> 64 + ); 65 + }
+219
client/src/__tests__/chess-logic.test.ts
··· 1 + /** 2 + * Chess logic tests — validates the move validation and game state detection 3 + * that both the server module and client use via chess.js. 4 + * 5 + * These tests verify the chess.js integration patterns used in our server 6 + * reducers and client components. The exact same logic runs in SpacetimeDB's 7 + * makeMove reducer for authoritative validation. 8 + */ 9 + 10 + import { describe, it, expect } from 'vitest'; 11 + import { Chess } from 'chess.js'; 12 + 13 + const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; 14 + 15 + describe('Chess move validation', () => { 16 + it('accepts legal opening moves', () => { 17 + const chess = new Chess(STARTING_FEN); 18 + const move = chess.move({ from: 'e2', to: 'e4' }); 19 + expect(move).toBeTruthy(); 20 + expect(move.san).toBe('e4'); 21 + expect(chess.turn()).toBe('b'); 22 + }); 23 + 24 + it('rejects illegal moves', () => { 25 + const chess = new Chess(STARTING_FEN); 26 + expect(() => chess.move({ from: 'e2', to: 'e5' })).toThrow(); 27 + }); 28 + 29 + it('rejects moving opponent pieces', () => { 30 + const chess = new Chess(STARTING_FEN); 31 + // White to move, trying to move black pawn 32 + expect(() => chess.move({ from: 'e7', to: 'e5' })).toThrow(); 33 + }); 34 + 35 + it('detects check (not checkmate)', () => { 36 + // Position where white can give check but not mate 37 + const chess = new Chess('rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2'); 38 + chess.move('Qh5'); // Not check yet 39 + expect(chess.isCheck()).toBe(false); 40 + chess.move('Nc6'); 41 + chess.move('Bc4'); // Still not check 42 + expect(chess.isCheck()).toBe(false); 43 + expect(chess.isCheckmate()).toBe(false); 44 + }); 45 + 46 + it('detects checkmate (Fool\'s Mate)', () => { 47 + const chess = new Chess(); 48 + chess.move('f3'); 49 + chess.move('e5'); 50 + chess.move('g4'); 51 + chess.move('Qh4'); 52 + expect(chess.isCheckmate()).toBe(true); 53 + expect(chess.isCheck()).toBe(true); 54 + expect(chess.isGameOver()).toBe(true); 55 + }); 56 + 57 + it('detects checkmate (Scholar\'s Mate)', () => { 58 + const chess = new Chess(); 59 + chess.move('e4'); 60 + chess.move('e5'); 61 + chess.move('Bc4'); 62 + chess.move('Nc6'); 63 + chess.move('Qh5'); 64 + chess.move('Nf6'); 65 + chess.move('Qxf7'); 66 + expect(chess.isCheckmate()).toBe(true); 67 + expect(chess.isGameOver()).toBe(true); 68 + }); 69 + 70 + it('detects stalemate', () => { 71 + // Classic stalemate: white king on a6, white queen on b6, black king on a8 72 + // Black has no legal moves but is not in check 73 + const chess = new Chess('k7/8/KQ6/8/8/8/8/8 b - - 0 1'); 74 + expect(chess.isStalemate()).toBe(true); 75 + expect(chess.isGameOver()).toBe(true); 76 + expect(chess.isCheckmate()).toBe(false); 77 + expect(chess.isDraw()).toBe(true); 78 + }); 79 + 80 + it('detects insufficient material draw', () => { 81 + // King vs King 82 + const chess = new Chess('k7/8/1K6/8/8/8/8/8 w - - 0 1'); 83 + expect(chess.isInsufficientMaterial()).toBe(true); 84 + expect(chess.isDraw()).toBe(true); 85 + }); 86 + 87 + it('handles pawn promotion', () => { 88 + // Position where promoting doesn't cause checkmate 89 + const chess = new Chess('8/P7/8/8/8/8/3k4/K7 w - - 0 1'); 90 + const move = chess.move({ from: 'a7', to: 'a8', promotion: 'q' }); 91 + expect(move).toBeTruthy(); 92 + expect(move.san).toMatch(/^a8=Q/); // May or may not have + or # 93 + // Should have a queen on a8 94 + const piece = chess.get('a8'); 95 + expect(piece?.type).toBe('q'); 96 + expect(piece?.color).toBe('w'); 97 + }); 98 + 99 + it('records moves in history', () => { 100 + const chess = new Chess(); 101 + chess.move('e4'); 102 + chess.move('e5'); 103 + chess.move('Nf3'); 104 + const history = chess.history(); 105 + expect(history).toEqual(['e4', 'e5', 'Nf3']); 106 + }); 107 + 108 + it('produces consistent FEN after moves', () => { 109 + const chess = new Chess(); 110 + chess.move('e4'); 111 + const fen = chess.fen(); 112 + // After 1. e4, we should have the pawn on e4 and it's black's turn 113 + expect(fen).toContain('4P3'); 114 + expect(fen).toContain(' b '); 115 + expect(fen).toContain('KQkq'); 116 + }); 117 + 118 + it('supports move object format used by our reducers', () => { 119 + const chess = new Chess(STARTING_FEN); 120 + // This is the exact format our makeMove reducer receives 121 + const from = 'e2'; 122 + const to = 'e4'; 123 + const promotion = ''; 124 + const move = chess.move({ 125 + from, 126 + to, 127 + promotion: promotion || undefined, 128 + }); 129 + expect(move.san).toBe('e4'); 130 + expect(move.from).toBe('e2'); 131 + expect(move.to).toBe('e4'); 132 + }); 133 + 134 + it('returns verbose move info for UI highlighting', () => { 135 + const chess = new Chess(STARTING_FEN); 136 + const moves = chess.moves({ square: 'e2', verbose: true }); 137 + expect(moves).toHaveLength(2); // e3 and e4 138 + expect(moves.map((m) => m.to).sort()).toEqual(['e3', 'e4']); 139 + }); 140 + 141 + it('correctly identifies legal moves for a piece', () => { 142 + const chess = new Chess(STARTING_FEN); 143 + // Knight on g1 can go to f3 or h3 144 + const moves = chess.moves({ square: 'g1', verbose: true }); 145 + expect(moves).toHaveLength(2); 146 + expect(moves.map((m) => m.to).sort()).toEqual(['f3', 'h3']); 147 + }); 148 + 149 + it('finds king position on the board', () => { 150 + const chess = new Chess(STARTING_FEN); 151 + const board = chess.board(); 152 + let whiteKingSquare: string | null = null; 153 + for (let rank = 0; rank < 8; rank++) { 154 + for (let file = 0; file < 8; file++) { 155 + const piece = board[rank][file]; 156 + if (piece && piece.type === 'k' && piece.color === 'w') { 157 + whiteKingSquare = piece.square; 158 + } 159 + } 160 + } 161 + expect(whiteKingSquare).toBe('e1'); 162 + }); 163 + }); 164 + 165 + describe('Game state transitions (simulating server reducer flow)', () => { 166 + it('plays a complete game to checkmate', () => { 167 + // Simulate the server reducer flow 168 + let fen = STARTING_FEN; 169 + const moves = [ 170 + { from: 'f2', to: 'f3' }, 171 + { from: 'e7', to: 'e5' }, 172 + { from: 'g2', to: 'g4' }, 173 + { from: 'd8', to: 'h4' }, // Fool's Mate 174 + ]; 175 + 176 + let status = 'active'; 177 + let winner = ''; 178 + let turn = 'w'; 179 + 180 + for (const { from, to } of moves) { 181 + const chess = new Chess(fen); 182 + const move = chess.move({ from, to }); 183 + expect(move).toBeTruthy(); 184 + 185 + fen = chess.fen(); 186 + turn = chess.turn(); 187 + 188 + if (chess.isCheckmate()) { 189 + status = 'checkmate'; 190 + // The side that just moved wins 191 + winner = turn === 'w' ? 'black' : 'white'; 192 + } else if (chess.isStalemate()) { 193 + status = 'stalemate'; 194 + winner = 'draw'; 195 + } else if (chess.isDraw()) { 196 + status = 'draw'; 197 + winner = 'draw'; 198 + } 199 + } 200 + 201 + expect(status).toBe('checkmate'); 202 + expect(winner).toBe('black'); // Black delivered mate 203 + }); 204 + 205 + it('validates turn enforcement', () => { 206 + const chess = new Chess(STARTING_FEN); 207 + // White moves first 208 + chess.move({ from: 'e2', to: 'e4' }); 209 + // Now it's black's turn — trying to move white should fail 210 + expect(() => chess.move({ from: 'd2', to: 'd4' })).toThrow(); 211 + }); 212 + 213 + it('prevents moves after game over', () => { 214 + // Checkmate position (Fool's Mate result) 215 + const chess = new Chess('rnb1kbnr/pppp1ppp/4p3/8/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3'); 216 + expect(chess.isCheckmate()).toBe(true); 217 + expect(chess.moves()).toHaveLength(0); 218 + }); 219 + });
+109
client/src/__tests__/pgn.test.ts
··· 1 + /** 2 + * PGN generation tests. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest'; 6 + import { buildPgn, gameResultToPgn } from '../lib/pgn'; 7 + 8 + describe('gameResultToPgn', () => { 9 + it('maps white win', () => { 10 + expect(gameResultToPgn('checkmate', 'white')).toBe('1-0'); 11 + expect(gameResultToPgn('resigned', 'white')).toBe('1-0'); 12 + }); 13 + 14 + it('maps black win', () => { 15 + expect(gameResultToPgn('checkmate', 'black')).toBe('0-1'); 16 + expect(gameResultToPgn('resigned', 'black')).toBe('0-1'); 17 + }); 18 + 19 + it('maps draw', () => { 20 + expect(gameResultToPgn('stalemate', 'draw')).toBe('1/2-1/2'); 21 + expect(gameResultToPgn('draw', 'draw')).toBe('1/2-1/2'); 22 + }); 23 + 24 + it('maps active game', () => { 25 + expect(gameResultToPgn('active', '')).toBe('*'); 26 + }); 27 + }); 28 + 29 + describe('buildPgn', () => { 30 + it('builds a valid PGN with all required headers', () => { 31 + const pgn = buildPgn({ 32 + whiteHandle: 'alice.bsky.social', 33 + blackHandle: 'bob.bsky.social', 34 + result: '1-0', 35 + date: new Date('2026-04-15'), 36 + moves: [ 37 + { san: 'e4' }, 38 + { san: 'e5' }, 39 + { san: 'Qh5' }, 40 + { san: 'Nc6' }, 41 + { san: 'Bc4' }, 42 + { san: 'Nf6' }, 43 + { san: 'Qxf7#' }, 44 + ], 45 + }); 46 + 47 + // Check Seven Tag Roster 48 + expect(pgn).toContain('[Event "Checkmate Online"]'); 49 + expect(pgn).toContain('[Site "checkmate.social"]'); 50 + expect(pgn).toContain('[Date "2026.04.15"]'); 51 + expect(pgn).toContain('[Round "-"]'); 52 + expect(pgn).toContain('[White "alice.bsky.social"]'); 53 + expect(pgn).toContain('[Black "bob.bsky.social"]'); 54 + expect(pgn).toContain('[Result "1-0"]'); 55 + 56 + // Check movetext 57 + expect(pgn).toContain('1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0'); 58 + }); 59 + 60 + it('handles draw result', () => { 61 + const pgn = buildPgn({ 62 + whiteHandle: 'alice.bsky.social', 63 + blackHandle: 'bob.bsky.social', 64 + result: '1/2-1/2', 65 + date: new Date('2026-01-01'), 66 + moves: [{ san: 'e4' }, { san: 'e5' }], 67 + }); 68 + 69 + expect(pgn).toContain('[Result "1/2-1/2"]'); 70 + expect(pgn).toContain('1. e4 e5 1/2-1/2'); 71 + }); 72 + 73 + it('handles empty moves (resignation before any moves)', () => { 74 + const pgn = buildPgn({ 75 + whiteHandle: 'alice.bsky.social', 76 + blackHandle: 'bob.bsky.social', 77 + result: '0-1', 78 + date: new Date('2026-06-15'), 79 + moves: [], 80 + }); 81 + 82 + expect(pgn).toContain('[Result "0-1"]'); 83 + expect(pgn).toContain('0-1'); 84 + }); 85 + 86 + it('handles odd number of moves (white moved last)', () => { 87 + const pgn = buildPgn({ 88 + whiteHandle: 'w', 89 + blackHandle: 'b', 90 + result: '1-0', 91 + date: new Date('2026-04-15'), 92 + moves: [{ san: 'e4' }, { san: 'e5' }, { san: 'Nf3' }], 93 + }); 94 + 95 + expect(pgn).toContain('1. e4 e5 2. Nf3 1-0'); 96 + }); 97 + 98 + it('formats date correctly with zero-padded month and day', () => { 99 + const pgn = buildPgn({ 100 + whiteHandle: 'w', 101 + blackHandle: 'b', 102 + result: '*', 103 + date: new Date('2026-01-05'), 104 + moves: [], 105 + }); 106 + 107 + expect(pgn).toContain('[Date "2026.01.05"]'); 108 + }); 109 + });
+50
client/src/app.css
··· 1 + @import "tailwindcss"; 2 + 3 + /* 4 + * Checkmate global styles 5 + * 6 + * We use Tailwind v4 for utility classes. This file holds a small number 7 + * of custom CSS properties and base resets that don't fit neatly into 8 + * utility classes. 9 + */ 10 + 11 + :root { 12 + /* Board colors — warm wood tones */ 13 + --board-light: #f0d9b5; 14 + --board-dark: #b58863; 15 + 16 + /* Accent */ 17 + --accent: #7c3aed; 18 + --accent-hover: #6d28d9; 19 + 20 + /* Game status highlights */ 21 + --highlight-move: rgba(255, 255, 0, 0.35); 22 + --highlight-check: rgba(255, 0, 0, 0.45); 23 + --highlight-legal: rgba(0, 0, 0, 0.1); 24 + --highlight-legal-capture: rgba(0, 0, 0, 0.1); 25 + } 26 + 27 + /* Smooth scrolling on the move list */ 28 + .move-list { 29 + scroll-behavior: smooth; 30 + } 31 + 32 + /* Pulse animation for "searching for opponent" */ 33 + @keyframes pulse-ring { 34 + 0% { 35 + transform: scale(0.9); 36 + opacity: 0.7; 37 + } 38 + 50% { 39 + transform: scale(1.1); 40 + opacity: 0.3; 41 + } 42 + 100% { 43 + transform: scale(0.9); 44 + opacity: 0.7; 45 + } 46 + } 47 + 48 + .animate-pulse-ring { 49 + animation: pulse-ring 2s ease-in-out infinite; 50 + }
+166
client/src/components/auth/AuthProvider.tsx
··· 1 + /** 2 + * AuthProvider — manages atproto OAuth session state. 3 + * 4 + * Wraps the app and provides auth context to all children. On mount, it calls 5 + * oauthClient.init() which handles two scenarios: 6 + * 1. Restoring an existing session from IndexedDB 7 + * 2. Processing an OAuth callback redirect (code + state in URL) 8 + * 9 + * After successful auth, it exposes the user's DID and Bluesky profile info. 10 + */ 11 + 12 + import { 13 + createContext, 14 + useCallback, 15 + useEffect, 16 + useMemo, 17 + useRef, 18 + useState, 19 + type ReactNode, 20 + } from 'react'; 21 + import type { BrowserOAuthClient, OAuthSession } from '@atproto/oauth-client-browser'; 22 + import { Agent } from '@atproto/api'; 23 + 24 + export interface AuthState { 25 + /** Whether the auth system is initializing (checking for existing session) */ 26 + isLoading: boolean; 27 + /** The authenticated session, if any */ 28 + session: OAuthSession | null; 29 + /** The user's atproto DID (e.g., "did:plc:abc123") */ 30 + did: string | null; 31 + /** The user's Bluesky handle (e.g., "alice.bsky.social") */ 32 + handle: string | null; 33 + /** The user's display name */ 34 + displayName: string | null; 35 + /** The user's avatar URL */ 36 + avatarUrl: string | null; 37 + /** Initiate login with a Bluesky handle. Redirects the browser. */ 38 + login: (handle: string) => Promise<void>; 39 + /** Log out and clear session */ 40 + logout: () => void; 41 + /** Auth error, if any */ 42 + error: string | null; 43 + } 44 + 45 + export const AuthContext = createContext<AuthState>({ 46 + isLoading: true, 47 + session: null, 48 + did: null, 49 + handle: null, 50 + displayName: null, 51 + avatarUrl: null, 52 + login: async () => {}, 53 + logout: () => {}, 54 + error: null, 55 + }); 56 + 57 + interface AuthProviderProps { 58 + client: BrowserOAuthClient; 59 + children: ReactNode; 60 + } 61 + 62 + export function AuthProvider({ client, children }: AuthProviderProps) { 63 + const [isLoading, setIsLoading] = useState(true); 64 + const [session, setSession] = useState<OAuthSession | null>(null); 65 + const [did, setDid] = useState<string | null>(null); 66 + const [handle, setHandle] = useState<string | null>(null); 67 + const [displayName, setDisplayName] = useState<string | null>(null); 68 + const [avatarUrl, setAvatarUrl] = useState<string | null>(null); 69 + const [error, setError] = useState<string | null>(null); 70 + const initCalled = useRef(false); 71 + 72 + // Fetch profile info from the atproto PDS 73 + const fetchProfile = useCallback(async (oauthSession: OAuthSession) => { 74 + try { 75 + const agent = new Agent(oauthSession); 76 + const profile = await agent.getProfile({ actor: oauthSession.sub }); 77 + setHandle(profile.data.handle); 78 + setDisplayName(profile.data.displayName ?? profile.data.handle); 79 + setAvatarUrl(profile.data.avatar ?? null); 80 + } catch (err) { 81 + console.warn('[auth] Failed to fetch profile, using DID as fallback:', err); 82 + setHandle(oauthSession.sub); 83 + setDisplayName(oauthSession.sub); 84 + } 85 + }, []); 86 + 87 + // Initialize: restore session or process callback 88 + useEffect(() => { 89 + if (initCalled.current) return; 90 + initCalled.current = true; 91 + 92 + client 93 + .init() 94 + .then(async (result) => { 95 + if (result) { 96 + const { session: oauthSession } = result; 97 + setSession(oauthSession); 98 + setDid(oauthSession.sub); 99 + await fetchProfile(oauthSession); 100 + 101 + // Clean up the URL (remove OAuth callback params) 102 + if (window.location.search) { 103 + window.history.replaceState({}, '', window.location.pathname); 104 + } 105 + } 106 + }) 107 + .catch((err) => { 108 + console.error('[auth] Init error:', err); 109 + setError(err instanceof Error ? err.message : 'Authentication failed'); 110 + }) 111 + .finally(() => { 112 + setIsLoading(false); 113 + }); 114 + 115 + // Note: BrowserOAuthClient doesn't expose an EventTarget interface 116 + // for session deletion in this version. Session invalidation is 117 + // handled by the client internally (auto-revokes on token failure). 118 + }, [client, fetchProfile]); 119 + 120 + const login = useCallback( 121 + async (userHandle: string) => { 122 + setError(null); 123 + try { 124 + await client.signIn(userHandle, { 125 + state: JSON.stringify({ returnTo: window.location.pathname }), 126 + }); 127 + // This line is never reached — browser redirects to the PDS 128 + } catch (err) { 129 + // Only fires if user cancels or navigates back 130 + if (err instanceof Error && err.message !== 'The user aborted a request.') { 131 + setError(err.message); 132 + } 133 + } 134 + }, 135 + [client] 136 + ); 137 + 138 + const logout = useCallback(() => { 139 + setSession(null); 140 + setDid(null); 141 + setHandle(null); 142 + setDisplayName(null); 143 + setAvatarUrl(null); 144 + // Clear all stored auth state 145 + if (did) { 146 + client.revoke(did).catch(console.error); 147 + } 148 + }, [client, did]); 149 + 150 + const value = useMemo<AuthState>( 151 + () => ({ 152 + isLoading, 153 + session, 154 + did, 155 + handle, 156 + displayName, 157 + avatarUrl, 158 + login, 159 + logout, 160 + error, 161 + }), 162 + [isLoading, session, did, handle, displayName, avatarUrl, login, logout, error] 163 + ); 164 + 165 + return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; 166 + }
+96
client/src/components/auth/LoginScreen.tsx
··· 1 + /** 2 + * LoginScreen — entry point for unauthenticated users. 3 + * 4 + * Clean, focused interface: enter your Bluesky handle, click sign in. 5 + * The OAuth flow redirects to the PDS, then back to /callback. 6 + */ 7 + 8 + import { useState, type FormEvent } from 'react'; 9 + import { useAuth } from '../../hooks/useAuth'; 10 + 11 + export function LoginScreen() { 12 + const { login, error, isLoading } = useAuth(); 13 + const [handle, setHandle] = useState(''); 14 + const [isSubmitting, setIsSubmitting] = useState(false); 15 + 16 + const handleSubmit = async (e: FormEvent) => { 17 + e.preventDefault(); 18 + const trimmed = handle.trim(); 19 + if (!trimmed) return; 20 + 21 + setIsSubmitting(true); 22 + try { 23 + await login(trimmed); 24 + } catch { 25 + setIsSubmitting(false); 26 + } 27 + }; 28 + 29 + if (isLoading) { 30 + return ( 31 + <div className="flex min-h-screen items-center justify-center"> 32 + <div className="text-neutral-400">Loading...</div> 33 + </div> 34 + ); 35 + } 36 + 37 + return ( 38 + <div className="flex min-h-screen flex-col items-center justify-center px-4"> 39 + <div className="w-full max-w-sm space-y-8"> 40 + {/* Logo / Title */} 41 + <div className="text-center"> 42 + <h1 className="text-4xl font-bold tracking-tight text-white"> 43 + Checkmate 44 + </h1> 45 + <p className="mt-2 text-sm text-neutral-400"> 46 + Real-time chess on the AT Protocol 47 + </p> 48 + </div> 49 + 50 + {/* Login Form */} 51 + <form onSubmit={handleSubmit} className="space-y-4"> 52 + <div> 53 + <label 54 + htmlFor="handle" 55 + className="block text-sm font-medium text-neutral-300" 56 + > 57 + Bluesky Handle 58 + </label> 59 + <input 60 + id="handle" 61 + type="text" 62 + value={handle} 63 + onChange={(e) => setHandle(e.target.value)} 64 + placeholder="alice.bsky.social" 65 + autoComplete="username" 66 + autoFocus 67 + disabled={isSubmitting} 68 + className="mt-1 block w-full rounded-lg border border-neutral-700 bg-neutral-900 px-4 py-3 text-white placeholder-neutral-500 focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500 disabled:opacity-50" 69 + /> 70 + </div> 71 + 72 + {error && ( 73 + <div className="rounded-lg bg-red-900/50 px-4 py-3 text-sm text-red-300"> 74 + {error} 75 + </div> 76 + )} 77 + 78 + <button 79 + type="submit" 80 + disabled={!handle.trim() || isSubmitting} 81 + className="w-full rounded-lg bg-violet-600 px-4 py-3 font-medium text-white transition-colors hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-neutral-950 disabled:cursor-not-allowed disabled:opacity-50" 82 + > 83 + {isSubmitting ? 'Redirecting...' : 'Sign in with Bluesky'} 84 + </button> 85 + </form> 86 + 87 + <p className="text-center text-xs text-neutral-500"> 88 + Powered by{' '} 89 + <span className="text-neutral-400">SpacetimeDB</span> 90 + {' + '} 91 + <span className="text-neutral-400">AT Protocol</span> 92 + </p> 93 + </div> 94 + </div> 95 + ); 96 + }
+81
client/src/components/auth/__tests__/LoginScreen.test.tsx
··· 1 + /** 2 + * LoginScreen component tests. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest'; 6 + import { render, screen, fireEvent } from '@testing-library/react'; 7 + import { LoginScreen } from '../LoginScreen'; 8 + import { AuthContext, type AuthState } from '../AuthProvider'; 9 + 10 + function renderWithAuth(overrides: Partial<AuthState> = {}) { 11 + const defaultAuth: AuthState = { 12 + isLoading: false, 13 + session: null, 14 + did: null, 15 + handle: null, 16 + displayName: null, 17 + avatarUrl: null, 18 + login: vi.fn(), 19 + logout: vi.fn(), 20 + error: null, 21 + ...overrides, 22 + }; 23 + 24 + return { 25 + ...render( 26 + <AuthContext.Provider value={defaultAuth}> 27 + <LoginScreen /> 28 + </AuthContext.Provider> 29 + ), 30 + auth: defaultAuth, 31 + }; 32 + } 33 + 34 + describe('LoginScreen', () => { 35 + it('renders the login form', () => { 36 + renderWithAuth(); 37 + expect(screen.getByText('Checkmate')).toBeInTheDocument(); 38 + expect(screen.getByText('Sign in with Bluesky')).toBeInTheDocument(); 39 + expect(screen.getByPlaceholderText('alice.bsky.social')).toBeInTheDocument(); 40 + }); 41 + 42 + it('shows loading state during init', () => { 43 + renderWithAuth({ isLoading: true }); 44 + expect(screen.getByText('Loading...')).toBeInTheDocument(); 45 + }); 46 + 47 + it('disables button when handle is empty', () => { 48 + renderWithAuth(); 49 + const button = screen.getByRole('button', { name: /sign in/i }); 50 + expect(button).toBeDisabled(); 51 + }); 52 + 53 + it('enables button when handle is entered', () => { 54 + renderWithAuth(); 55 + const input = screen.getByPlaceholderText('alice.bsky.social'); 56 + fireEvent.change(input, { target: { value: 'alice.bsky.social' } }); 57 + const button = screen.getByRole('button', { name: /sign in/i }); 58 + expect(button).not.toBeDisabled(); 59 + }); 60 + 61 + it('calls login on form submit', () => { 62 + const { auth } = renderWithAuth(); 63 + const input = screen.getByPlaceholderText('alice.bsky.social'); 64 + fireEvent.change(input, { target: { value: 'alice.bsky.social' } }); 65 + fireEvent.submit(screen.getByRole('button', { name: /sign in/i })); 66 + expect(auth.login).toHaveBeenCalledWith('alice.bsky.social'); 67 + }); 68 + 69 + it('shows error message', () => { 70 + renderWithAuth({ error: 'Something went wrong' }); 71 + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); 72 + }); 73 + 74 + it('trims whitespace from handle', () => { 75 + const { auth } = renderWithAuth(); 76 + const input = screen.getByPlaceholderText('alice.bsky.social'); 77 + fireEvent.change(input, { target: { value: ' alice.bsky.social ' } }); 78 + fireEvent.submit(screen.getByRole('button', { name: /sign in/i })); 79 + expect(auth.login).toHaveBeenCalledWith('alice.bsky.social'); 80 + }); 81 + });
+236
client/src/components/game/ChessBoard.tsx
··· 1 + /** 2 + * ChessBoard — wrapper around react-chessboard with game logic integration. 3 + * 4 + * Provides: 5 + * - Drag-and-drop and click-to-move 6 + * - Legal move highlighting (dots on target squares) 7 + * - Last move highlighting 8 + * - Check indicator (red highlight on king square) 9 + * - Smooth piece animations 10 + * - Board orientation based on player color 11 + * 12 + * chess.js runs on the client for instant legal move feedback, but the 13 + * actual move is submitted to SpacetimeDB for authoritative validation. 14 + */ 15 + 16 + import { useCallback, useMemo, useState } from 'react'; 17 + import { Chessboard } from 'react-chessboard'; 18 + import type { PieceDropHandlerArgs, SquareHandlerArgs } from 'react-chessboard'; 19 + import { Chess, type Square } from 'chess.js'; 20 + 21 + interface ChessBoardProps { 22 + /** Current board position in FEN notation */ 23 + fen: string; 24 + /** Board orientation */ 25 + orientation: 'white' | 'black'; 26 + /** Whether it's this player's turn */ 27 + isMyTurn: boolean; 28 + /** Whether the game is active (moves are allowed) */ 29 + isActive: boolean; 30 + /** Last move from/to squares for highlighting */ 31 + lastMove?: { from: string; to: string } | null; 32 + /** Called when the player makes a move */ 33 + onMove: (from: string, to: string, promotion?: string) => void; 34 + } 35 + 36 + // Highlight colors 37 + const LAST_MOVE_STYLE = { background: 'rgba(255, 255, 0, 0.35)' }; 38 + const CHECK_STYLE = { 39 + background: 'radial-gradient(circle, rgba(255, 0, 0, 0.5) 0%, transparent 70%)', 40 + }; 41 + const LEGAL_MOVE_STYLE = { 42 + background: 'radial-gradient(circle, rgba(0, 0, 0, 0.12) 25%, transparent 25%)', 43 + }; 44 + const LEGAL_CAPTURE_STYLE = { 45 + background: 'radial-gradient(circle, rgba(0, 0, 0, 0.12) 85%, transparent 85%)', 46 + }; 47 + const SELECTED_STYLE = { background: 'rgba(255, 255, 0, 0.45)' }; 48 + 49 + // Board colors — warm wood 50 + const LIGHT_SQUARE = '#f0d9b5'; 51 + const DARK_SQUARE = '#b58863'; 52 + 53 + export function ChessBoard({ 54 + fen, 55 + orientation, 56 + isMyTurn, 57 + isActive, 58 + lastMove, 59 + onMove, 60 + }: ChessBoardProps) { 61 + const [selectedSquare, setSelectedSquare] = useState<Square | null>(null); 62 + const [legalMoveSquares, setLegalMoveSquares] = useState< 63 + Record<string, React.CSSProperties> 64 + >({}); 65 + 66 + // chess.js instance for client-side move validation and legal move hints 67 + const chess = useMemo(() => new Chess(fen), [fen]); 68 + 69 + // Compute highlight styles 70 + const squareStyles = useMemo(() => { 71 + const styles: Record<string, React.CSSProperties> = {}; 72 + 73 + // Last move highlight 74 + if (lastMove) { 75 + styles[lastMove.from] = LAST_MOVE_STYLE; 76 + styles[lastMove.to] = LAST_MOVE_STYLE; 77 + } 78 + 79 + // Check highlight on king square 80 + if (chess.isCheck()) { 81 + const kingSquare = findKingSquare(chess, chess.turn()); 82 + if (kingSquare) { 83 + styles[kingSquare] = CHECK_STYLE; 84 + } 85 + } 86 + 87 + // Legal move dots (from click-to-move selection) 88 + for (const [sq, style] of Object.entries(legalMoveSquares)) { 89 + styles[sq] = { ...styles[sq], ...style }; 90 + } 91 + 92 + // Selected square highlight 93 + if (selectedSquare) { 94 + styles[selectedSquare] = { ...styles[selectedSquare], ...SELECTED_STYLE }; 95 + } 96 + 97 + return styles; 98 + }, [chess, lastMove, legalMoveSquares, selectedSquare]); 99 + 100 + // Show legal moves for a square 101 + const showLegalMoves = useCallback( 102 + (square: Square) => { 103 + const moves = chess.moves({ square, verbose: true }); 104 + if (moves.length === 0) return false; 105 + 106 + const newSquares: Record<string, React.CSSProperties> = {}; 107 + for (const move of moves) { 108 + const targetPiece = chess.get(move.to as Square); 109 + newSquares[move.to] = targetPiece ? LEGAL_CAPTURE_STYLE : LEGAL_MOVE_STYLE; 110 + } 111 + setLegalMoveSquares(newSquares); 112 + return true; 113 + }, 114 + [chess] 115 + ); 116 + 117 + // Clear selection and legal move indicators 118 + const clearSelection = useCallback(() => { 119 + setSelectedSquare(null); 120 + setLegalMoveSquares({}); 121 + }, []); 122 + 123 + // Handle piece drop (drag-and-drop) — v5 uses PieceDropHandlerArgs 124 + const handlePieceDrop = useCallback( 125 + ({ piece, sourceSquare, targetSquare }: PieceDropHandlerArgs) => { 126 + if (!isActive || !isMyTurn || !targetSquare) return false; 127 + 128 + // Check if this is a pawn promotion 129 + const pieceType = piece.pieceType; // e.g., "wP", "bQ" 130 + const isPromotion = 131 + pieceType[1] === 'P' && 132 + ((pieceType[0] === 'w' && targetSquare[1] === '8') || 133 + (pieceType[0] === 'b' && targetSquare[1] === '1')); 134 + 135 + // Validate locally with chess.js for instant feedback 136 + try { 137 + chess.move({ 138 + from: sourceSquare, 139 + to: targetSquare, 140 + promotion: isPromotion ? 'q' : undefined, 141 + }); 142 + } catch { 143 + return false; // Illegal move — snap back 144 + } 145 + 146 + // Submit to SpacetimeDB 147 + onMove(sourceSquare, targetSquare, isPromotion ? 'q' : ''); 148 + clearSelection(); 149 + return true; 150 + }, 151 + [chess, isActive, isMyTurn, onMove, clearSelection] 152 + ); 153 + 154 + // Handle square click (click-to-move) — v5 uses SquareHandlerArgs 155 + const handleSquareClick = useCallback( 156 + ({ square }: SquareHandlerArgs) => { 157 + const sq = square as Square; 158 + if (!isActive || !isMyTurn) { 159 + clearSelection(); 160 + return; 161 + } 162 + 163 + // If a piece is already selected, try to move there 164 + if (selectedSquare) { 165 + const piece = chess.get(selectedSquare); 166 + const isPromotion = 167 + piece?.type === 'p' && 168 + ((piece.color === 'w' && sq[1] === '8') || 169 + (piece.color === 'b' && sq[1] === '1')); 170 + 171 + try { 172 + chess.move({ 173 + from: selectedSquare, 174 + to: sq, 175 + promotion: isPromotion ? 'q' : undefined, 176 + }); 177 + onMove(selectedSquare, sq, isPromotion ? 'q' : ''); 178 + clearSelection(); 179 + return; 180 + } catch { 181 + // Not a valid move to this square — fall through to select new piece 182 + } 183 + } 184 + 185 + // Select a new piece 186 + const piece = chess.get(sq); 187 + if (piece && piece.color === chess.turn()) { 188 + setSelectedSquare(sq); 189 + showLegalMoves(sq); 190 + } else { 191 + clearSelection(); 192 + } 193 + }, 194 + [chess, isActive, isMyTurn, selectedSquare, onMove, clearSelection, showLegalMoves] 195 + ); 196 + 197 + const canDrag = isActive && isMyTurn; 198 + 199 + return ( 200 + <div className="w-full max-w-[600px] aspect-square"> 201 + <Chessboard 202 + options={{ 203 + id: 'checkmate-board', 204 + position: fen, 205 + boardOrientation: orientation, 206 + onPieceDrop: handlePieceDrop, 207 + onSquareClick: handleSquareClick, 208 + allowDragging: canDrag, 209 + squareStyles, 210 + boardStyle: { 211 + borderRadius: '4px', 212 + boxShadow: '0 4px 24px rgba(0, 0, 0, 0.4)', 213 + }, 214 + lightSquareStyle: { backgroundColor: LIGHT_SQUARE }, 215 + darkSquareStyle: { backgroundColor: DARK_SQUARE }, 216 + animationDurationInMs: 250, 217 + showAnimations: true, 218 + }} 219 + /> 220 + </div> 221 + ); 222 + } 223 + 224 + /** Find the square of the king for a given color. */ 225 + function findKingSquare(chess: Chess, color: 'w' | 'b'): Square | null { 226 + const board = chess.board(); 227 + for (let rank = 0; rank < 8; rank++) { 228 + for (let file = 0; file < 8; file++) { 229 + const piece = board[rank][file]; 230 + if (piece && piece.type === 'k' && piece.color === color) { 231 + return piece.square; 232 + } 233 + } 234 + } 235 + return null; 236 + }
+169
client/src/components/game/GameScreen.tsx
··· 1 + /** 2 + * GameScreen — the main game view. 3 + * 4 + * Layout: 5 + * - Opponent player bar (top) 6 + * - Chess board (center) 7 + * - Your player bar (bottom) 8 + * - Move list sidebar (right, collapses below on mobile) 9 + * - Game status overlay (when game ends) 10 + * - Resign button 11 + * 12 + * In solo mode (both sides are the same player), the board always shows 13 + * white's perspective and both sides are always movable. 14 + */ 15 + 16 + import { useCallback, useMemo, useState } from 'react'; 17 + import { useAuth } from '../../hooks/useAuth'; 18 + import { useGame } from '../../hooks/useGame'; 19 + import { ChessBoard } from './ChessBoard'; 20 + import { MoveList } from './MoveList'; 21 + import { PlayerBar } from './PlayerBar'; 22 + import { GameStatus } from './GameStatus'; 23 + 24 + export function GameScreen() { 25 + const { displayName, avatarUrl, handle } = useAuth(); 26 + const { activeGame, moves, players, makeMove, resignGame } = useGame(); 27 + const [showResignConfirm, setShowResignConfirm] = useState(false); 28 + 29 + // Shouldn't happen — parent only renders GameScreen when activeGame exists 30 + if (!activeGame) return null; 31 + 32 + const { isSolo } = activeGame; 33 + 34 + // In solo mode, orient as white and always allow moves. 35 + // In multiplayer, orient to the player's assigned color. 36 + const orientation = isSolo ? 'white' : activeGame.isWhite ? 'white' : 'black'; 37 + const isMyTurn = isSolo 38 + ? true 39 + : (activeGame.turn === 'w' && activeGame.isWhite) || 40 + (activeGame.turn === 'b' && !activeGame.isWhite); 41 + 42 + // Player bar colors 43 + const bottomColor = isSolo ? 'white' : activeGame.isWhite ? 'white' : 'black'; 44 + const topColor = isSolo ? 'black' : activeGame.isWhite ? 'black' : 'white'; 45 + 46 + // Look up opponent info (in solo mode, opponent is yourself) 47 + const opponent = isSolo ? null : players.get(activeGame.opponentIdentityHex); 48 + const opponentName = isSolo ? (displayName ?? 'You') : (opponent?.displayName ?? 'Opponent'); 49 + const opponentHandle = isSolo ? (handle ?? undefined) : opponent?.handle; 50 + const opponentAvatar = isSolo ? (avatarUrl ?? undefined) : opponent?.avatarUrl; 51 + 52 + // Last move for board highlighting 53 + const lastMove = useMemo(() => { 54 + if (moves.length === 0) return null; 55 + const last = moves[moves.length - 1]; 56 + return { from: last.from, to: last.to }; 57 + }, [moves]); 58 + 59 + const handleMove = useCallback( 60 + (from: string, to: string, promotion?: string) => { 61 + makeMove({ 62 + gameId: activeGame.id, 63 + from, 64 + to, 65 + promotion: promotion ?? '', 66 + }); 67 + }, 68 + [makeMove, activeGame.id] 69 + ); 70 + 71 + const handleResign = useCallback(() => { 72 + resignGame({ gameId: activeGame.id }); 73 + setShowResignConfirm(false); 74 + }, [resignGame, activeGame.id]); 75 + 76 + const handleBackToLobby = useCallback(() => { 77 + // The game is already over — the parent routing will handle 78 + // transitioning back when there's no active game. We just need 79 + // to force a re-render by acknowledging the game is done. 80 + // Since the game status changed in SpacetimeDB, the subscription 81 + // will have already updated activeGame to null or non-active. 82 + window.location.reload(); 83 + }, []); 84 + 85 + return ( 86 + <div className="flex flex-1 flex-col lg:flex-row items-center justify-center gap-4 p-4"> 87 + {/* Board + player bars column */} 88 + <div className="flex flex-col gap-2 w-full max-w-[600px] relative"> 89 + {/* Opponent / Black (top) */} 90 + <PlayerBar 91 + displayName={isSolo ? 'Black' : opponentName} 92 + handle={isSolo ? undefined : opponentHandle} 93 + avatarUrl={isSolo ? undefined : opponentAvatar} 94 + isActive={activeGame.turn === 'b' && activeGame.status === 'active'} 95 + color={topColor} 96 + /> 97 + 98 + {/* Board */} 99 + <div className="relative"> 100 + <ChessBoard 101 + fen={activeGame.fen} 102 + orientation={orientation} 103 + isMyTurn={isMyTurn} 104 + isActive={activeGame.status === 'active'} 105 + lastMove={lastMove} 106 + onMove={handleMove} 107 + /> 108 + 109 + {/* Game over overlay */} 110 + <GameStatus 111 + status={activeGame.status} 112 + winner={activeGame.winner} 113 + isWhite={activeGame.isWhite} 114 + onBackToLobby={handleBackToLobby} 115 + /> 116 + </div> 117 + 118 + {/* You / White (bottom) */} 119 + <PlayerBar 120 + displayName={isSolo ? 'White' : (displayName ?? 'You')} 121 + handle={isSolo ? undefined : (handle ?? undefined)} 122 + avatarUrl={isSolo ? undefined : (avatarUrl ?? undefined)} 123 + isActive={activeGame.turn === 'w' && activeGame.status === 'active'} 124 + color={bottomColor} 125 + /> 126 + 127 + {/* Resign button */} 128 + {activeGame.status === 'active' && ( 129 + <div className="flex justify-center mt-2"> 130 + {showResignConfirm ? ( 131 + <div className="flex items-center gap-2"> 132 + <span className="text-sm text-neutral-400">Resign?</span> 133 + <button 134 + onClick={handleResign} 135 + className="rounded px-3 py-1 text-sm bg-red-600 text-white hover:bg-red-700" 136 + > 137 + Yes 138 + </button> 139 + <button 140 + onClick={() => setShowResignConfirm(false)} 141 + className="rounded px-3 py-1 text-sm border border-neutral-600 text-neutral-300 hover:border-neutral-400" 142 + > 143 + No 144 + </button> 145 + </div> 146 + ) : ( 147 + <button 148 + onClick={() => setShowResignConfirm(true)} 149 + className="rounded px-4 py-1.5 text-sm text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 transition-colors" 150 + > 151 + Resign 152 + </button> 153 + )} 154 + </div> 155 + )} 156 + </div> 157 + 158 + {/* Move list sidebar */} 159 + <div className="w-full lg:w-56 lg:h-[500px] h-32 rounded-lg bg-neutral-900 border border-neutral-800 p-3 overflow-hidden"> 160 + <h3 className="text-xs font-medium text-neutral-500 uppercase tracking-wider mb-2"> 161 + Moves 162 + </h3> 163 + <div className="h-[calc(100%-2rem)]"> 164 + <MoveList moves={moves} /> 165 + </div> 166 + </div> 167 + </div> 168 + ); 169 + }
+73
client/src/components/game/GameStatus.tsx
··· 1 + /** 2 + * GameStatus — displays the game outcome when it ends. 3 + * 4 + * Shows a modal overlay for checkmate, stalemate, draw, or resignation. 5 + */ 6 + 7 + import type { GameStatus as GameStatusType } from '../../hooks/useGame'; 8 + 9 + interface GameStatusProps { 10 + status: GameStatusType; 11 + winner: string; 12 + isWhite: boolean; 13 + onBackToLobby: () => void; 14 + } 15 + 16 + export function GameStatus({ status, winner, isWhite, onBackToLobby }: GameStatusProps) { 17 + if (status === 'active') return null; 18 + 19 + const myColor = isWhite ? 'white' : 'black'; 20 + const iWon = winner === myColor; 21 + const isDraw = winner === 'draw'; 22 + 23 + let title: string; 24 + let subtitle: string; 25 + 26 + switch (status) { 27 + case 'checkmate': 28 + title = iWon ? 'You win!' : 'Checkmate'; 29 + subtitle = iWon 30 + ? 'Checkmate — well played!' 31 + : 'Your king has been checkmated.'; 32 + break; 33 + case 'stalemate': 34 + title = 'Stalemate'; 35 + subtitle = 'No legal moves — the game is a draw.'; 36 + break; 37 + case 'draw': 38 + title = 'Draw'; 39 + subtitle = 'The game ended in a draw.'; 40 + break; 41 + case 'resigned': 42 + title = iWon ? 'Opponent resigned' : 'You resigned'; 43 + subtitle = iWon 44 + ? 'Your opponent has resigned. You win!' 45 + : 'You have resigned the game.'; 46 + break; 47 + case 'abandoned': 48 + title = 'Game abandoned'; 49 + subtitle = 'Your opponent disconnected.'; 50 + break; 51 + default: 52 + title = 'Game over'; 53 + subtitle = ''; 54 + } 55 + 56 + return ( 57 + <div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60 backdrop-blur-sm"> 58 + <div className="mx-4 w-full max-w-xs rounded-xl bg-neutral-900 border border-neutral-700 p-6 text-center shadow-2xl"> 59 + <div className="text-4xl mb-3"> 60 + {iWon ? '🏆' : isDraw ? '🤝' : '💀'} 61 + </div> 62 + <h2 className="text-xl font-bold text-white">{title}</h2> 63 + <p className="mt-1 text-sm text-neutral-400">{subtitle}</p> 64 + <button 65 + onClick={onBackToLobby} 66 + className="mt-5 w-full rounded-lg bg-violet-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-violet-700" 67 + > 68 + Back to Lobby 69 + </button> 70 + </div> 71 + </div> 72 + ); 73 + }
+69
client/src/components/game/MoveList.tsx
··· 1 + /** 2 + * MoveList — displays the game's move history in standard chess notation. 3 + * 4 + * Moves are grouped in pairs (white + black) and auto-scrolls to the latest 5 + * move as the game progresses. 6 + */ 7 + 8 + import { useEffect, useRef } from 'react'; 9 + 10 + interface Move { 11 + moveNumber: number; 12 + san: string; 13 + } 14 + 15 + interface MoveListProps { 16 + moves: Move[]; 17 + } 18 + 19 + export function MoveList({ moves }: MoveListProps) { 20 + const scrollRef = useRef<HTMLDivElement>(null); 21 + 22 + // Auto-scroll to bottom when new moves arrive 23 + useEffect(() => { 24 + if (scrollRef.current) { 25 + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; 26 + } 27 + }, [moves.length]); 28 + 29 + if (moves.length === 0) { 30 + return ( 31 + <div className="flex h-full items-center justify-center text-sm text-neutral-500"> 32 + No moves yet 33 + </div> 34 + ); 35 + } 36 + 37 + // Group moves into pairs: [1. e4 e5], [2. Nf3 Nc6], ... 38 + const pairs: Array<{ number: number; white: string; black?: string }> = []; 39 + for (let i = 0; i < moves.length; i += 2) { 40 + pairs.push({ 41 + number: Math.floor(i / 2) + 1, 42 + white: moves[i].san, 43 + black: moves[i + 1]?.san, 44 + }); 45 + } 46 + 47 + return ( 48 + <div ref={scrollRef} className="move-list overflow-y-auto"> 49 + <div className="space-y-0.5"> 50 + {pairs.map((pair) => ( 51 + <div 52 + key={pair.number} 53 + className="flex items-center gap-1 text-sm font-mono" 54 + > 55 + <span className="w-8 text-right text-neutral-500"> 56 + {pair.number}. 57 + </span> 58 + <span className="w-16 text-center text-neutral-200"> 59 + {pair.white} 60 + </span> 61 + <span className="w-16 text-center text-neutral-200"> 62 + {pair.black ?? ''} 63 + </span> 64 + </div> 65 + ))} 66 + </div> 67 + </div> 68 + ); 69 + }
+64
client/src/components/game/PlayerBar.tsx
··· 1 + /** 2 + * PlayerBar — displays player info above/below the chess board. 3 + * 4 + * Shows avatar, display name, handle, and a turn indicator. 5 + */ 6 + 7 + interface PlayerBarProps { 8 + displayName: string; 9 + handle?: string; 10 + avatarUrl?: string; 11 + isActive: boolean; // Whether it's this player's turn 12 + color: 'white' | 'black'; 13 + } 14 + 15 + export function PlayerBar({ 16 + displayName, 17 + handle, 18 + avatarUrl, 19 + isActive, 20 + color, 21 + }: PlayerBarProps) { 22 + return ( 23 + <div 24 + className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-colors ${ 25 + isActive 26 + ? 'bg-neutral-800 border border-violet-500/50' 27 + : 'bg-neutral-900 border border-transparent' 28 + }`} 29 + > 30 + {/* Avatar or color indicator */} 31 + {avatarUrl ? ( 32 + <img 33 + src={avatarUrl} 34 + alt={displayName} 35 + className="h-8 w-8 rounded-full" 36 + /> 37 + ) : ( 38 + <div 39 + className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold ${ 40 + color === 'white' 41 + ? 'bg-neutral-200 text-neutral-800' 42 + : 'bg-neutral-700 text-neutral-200' 43 + }`} 44 + > 45 + {color === 'white' ? '♔' : '♚'} 46 + </div> 47 + )} 48 + 49 + <div className="min-w-0 flex-1"> 50 + <div className="truncate text-sm font-medium text-white"> 51 + {displayName} 52 + </div> 53 + {handle && handle !== displayName && ( 54 + <div className="truncate text-xs text-neutral-500">@{handle}</div> 55 + )} 56 + </div> 57 + 58 + {/* Turn indicator */} 59 + {isActive && ( 60 + <div className="h-2.5 w-2.5 rounded-full bg-green-500 animate-pulse" /> 61 + )} 62 + </div> 63 + ); 64 + }
+109
client/src/components/game/__tests__/ChessBoard.test.tsx
··· 1 + /** 2 + * ChessBoard component tests. 3 + * 4 + * Tests the chess board rendering with various positions and configurations. 5 + * Since react-chessboard uses complex DOM rendering with DnD context, 6 + * these tests focus on verifying the component mounts without errors. 7 + */ 8 + 9 + import { describe, it, expect, vi } from 'vitest'; 10 + import { render } from '@testing-library/react'; 11 + import { ChessBoard } from '../ChessBoard'; 12 + 13 + // Starting position FEN 14 + const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; 15 + 16 + // A position after 1. e4 e5 17 + const AFTER_E4_E5 = 'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2'; 18 + 19 + // A checkmate position (Scholar's Mate) 20 + const SCHOLARS_MATE = 'r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4'; 21 + 22 + describe('ChessBoard', () => { 23 + it('renders starting position without crashing', () => { 24 + const onMove = vi.fn(); 25 + const { container } = render( 26 + <ChessBoard 27 + fen={STARTING_FEN} 28 + orientation="white" 29 + isMyTurn={true} 30 + isActive={true} 31 + onMove={onMove} 32 + /> 33 + ); 34 + // The wrapper div should always render 35 + expect(container.firstChild).toBeTruthy(); 36 + }); 37 + 38 + it('renders with black orientation', () => { 39 + const onMove = vi.fn(); 40 + const { container } = render( 41 + <ChessBoard 42 + fen={STARTING_FEN} 43 + orientation="black" 44 + isMyTurn={false} 45 + isActive={true} 46 + onMove={onMove} 47 + /> 48 + ); 49 + expect(container.firstChild).toBeTruthy(); 50 + }); 51 + 52 + it('renders mid-game position', () => { 53 + const onMove = vi.fn(); 54 + const { container } = render( 55 + <ChessBoard 56 + fen={AFTER_E4_E5} 57 + orientation="white" 58 + isMyTurn={true} 59 + isActive={true} 60 + onMove={onMove} 61 + /> 62 + ); 63 + expect(container.firstChild).toBeTruthy(); 64 + }); 65 + 66 + it('renders with last move highlight props', () => { 67 + const onMove = vi.fn(); 68 + const { container } = render( 69 + <ChessBoard 70 + fen={AFTER_E4_E5} 71 + orientation="white" 72 + isMyTurn={true} 73 + isActive={true} 74 + lastMove={{ from: 'e7', to: 'e5' }} 75 + onMove={onMove} 76 + /> 77 + ); 78 + expect(container.firstChild).toBeTruthy(); 79 + }); 80 + 81 + it('renders inactive board (game over)', () => { 82 + const onMove = vi.fn(); 83 + const { container } = render( 84 + <ChessBoard 85 + fen={SCHOLARS_MATE} 86 + orientation="black" 87 + isMyTurn={false} 88 + isActive={false} 89 + onMove={onMove} 90 + /> 91 + ); 92 + expect(container.firstChild).toBeTruthy(); 93 + }); 94 + 95 + it('does not call onMove when game is not active', () => { 96 + const onMove = vi.fn(); 97 + render( 98 + <ChessBoard 99 + fen={SCHOLARS_MATE} 100 + orientation="black" 101 + isMyTurn={false} 102 + isActive={false} 103 + onMove={onMove} 104 + /> 105 + ); 106 + // No interaction happens in the test, onMove should never be called 107 + expect(onMove).not.toHaveBeenCalled(); 108 + }); 109 + });
+109
client/src/components/game/__tests__/GameStatus.test.tsx
··· 1 + /** 2 + * GameStatus component tests — verifies correct messages for all game outcomes. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest'; 6 + import { render, screen, fireEvent } from '@testing-library/react'; 7 + import { GameStatus } from '../GameStatus'; 8 + 9 + describe('GameStatus', () => { 10 + it('returns null for active games', () => { 11 + const { container } = render( 12 + <GameStatus 13 + status="active" 14 + winner="" 15 + isWhite={true} 16 + onBackToLobby={vi.fn()} 17 + /> 18 + ); 19 + expect(container.firstChild).toBeNull(); 20 + }); 21 + 22 + it('shows win message on checkmate when player wins', () => { 23 + render( 24 + <GameStatus 25 + status="checkmate" 26 + winner="white" 27 + isWhite={true} 28 + onBackToLobby={vi.fn()} 29 + /> 30 + ); 31 + expect(screen.getByText('You win!')).toBeInTheDocument(); 32 + expect(screen.getByText(/well played/i)).toBeInTheDocument(); 33 + }); 34 + 35 + it('shows loss message on checkmate when player loses', () => { 36 + render( 37 + <GameStatus 38 + status="checkmate" 39 + winner="white" 40 + isWhite={false} 41 + onBackToLobby={vi.fn()} 42 + /> 43 + ); 44 + expect(screen.getByText('Checkmate')).toBeInTheDocument(); 45 + expect(screen.getByText(/checkmated/i)).toBeInTheDocument(); 46 + }); 47 + 48 + it('shows stalemate message', () => { 49 + render( 50 + <GameStatus 51 + status="stalemate" 52 + winner="draw" 53 + isWhite={true} 54 + onBackToLobby={vi.fn()} 55 + /> 56 + ); 57 + expect(screen.getByText('Stalemate')).toBeInTheDocument(); 58 + }); 59 + 60 + it('shows draw message', () => { 61 + render( 62 + <GameStatus 63 + status="draw" 64 + winner="draw" 65 + isWhite={true} 66 + onBackToLobby={vi.fn()} 67 + /> 68 + ); 69 + expect(screen.getByText('Draw')).toBeInTheDocument(); 70 + }); 71 + 72 + it('shows resignation win message', () => { 73 + render( 74 + <GameStatus 75 + status="resigned" 76 + winner="white" 77 + isWhite={true} 78 + onBackToLobby={vi.fn()} 79 + /> 80 + ); 81 + expect(screen.getByText('Opponent resigned')).toBeInTheDocument(); 82 + }); 83 + 84 + it('shows resignation loss message', () => { 85 + render( 86 + <GameStatus 87 + status="resigned" 88 + winner="white" 89 + isWhite={false} 90 + onBackToLobby={vi.fn()} 91 + /> 92 + ); 93 + expect(screen.getByText('You resigned')).toBeInTheDocument(); 94 + }); 95 + 96 + it('calls onBackToLobby when button clicked', () => { 97 + const onBack = vi.fn(); 98 + render( 99 + <GameStatus 100 + status="checkmate" 101 + winner="white" 102 + isWhite={true} 103 + onBackToLobby={onBack} 104 + /> 105 + ); 106 + fireEvent.click(screen.getByText('Back to Lobby')); 107 + expect(onBack).toHaveBeenCalledOnce(); 108 + }); 109 + });
+70
client/src/components/game/__tests__/MoveList.test.tsx
··· 1 + /** 2 + * MoveList component tests. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest'; 6 + import { render, screen } from '@testing-library/react'; 7 + import { MoveList } from '../MoveList'; 8 + 9 + describe('MoveList', () => { 10 + it('shows empty state when no moves', () => { 11 + render(<MoveList moves={[]} />); 12 + expect(screen.getByText('No moves yet')).toBeInTheDocument(); 13 + }); 14 + 15 + it('renders a single move', () => { 16 + render( 17 + <MoveList 18 + moves={[{ moveNumber: 1, san: 'e4' }]} 19 + /> 20 + ); 21 + expect(screen.getByText('1.')).toBeInTheDocument(); 22 + expect(screen.getByText('e4')).toBeInTheDocument(); 23 + }); 24 + 25 + it('renders a pair of moves', () => { 26 + render( 27 + <MoveList 28 + moves={[ 29 + { moveNumber: 1, san: 'e4' }, 30 + { moveNumber: 2, san: 'e5' }, 31 + ]} 32 + /> 33 + ); 34 + expect(screen.getByText('1.')).toBeInTheDocument(); 35 + expect(screen.getByText('e4')).toBeInTheDocument(); 36 + expect(screen.getByText('e5')).toBeInTheDocument(); 37 + }); 38 + 39 + it('renders multiple pairs', () => { 40 + render( 41 + <MoveList 42 + moves={[ 43 + { moveNumber: 1, san: 'e4' }, 44 + { moveNumber: 2, san: 'e5' }, 45 + { moveNumber: 3, san: 'Nf3' }, 46 + { moveNumber: 4, san: 'Nc6' }, 47 + ]} 48 + /> 49 + ); 50 + expect(screen.getByText('1.')).toBeInTheDocument(); 51 + expect(screen.getByText('2.')).toBeInTheDocument(); 52 + expect(screen.getByText('Nf3')).toBeInTheDocument(); 53 + expect(screen.getByText('Nc6')).toBeInTheDocument(); 54 + }); 55 + 56 + it('handles odd number of moves (white just moved)', () => { 57 + render( 58 + <MoveList 59 + moves={[ 60 + { moveNumber: 1, san: 'e4' }, 61 + { moveNumber: 2, san: 'e5' }, 62 + { moveNumber: 3, san: 'Nf3' }, 63 + ]} 64 + /> 65 + ); 66 + expect(screen.getByText('Nf3')).toBeInTheDocument(); 67 + // Black's second move slot should be empty 68 + expect(screen.getByText('2.')).toBeInTheDocument(); 69 + }); 70 + });
+97
client/src/components/game/__tests__/PlayerBar.test.tsx
··· 1 + /** 2 + * PlayerBar component tests. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest'; 6 + import { render, screen } from '@testing-library/react'; 7 + import { PlayerBar } from '../PlayerBar'; 8 + 9 + describe('PlayerBar', () => { 10 + it('renders display name', () => { 11 + render( 12 + <PlayerBar 13 + displayName="Alice" 14 + handle="alice.bsky.social" 15 + color="white" 16 + isActive={false} 17 + /> 18 + ); 19 + expect(screen.getByText('Alice')).toBeInTheDocument(); 20 + }); 21 + 22 + it('renders handle when different from display name', () => { 23 + render( 24 + <PlayerBar 25 + displayName="Alice" 26 + handle="alice.bsky.social" 27 + color="white" 28 + isActive={false} 29 + /> 30 + ); 31 + expect(screen.getByText('@alice.bsky.social')).toBeInTheDocument(); 32 + }); 33 + 34 + it('does not show handle when same as display name', () => { 35 + render( 36 + <PlayerBar 37 + displayName="alice.bsky.social" 38 + handle="alice.bsky.social" 39 + color="white" 40 + isActive={false} 41 + /> 42 + ); 43 + // Should show it once as the display name, not twice 44 + const elements = screen.getAllByText('alice.bsky.social'); 45 + expect(elements).toHaveLength(1); 46 + }); 47 + 48 + it('shows avatar when provided', () => { 49 + render( 50 + <PlayerBar 51 + displayName="Alice" 52 + avatarUrl="https://example.com/avatar.jpg" 53 + color="white" 54 + isActive={false} 55 + /> 56 + ); 57 + const img = screen.getByRole('img'); 58 + expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg'); 59 + }); 60 + 61 + it('shows king icon when no avatar', () => { 62 + render( 63 + <PlayerBar 64 + displayName="Alice" 65 + color="white" 66 + isActive={false} 67 + /> 68 + ); 69 + // Should not have an img tag 70 + expect(screen.queryByRole('img')).toBeNull(); 71 + }); 72 + 73 + it('shows turn indicator when active', () => { 74 + const { container } = render( 75 + <PlayerBar 76 + displayName="Alice" 77 + color="white" 78 + isActive={true} 79 + /> 80 + ); 81 + // Active player has a pulse indicator 82 + const pulse = container.querySelector('.animate-pulse'); 83 + expect(pulse).toBeTruthy(); 84 + }); 85 + 86 + it('does not show turn indicator when inactive', () => { 87 + const { container } = render( 88 + <PlayerBar 89 + displayName="Alice" 90 + color="white" 91 + isActive={false} 92 + /> 93 + ); 94 + const pulse = container.querySelector('.animate-pulse'); 95 + expect(pulse).toBeNull(); 96 + }); 97 + });
+48
client/src/components/layout/Header.tsx
··· 1 + /** 2 + * Header — top navigation bar with user info and connection status. 3 + */ 4 + 5 + import { useAuth } from '../../hooks/useAuth'; 6 + import { useGame } from '../../hooks/useGame'; 7 + 8 + export function Header() { 9 + const { handle, displayName, avatarUrl, logout } = useAuth(); 10 + const { connected } = useGame(); 11 + 12 + return ( 13 + <header className="flex items-center justify-between border-b border-neutral-800 px-4 py-3"> 14 + <div className="flex items-center gap-3"> 15 + <h1 className="text-lg font-bold tracking-tight text-white"> 16 + Checkmate 17 + </h1> 18 + <div 19 + className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} 20 + title={connected ? 'Connected' : 'Disconnected'} 21 + /> 22 + </div> 23 + 24 + {handle && ( 25 + <div className="flex items-center gap-3"> 26 + <div className="flex items-center gap-2"> 27 + {avatarUrl && ( 28 + <img 29 + src={avatarUrl} 30 + alt={displayName ?? handle} 31 + className="h-7 w-7 rounded-full" 32 + /> 33 + )} 34 + <span className="text-sm text-neutral-300"> 35 + {displayName ?? handle} 36 + </span> 37 + </div> 38 + <button 39 + onClick={logout} 40 + className="rounded px-2 py-1 text-xs text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-neutral-200" 41 + > 42 + Sign out 43 + </button> 44 + </div> 45 + )} 46 + </header> 47 + ); 48 + }
+78
client/src/components/lobby/LobbyScreen.tsx
··· 1 + /** 2 + * LobbyScreen — matchmaking interface. 3 + * 4 + * Shows a "Find Game" button for real matchmaking and a "Play vs Self" 5 + * button for solo play. While searching, displays an animated indicator 6 + * with a cancel option. Auto-transitions to GameScreen when a match is 7 + * found (handled by the parent App routing via activeGame state). 8 + */ 9 + 10 + import { useGame } from '../../hooks/useGame'; 11 + 12 + export function LobbyScreen() { 13 + const { connected, isQueued, joinQueue, leaveQueue, createSoloGame } = useGame(); 14 + 15 + if (isQueued) { 16 + return ( 17 + <div className="flex flex-1 flex-col items-center justify-center gap-8"> 18 + {/* Animated searching indicator */} 19 + <div className="relative flex items-center justify-center"> 20 + <div className="animate-pulse-ring h-32 w-32 rounded-full border-2 border-violet-500/30" /> 21 + <div className="absolute text-4xl">♟</div> 22 + </div> 23 + 24 + <div className="text-center"> 25 + <p className="text-lg font-medium text-white"> 26 + Searching for opponent... 27 + </p> 28 + <p className="mt-1 text-sm text-neutral-400"> 29 + Waiting for another player to join 30 + </p> 31 + </div> 32 + 33 + <button 34 + onClick={leaveQueue} 35 + className="rounded-lg border border-neutral-700 px-6 py-2 text-sm text-neutral-300 transition-colors hover:border-neutral-500 hover:text-white" 36 + > 37 + Cancel 38 + </button> 39 + </div> 40 + ); 41 + } 42 + 43 + return ( 44 + <div className="flex flex-1 flex-col items-center justify-center gap-8"> 45 + <div className="text-center"> 46 + <div className="text-6xl mb-4">♟</div> 47 + <h2 className="text-2xl font-bold text-white">Ready to play?</h2> 48 + <p className="mt-2 text-neutral-400"> 49 + Find an opponent or play both sides yourself 50 + </p> 51 + </div> 52 + 53 + <div className="flex flex-col gap-3"> 54 + <button 55 + onClick={joinQueue} 56 + disabled={!connected} 57 + className="rounded-lg bg-violet-600 px-8 py-3 text-lg font-medium text-white transition-colors hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 focus:ring-offset-neutral-950 disabled:cursor-not-allowed disabled:opacity-50" 58 + > 59 + Find Game 60 + </button> 61 + 62 + <button 63 + onClick={createSoloGame} 64 + disabled={!connected} 65 + className="rounded-lg border border-neutral-700 px-8 py-3 text-sm font-medium text-neutral-300 transition-colors hover:border-neutral-500 hover:text-white disabled:cursor-not-allowed disabled:opacity-50" 66 + > 67 + Play vs Self 68 + </button> 69 + </div> 70 + 71 + {!connected && ( 72 + <p className="text-sm text-yellow-500"> 73 + Connecting to server... 74 + </p> 75 + )} 76 + </div> 77 + ); 78 + }
+13
client/src/hooks/useAuth.ts
··· 1 + import { useContext } from 'react'; 2 + import { AuthContext, type AuthState } from '../components/auth/AuthProvider'; 3 + 4 + /** 5 + * Access the atproto OAuth auth state from any component. 6 + */ 7 + export function useAuth(): AuthState { 8 + const context = useContext(AuthContext); 9 + if (!context) { 10 + throw new Error('useAuth must be used within an AuthProvider'); 11 + } 12 + return context; 13 + }
+173
client/src/hooks/useGame.ts
··· 1 + /** 2 + * useGame — provides the current game state derived from SpacetimeDB tables. 3 + * 4 + * This hook is the bridge between SpacetimeDB's real-time table subscriptions 5 + * and the React UI. It reads from the local replica (zero latency reads) and 6 + * re-renders when subscriptions push updates. 7 + */ 8 + 9 + import { useMemo } from 'react'; 10 + import { useSpacetimeDB, useTable, useReducer } from 'spacetimedb/react'; 11 + import { tables, reducers } from '../module_bindings'; 12 + 13 + export type GameStatus = 14 + | 'active' 15 + | 'checkmate' 16 + | 'stalemate' 17 + | 'draw' 18 + | 'resigned' 19 + | 'abandoned'; 20 + 21 + export interface GameState { 22 + /** SpacetimeDB connection state */ 23 + connected: boolean; 24 + /** SpacetimeDB identity hex string */ 25 + identityHex: string | null; 26 + 27 + /** Whether the player is currently in the matchmaking queue */ 28 + isQueued: boolean; 29 + 30 + /** The active game, if any */ 31 + activeGame: { 32 + id: bigint; 33 + fen: string; 34 + status: GameStatus; 35 + turn: 'w' | 'b'; 36 + winner: string; 37 + moveCount: number; 38 + isWhite: boolean; 39 + isSolo: boolean; 40 + opponentIdentityHex: string; 41 + } | null; 42 + 43 + /** Moves for the active game, sorted by move number */ 44 + moves: Array<{ 45 + moveNumber: number; 46 + san: string; 47 + from: string; 48 + to: string; 49 + fen: string; 50 + }>; 51 + 52 + /** Player info lookup by identity hex */ 53 + players: Map< 54 + string, 55 + { 56 + did: string; 57 + handle: string; 58 + displayName: string; 59 + avatarUrl: string; 60 + } 61 + >; 62 + 63 + /** Reducer calls */ 64 + joinQueue: () => void; 65 + leaveQueue: () => void; 66 + createSoloGame: () => void; 67 + makeMove: (params: { gameId: bigint; from: string; to: string; promotion: string }) => void; 68 + resignGame: (params: { gameId: bigint }) => void; 69 + registerPlayer: (params: { 70 + did: string; 71 + handle: string; 72 + displayName: string; 73 + avatarUrl: string; 74 + }) => void; 75 + } 76 + 77 + export function useGame(): GameState { 78 + const conn = useSpacetimeDB(); 79 + const { isActive: connected, identity } = conn; 80 + const identityHex = identity?.toHexString() ?? null; 81 + 82 + // Subscribe to all tables — SpacetimeDB maintains these as local replicas 83 + const [allGames] = useTable(tables.game); 84 + const [allMoves] = useTable(tables.gameMove); 85 + const [queueEntries] = useTable(tables.matchQueue); 86 + const [allPlayers] = useTable(tables.player); 87 + 88 + // Get reducer functions 89 + const joinQueueReducer = useReducer(reducers.joinQueue); 90 + const leaveQueueReducer = useReducer(reducers.leaveQueue); 91 + const makeMoveReducer = useReducer(reducers.makeMove); 92 + const resignReducer = useReducer(reducers.resign); 93 + const registerPlayerReducer = useReducer(reducers.registerPlayer); 94 + const createSoloGameReducer = useReducer(reducers.createSoloGame); 95 + 96 + // Derive state from the raw table data 97 + const isQueued = useMemo(() => { 98 + if (!identityHex) return false; 99 + return queueEntries.some((e) => e.identity.toHexString() === identityHex); 100 + }, [queueEntries, identityHex]); 101 + 102 + const activeGame = useMemo(() => { 103 + if (!identityHex) return null; 104 + const game = allGames.find((g) => { 105 + if (g.status !== 'active') return false; 106 + return ( 107 + g.whiteIdentity.toHexString() === identityHex || 108 + g.blackIdentity.toHexString() === identityHex 109 + ); 110 + }); 111 + if (!game) return null; 112 + 113 + const whiteHex = game.whiteIdentity.toHexString(); 114 + const blackHex = game.blackIdentity.toHexString(); 115 + const isSolo = whiteHex === blackHex; 116 + const isWhite = whiteHex === identityHex; 117 + const opponentIdentityHex = isWhite ? blackHex : whiteHex; 118 + 119 + return { 120 + id: game.id, 121 + fen: game.fen, 122 + status: game.status as GameStatus, 123 + turn: game.turn as 'w' | 'b', 124 + winner: game.winner, 125 + moveCount: game.moveCount, 126 + isWhite, 127 + isSolo, 128 + opponentIdentityHex, 129 + }; 130 + }, [allGames, identityHex]); 131 + 132 + const moves = useMemo(() => { 133 + if (!activeGame) return []; 134 + return allMoves 135 + .filter((m) => m.gameId === activeGame.id) 136 + .sort((a, b) => a.moveNumber - b.moveNumber) 137 + .map((m) => ({ 138 + moveNumber: m.moveNumber, 139 + san: m.san, 140 + from: m.from, 141 + to: m.to, 142 + fen: m.fen, 143 + })); 144 + }, [allMoves, activeGame]); 145 + 146 + const players = useMemo(() => { 147 + const map = new Map<string, { did: string; handle: string; displayName: string; avatarUrl: string }>(); 148 + for (const p of allPlayers) { 149 + map.set(p.identity.toHexString(), { 150 + did: p.did, 151 + handle: p.handle, 152 + displayName: p.displayName, 153 + avatarUrl: p.avatarUrl, 154 + }); 155 + } 156 + return map; 157 + }, [allPlayers]); 158 + 159 + return { 160 + connected, 161 + identityHex, 162 + isQueued, 163 + activeGame, 164 + moves, 165 + players, 166 + joinQueue: joinQueueReducer, 167 + leaveQueue: leaveQueueReducer, 168 + createSoloGame: createSoloGameReducer, 169 + makeMove: makeMoveReducer, 170 + resignGame: resignReducer, 171 + registerPlayer: registerPlayerReducer, 172 + }; 173 + }
+95
client/src/hooks/useGamePublisher.ts
··· 1 + /** 2 + * useGamePublisher — publishes completed games to atproto on game end. 3 + * 4 + * Watches the SpacetimeDB game table for games that transition to a 5 + * terminal state. When detected, builds a PGN and publishes a 6 + * `social.checkmate.game` record to the player's PDS. 7 + * 8 + * Publishes at most once per game by tracking published game IDs. 9 + * Solo games (playing yourself) are skipped. 10 + */ 11 + 12 + import { useEffect, useRef } from 'react'; 13 + import { useTable } from 'spacetimedb/react'; 14 + import { tables } from '../module_bindings'; 15 + import { useAuth } from './useAuth'; 16 + import { useGame } from './useGame'; 17 + import { buildPgn, gameResultToPgn } from '../lib/pgn'; 18 + import { publishGameRecord } from '../lib/atproto-publish'; 19 + 20 + const TERMINAL_STATUSES = new Set(['checkmate', 'stalemate', 'draw', 'resigned']); 21 + 22 + export function useGamePublisher() { 23 + const { session, did } = useAuth(); 24 + const { identityHex, players } = useGame(); 25 + const [allGames] = useTable(tables.game); 26 + const [allMoves] = useTable(tables.gameMove); 27 + const publishedGameIds = useRef(new Set<string>()); 28 + 29 + useEffect(() => { 30 + if (!session || !did || !identityHex) return; 31 + 32 + for (const game of allGames) { 33 + if (!TERMINAL_STATUSES.has(game.status)) continue; 34 + 35 + const gameIdStr = game.id.toString(); 36 + if (publishedGameIds.current.has(gameIdStr)) continue; 37 + 38 + // Is this our game? 39 + const whiteHex = game.whiteIdentity.toHexString(); 40 + const blackHex = game.blackIdentity.toHexString(); 41 + const isWhite = whiteHex === identityHex; 42 + const isBlack = blackHex === identityHex; 43 + if (!isWhite && !isBlack) continue; 44 + 45 + // Solo games — skip publishing 46 + if (whiteHex === blackHex) { 47 + publishedGameIds.current.add(gameIdStr); 48 + continue; 49 + } 50 + 51 + // Mark as published immediately to prevent double-fire 52 + publishedGameIds.current.add(gameIdStr); 53 + 54 + // Gather moves for this game 55 + const gameMoves = allMoves 56 + .filter((m) => m.gameId === game.id) 57 + .sort((a, b) => a.moveNumber - b.moveNumber) 58 + .map((m) => ({ san: m.san })); 59 + 60 + if (gameMoves.length === 0) continue; // No moves yet — data may still be loading 61 + 62 + // Resolve player info 63 + const whitePlayer = players.get(whiteHex); 64 + const blackPlayer = players.get(blackHex); 65 + const opponentHex = isWhite ? blackHex : whiteHex; 66 + const opponentPlayer = players.get(opponentHex); 67 + 68 + const result = gameResultToPgn(game.status, game.winner); 69 + 70 + const pgn = buildPgn({ 71 + whiteHandle: whitePlayer?.handle ?? 'Unknown', 72 + blackHandle: blackPlayer?.handle ?? 'Unknown', 73 + result, 74 + date: new Date(Number(game.createdAt.microsSinceUnixEpoch / 1000n)), 75 + moves: gameMoves, 76 + }); 77 + 78 + publishGameRecord({ 79 + session, 80 + pgn, 81 + result, 82 + color: isWhite ? 'white' : 'black', 83 + opponentDid: opponentPlayer?.did ?? did, 84 + }) 85 + .then(({ uri }) => { 86 + console.log(`[publish] Game ${gameIdStr} published to atproto: ${uri}`); 87 + }) 88 + .catch((err) => { 89 + console.error(`[publish] Failed to publish game ${gameIdStr}:`, err); 90 + // Allow retry on next render cycle 91 + publishedGameIds.current.delete(gameIdStr); 92 + }); 93 + } 94 + }, [allGames, allMoves, session, did, identityHex, players]); 95 + }
+49
client/src/lib/atproto-publish.ts
··· 1 + /** 2 + * Publish a completed chess game to the player's atproto PDS. 3 + * 4 + * Creates a `social.checkmate.game` record in the user's repo containing 5 + * the full PGN and lightweight metadata. The PGN is the canonical 6 + * representation — no fields duplicate the move list. 7 + * 8 + * Record schema: 9 + * $type: "social.checkmate.game" 10 + * pgn: string — full PGN text (headers + movetext) 11 + * result: string — "1-0" | "0-1" | "1/2-1/2" 12 + * color: string — "white" | "black" 13 + * opponent: string — opponent's atproto DID 14 + * createdAt: string — ISO 8601 timestamp 15 + */ 16 + 17 + import { Agent } from '@atproto/api'; 18 + import type { OAuthSession } from '@atproto/oauth-client-browser'; 19 + 20 + const COLLECTION = 'social.checkmate.game'; 21 + 22 + export interface PublishGameParams { 23 + session: OAuthSession; 24 + pgn: string; 25 + result: '1-0' | '0-1' | '1/2-1/2' | '*'; 26 + color: 'white' | 'black'; 27 + opponentDid: string; 28 + } 29 + 30 + export async function publishGameRecord(params: PublishGameParams): Promise<{ uri: string; cid: string }> { 31 + const { session, pgn, result, color, opponentDid } = params; 32 + const agent = new Agent(session); 33 + 34 + const response = await agent.com.atproto.repo.createRecord({ 35 + repo: session.did, 36 + collection: COLLECTION, 37 + // TODO: set validate: true once lexicon is published via DNS + goat CLI 38 + record: { 39 + $type: COLLECTION, 40 + pgn, 41 + result, 42 + color, 43 + opponent: opponentDid, 44 + createdAt: new Date().toISOString(), 45 + }, 46 + }); 47 + 48 + return { uri: response.data.uri, cid: response.data.cid }; 49 + }
+62
client/src/lib/oauth.ts
··· 1 + /** 2 + * atproto OAuth client configuration. 3 + * 4 + * Uses @atproto/oauth-client-browser for the full OAuth 2.1 + DPoP + PKCE 5 + * flow that atproto requires. The BrowserOAuthClient handles all the 6 + * complexity: PAR requests, DPoP nonce rotation, token refresh, IndexedDB 7 + * storage, etc. 8 + * 9 + * For local development we use a loopback redirect (http://127.0.0.1:5173). 10 + * For production we use https://checkmate.social. 11 + */ 12 + 13 + import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; 14 + 15 + const isLocalhost = 16 + typeof window !== 'undefined' && 17 + (window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost'); 18 + 19 + const PROD_URL = 'https://checkmate.social'; 20 + const LOCAL_URL = 'http://127.0.0.1:5173'; 21 + 22 + /** 23 + * Create the OAuth client. For localhost, we pass client metadata inline 24 + * (atproto allows this for loopback clients). For production, we point to 25 + * the hosted client-metadata.json. 26 + */ 27 + export function createOAuthClient(): BrowserOAuthClient { 28 + if (isLocalhost) { 29 + return new BrowserOAuthClient({ 30 + clientMetadata: { 31 + client_id: `http://localhost?redirect_uri=${encodeURIComponent(`${LOCAL_URL}/callback`)}&scope=${encodeURIComponent('atproto transition:generic')}`, 32 + client_name: 'Checkmate (dev)', 33 + client_uri: LOCAL_URL, 34 + redirect_uris: [`${LOCAL_URL}/callback`], 35 + scope: 'atproto transition:generic', 36 + grant_types: ['authorization_code', 'refresh_token'], 37 + response_types: ['code'], 38 + token_endpoint_auth_method: 'none', 39 + application_type: 'web', 40 + dpop_bound_access_tokens: true, 41 + }, 42 + handleResolver: 'https://bsky.social', 43 + }); 44 + } 45 + 46 + return new BrowserOAuthClient({ 47 + clientMetadata: { 48 + client_id: `${PROD_URL}/client-metadata.json`, 49 + client_name: 'Checkmate', 50 + client_uri: PROD_URL, 51 + logo_uri: `${PROD_URL}/logo.png`, 52 + redirect_uris: [`${PROD_URL}/callback`], 53 + scope: 'atproto transition:generic', 54 + grant_types: ['authorization_code', 'refresh_token'], 55 + response_types: ['code'], 56 + token_endpoint_auth_method: 'none', 57 + application_type: 'web', 58 + dpop_bound_access_tokens: true, 59 + }, 60 + handleResolver: 'https://bsky.social', 61 + }); 62 + }
+68
client/src/lib/pgn.ts
··· 1 + /** 2 + * PGN (Portable Game Notation) generation. 3 + * 4 + * Builds a standard PGN string from game metadata and move history. 5 + * The PGN is the single canonical representation of the game — we store 6 + * it once in the atproto record and don't duplicate the move list. 7 + * 8 + * PGN spec: https://www.sarl.org/PGN/Standard 9 + */ 10 + 11 + export interface PgnGameData { 12 + whiteHandle: string; 13 + blackHandle: string; 14 + result: '1-0' | '0-1' | '1/2-1/2' | '*'; 15 + date: Date; 16 + moves: Array<{ san: string }>; 17 + } 18 + 19 + /** 20 + * Map game status + winner to standard PGN result notation. 21 + */ 22 + export function gameResultToPgn( 23 + status: string, 24 + winner: string 25 + ): '1-0' | '0-1' | '1/2-1/2' | '*' { 26 + if (status === 'active') return '*'; 27 + if (winner === 'white') return '1-0'; 28 + if (winner === 'black') return '0-1'; 29 + if (winner === 'draw') return '1/2-1/2'; 30 + return '*'; 31 + } 32 + 33 + /** 34 + * Build a complete PGN string from game data. 35 + */ 36 + export function buildPgn(data: PgnGameData): string { 37 + const { whiteHandle, blackHandle, result, date, moves } = data; 38 + 39 + // Format date as YYYY.MM.DD per PGN spec (UTC to avoid timezone drift) 40 + const year = date.getUTCFullYear(); 41 + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); 42 + const day = String(date.getUTCDate()).padStart(2, '0'); 43 + const dateStr = `${year}.${month}.${day}`; 44 + 45 + // Seven Tag Roster (STR) — the required PGN headers 46 + const headers = [ 47 + `[Event "Checkmate Online"]`, 48 + `[Site "checkmate.social"]`, 49 + `[Date "${dateStr}"]`, 50 + `[Round "-"]`, 51 + `[White "${whiteHandle}"]`, 52 + `[Black "${blackHandle}"]`, 53 + `[Result "${result}"]`, 54 + ].join('\n'); 55 + 56 + // Build movetext: "1. e4 e5 2. Nf3 Nc6 ..." 57 + const parts: string[] = []; 58 + for (let i = 0; i < moves.length; i++) { 59 + if (i % 2 === 0) { 60 + parts.push(`${Math.floor(i / 2) + 1}.`); 61 + } 62 + parts.push(moves[i].san); 63 + } 64 + parts.push(result); 65 + const movetext = parts.join(' '); 66 + 67 + return `${headers}\n\n${movetext}\n`; 68 + }
+43
client/src/lib/spacetime.ts
··· 1 + /** 2 + * SpacetimeDB connection configuration. 3 + * 4 + * The connectionBuilder is consumed by SpacetimeDBProvider in main.tsx. 5 + * It handles token persistence in localStorage so that reconnections 6 + * reuse the same SpacetimeDB identity. 7 + */ 8 + 9 + import { Identity } from 'spacetimedb'; 10 + import { DbConnection, type ErrorContext } from '../module_bindings'; 11 + 12 + const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'ws://localhost:3000'; 13 + const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'checkmate'; 14 + const TOKEN_KEY = `checkmate:${HOST}/${DB_NAME}/auth_token`; 15 + 16 + export { HOST, DB_NAME, TOKEN_KEY }; 17 + 18 + function onConnect(_conn: DbConnection, identity: Identity, token: string) { 19 + localStorage.setItem(TOKEN_KEY, token); 20 + console.log('[spacetime] Connected as:', identity.toHexString()); 21 + } 22 + 23 + function onDisconnect() { 24 + console.log('[spacetime] Disconnected'); 25 + } 26 + 27 + function onConnectError(_ctx: ErrorContext, err: Error) { 28 + console.error('[spacetime] Connection error:', err); 29 + } 30 + 31 + /** 32 + * Build the SpacetimeDB connection. This is called once and passed to the 33 + * provider. Do NOT call .build() — the provider does that. 34 + */ 35 + export function createConnectionBuilder() { 36 + return DbConnection.builder() 37 + .withUri(HOST) 38 + .withDatabaseName(DB_NAME) 39 + .withToken(localStorage.getItem(TOKEN_KEY) || undefined) 40 + .onConnect(onConnect) 41 + .onDisconnect(onDisconnect) 42 + .onConnectError(onConnectError); 43 + }
+36
client/src/main.tsx
··· 1 + /** 2 + * Entry point — sets up providers and renders the app. 3 + * 4 + * Provider hierarchy: 5 + * SpacetimeDBProvider — real-time database connection 6 + * AuthProvider — atproto OAuth session 7 + * App — state-based routing 8 + */ 9 + 10 + import { StrictMode, useMemo } from 'react'; 11 + import { createRoot } from 'react-dom/client'; 12 + import { SpacetimeDBProvider } from 'spacetimedb/react'; 13 + import { AuthProvider } from './components/auth/AuthProvider'; 14 + import { createConnectionBuilder } from './lib/spacetime'; 15 + import { createOAuthClient } from './lib/oauth'; 16 + import App from './App'; 17 + import './app.css'; 18 + 19 + function Root() { 20 + const connectionBuilder = useMemo(() => createConnectionBuilder(), []); 21 + const oauthClient = useMemo(() => createOAuthClient(), []); 22 + 23 + return ( 24 + <SpacetimeDBProvider connectionBuilder={connectionBuilder}> 25 + <AuthProvider client={oauthClient}> 26 + <App /> 27 + </AuthProvider> 28 + </SpacetimeDBProvider> 29 + ); 30 + } 31 + 32 + createRoot(document.getElementById('root')!).render( 33 + <StrictMode> 34 + <Root /> 35 + </StrictMode> 36 + );
+1
client/src/test-setup.ts
··· 1 + import '@testing-library/jest-dom/vitest';
+10
client/src/vite-env.d.ts
··· 1 + /// <reference types="vite/client" /> 2 + 3 + interface ImportMetaEnv { 4 + readonly VITE_SPACETIMEDB_HOST: string; 5 + readonly VITE_SPACETIMEDB_DB_NAME: string; 6 + } 7 + 8 + interface ImportMeta { 9 + readonly env: ImportMetaEnv; 10 + }
+22
client/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2020", 4 + "useDefineForClassFields": true, 5 + "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 + "types": ["vite/client"], 7 + "module": "ESNext", 8 + "skipLibCheck": true, 9 + "moduleResolution": "bundler", 10 + "allowImportingTsExtensions": true, 11 + "isolatedModules": true, 12 + "moduleDetection": "force", 13 + "noEmit": true, 14 + "jsx": "react-jsx", 15 + "strict": true, 16 + "noUnusedLocals": true, 17 + "noUnusedParameters": true, 18 + "noFallthroughCasesInSwitch": true, 19 + "noUncheckedSideEffectImports": true 20 + }, 21 + "include": ["src", "vite.config.ts"] 22 + }
+15
client/vite.config.ts
··· 1 + import { defineConfig } from 'vite'; 2 + import react from '@vitejs/plugin-react'; 3 + import tailwindcss from '@tailwindcss/vite'; 4 + 5 + export default defineConfig({ 6 + plugins: [react(), tailwindcss()], 7 + envDir: '..', 8 + server: { 9 + port: 5173, 10 + host: '127.0.0.1', 11 + }, 12 + // SPA fallback — all routes serve index.html so that atproto OAuth 13 + // callback at /callback is handled by the React app. 14 + appType: 'spa', 15 + });
+12
client/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + import react from '@vitejs/plugin-react'; 3 + 4 + export default defineConfig({ 5 + plugins: [react()], 6 + test: { 7 + environment: 'jsdom', 8 + globals: true, 9 + setupFiles: ['./src/test-setup.ts'], 10 + css: false, 11 + }, 12 + });
+89
justfile
··· 1 + # Checkmate — real-time chess on atproto + SpacetimeDB 2 + 3 + pid_file := ".spacetime.pid" 4 + log_file := ".spacetime.log" 5 + 6 + # Install all dependencies 7 + install: 8 + cd server && bun install 9 + cd client && bun install 10 + 11 + # Build the SpacetimeDB server module 12 + build: 13 + spacetime build --module-path ./server 14 + 15 + # Generate TypeScript client bindings from the server module 16 + generate: 17 + spacetime generate --lang typescript --out-dir client/src/module_bindings --module-path server 18 + 19 + # Publish the module to local SpacetimeDB (clears existing data) 20 + publish: 21 + spacetime publish checkmate --module-path ./server --server local --delete-data -y 22 + 23 + # Start SpacetimeDB in the background 24 + up: 25 + #!/usr/bin/env bash 26 + set -euo pipefail 27 + if [ -f {{pid_file}} ] && kill -0 "$(cat {{pid_file}})" 2>/dev/null; then 28 + echo "SpacetimeDB already running (pid $(cat {{pid_file}}))" 29 + exit 0 30 + fi 31 + spacetime start --non-interactive > {{log_file}} 2>&1 & 32 + echo $! > {{pid_file}} 33 + echo "SpacetimeDB started (pid $(cat {{pid_file}}))" 34 + # Wait for the server to accept TCP connections 35 + for i in $(seq 1 30); do 36 + if curl -so /dev/null http://127.0.0.1:3000/ 2>/dev/null; then 37 + echo "SpacetimeDB ready" 38 + exit 0 39 + fi 40 + sleep 0.2 41 + done 42 + echo "Warning: SpacetimeDB may not be ready yet — check 'just logs'" 43 + 44 + # Stop SpacetimeDB 45 + down: 46 + #!/usr/bin/env bash 47 + set -euo pipefail 48 + if [ ! -f {{pid_file}} ]; then 49 + echo "No PID file found — SpacetimeDB not running" 50 + exit 0 51 + fi 52 + pid=$(cat {{pid_file}}) 53 + if kill -0 "$pid" 2>/dev/null; then 54 + kill "$pid" 55 + echo "SpacetimeDB stopped (pid $pid)" 56 + else 57 + echo "SpacetimeDB was not running (stale PID file)" 58 + fi 59 + rm -f {{pid_file}} 60 + 61 + # Start the Vite dev server 62 + dev: 63 + cd client && bun run dev 64 + 65 + # Run all tests 66 + test: 67 + cd client && bun run test:ci 68 + 69 + # Run tests in watch mode 70 + test-watch: 71 + cd client && bun run test 72 + 73 + # Type-check the client 74 + check: 75 + cd client && npx tsc --noEmit 76 + 77 + # Full build + type-check + test 78 + ci: build check test 79 + 80 + # View SpacetimeDB server logs 81 + logs: 82 + spacetime logs checkmate 83 + 84 + # Query the database 85 + sql query: 86 + spacetime sql checkmate "{{query}}" 87 + 88 + # First-time setup: install deps, start db, build, publish, generate bindings 89 + setup: install up build publish generate
+42
lexicons/social.checkmate.game.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.checkmate.game", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A completed chess game, stored as PGN.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["pgn", "result", "color", "opponent", "createdAt"], 12 + "properties": { 13 + "pgn": { 14 + "type": "string", 15 + "maxLength": 100000, 16 + "description": "Full PGN text including Seven Tag Roster and movetext." 17 + }, 18 + "result": { 19 + "type": "string", 20 + "knownValues": ["1-0", "0-1", "1/2-1/2"], 21 + "description": "Game result in standard PGN notation." 22 + }, 23 + "color": { 24 + "type": "string", 25 + "knownValues": ["white", "black"], 26 + "description": "The color this player played." 27 + }, 28 + "opponent": { 29 + "type": "string", 30 + "format": "did", 31 + "description": "The opponent's atproto DID." 32 + }, 33 + "createdAt": { 34 + "type": "string", 35 + "format": "datetime", 36 + "description": "Timestamp when the game was completed." 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+39
server/bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "checkmate-server", 7 + "dependencies": { 8 + "chess.js": "^1.4.0", 9 + "spacetimedb": "^2.1.0", 10 + }, 11 + "devDependencies": { 12 + "typescript": "~5.6.2", 13 + }, 14 + }, 15 + }, 16 + "packages": { 17 + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], 18 + 19 + "chess.js": ["chess.js@1.4.0", "", {}, "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw=="], 20 + 21 + "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], 22 + 23 + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], 24 + 25 + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], 26 + 27 + "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], 28 + 29 + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], 30 + 31 + "spacetimedb": ["spacetimedb@2.1.0", "", { "dependencies": { "base64-js": "^1.5.1", "headers-polyfill": "^4.0.3", "object-inspect": "^1.13.4", "prettier": "^3.3.3", "pure-rand": "^7.0.1", "safe-stable-stringify": "^2.5.0", "statuses": "^2.0.2", "url-polyfill": "^1.1.14" }, "peerDependencies": { "@angular/core": ">=17.0.0", "@tanstack/react-query": "^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0", "svelte": "^4.0.0 || ^5.0.0", "undici": "^6.19.2", "vue": "^3.3.0" }, "optionalPeers": ["@angular/core", "@tanstack/react-query", "react", "svelte", "undici", "vue"] }, "sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA=="], 32 + 33 + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], 34 + 35 + "typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], 36 + 37 + "url-polyfill": ["url-polyfill@1.1.14", "", {}, "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ=="], 38 + } 39 + }
+18
server/package.json
··· 1 + { 2 + "name": "checkmate-server", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "scripts": { 7 + "build": "spacetime build", 8 + "publish:local": "spacetime publish checkmate --module-path . --server local", 9 + "publish:cloud": "spacetime publish checkmate --module-path . --server maincloud" 10 + }, 11 + "dependencies": { 12 + "chess.js": "^1.4.0", 13 + "spacetimedb": "^2.1.0" 14 + }, 15 + "devDependencies": { 16 + "typescript": "~5.6.2" 17 + } 18 + }
+440
server/src/index.ts
··· 1 + /** 2 + * Checkmate SpacetimeDB Module 3 + * 4 + * This is the entire backend for the chess application. SpacetimeDB runs this 5 + * code inside the database itself — there is no separate API server. Clients 6 + * connect via WebSocket, subscribe to tables, and call reducers to mutate state. 7 + * 8 + * All chess move validation happens here via chess.js, making this the 9 + * authoritative source of truth. The client also runs chess.js for UI 10 + * responsiveness (legal move hints, instant feedback), but the server rejects 11 + * any illegal moves. 12 + */ 13 + 14 + import { schema, table, t, SenderError } from 'spacetimedb/server'; 15 + import { Chess } from 'chess.js'; 16 + 17 + // Starting position FEN 18 + const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; 19 + 20 + // --------------------------------------------------------------------------- 21 + // Schema: Tables 22 + // --------------------------------------------------------------------------- 23 + 24 + const spacetimedb = schema({ 25 + // Registered players linked to atproto DIDs 26 + player: table( 27 + { 28 + name: 'player', 29 + public: true, 30 + }, 31 + { 32 + identity: t.identity().primaryKey(), 33 + did: t.string().unique(), 34 + handle: t.string(), 35 + displayName: t.string(), 36 + avatarUrl: t.string(), 37 + createdAt: t.timestamp(), 38 + } 39 + ), 40 + 41 + // Matchmaking queue — players waiting for an opponent 42 + matchQueue: table( 43 + { name: 'match_queue', public: true }, 44 + { 45 + identity: t.identity().primaryKey(), 46 + joinedAt: t.timestamp(), 47 + } 48 + ), 49 + 50 + // Games (active and completed) 51 + game: table( 52 + { 53 + name: 'game', 54 + public: true, 55 + indexes: [ 56 + { accessor: 'game_white', algorithm: 'btree' as const, columns: ['whiteIdentity'] }, 57 + { accessor: 'game_black', algorithm: 'btree' as const, columns: ['blackIdentity'] }, 58 + ], 59 + }, 60 + { 61 + id: t.u64().primaryKey().autoInc(), 62 + whiteIdentity: t.identity(), 63 + blackIdentity: t.identity(), 64 + fen: t.string(), 65 + // "active" | "checkmate" | "stalemate" | "draw" | "resigned" | "abandoned" 66 + status: t.string(), 67 + turn: t.string(), // "w" | "b" 68 + winner: t.string().default(''), // "" | "white" | "black" | "draw" 69 + moveCount: t.u32().default(0), 70 + lastMoveAt: t.timestamp(), 71 + createdAt: t.timestamp(), 72 + } 73 + ), 74 + 75 + // Individual moves — append-only log for game replay 76 + gameMove: table( 77 + { 78 + name: 'game_move', 79 + public: true, 80 + indexes: [ 81 + { accessor: 'game_move_game_id', algorithm: 'btree' as const, columns: ['gameId'] }, 82 + ], 83 + }, 84 + { 85 + id: t.u64().primaryKey().autoInc(), 86 + gameId: t.u64(), 87 + moveNumber: t.u32(), 88 + san: t.string(), 89 + from: t.string(), 90 + to: t.string(), 91 + fen: t.string(), 92 + playedBy: t.identity(), 93 + createdAt: t.timestamp(), 94 + } 95 + ), 96 + }); 97 + 98 + export default spacetimedb; 99 + 100 + // --------------------------------------------------------------------------- 101 + // Lifecycle Reducers 102 + // --------------------------------------------------------------------------- 103 + 104 + export const init = spacetimedb.init((_ctx) => { 105 + console.info('Checkmate module initialized'); 106 + }); 107 + 108 + export const onConnect = spacetimedb.clientConnected((ctx) => { 109 + console.info(`Client connected: ${ctx.sender.toHexString()}`); 110 + }); 111 + 112 + export const onDisconnect = spacetimedb.clientDisconnected((ctx) => { 113 + const identity = ctx.sender; 114 + 115 + // Remove from matchmaking queue if they were waiting 116 + const queued = ctx.db.matchQueue.identity.find(identity); 117 + if (queued) { 118 + ctx.db.matchQueue.identity.delete(identity); 119 + console.info(`Removed disconnected player from queue: ${identity.toHexString()}`); 120 + } 121 + }); 122 + 123 + // --------------------------------------------------------------------------- 124 + // Player Registration 125 + // --------------------------------------------------------------------------- 126 + 127 + /** 128 + * Register or update a player's profile. Called after OAuth login to link 129 + * the atproto DID to the SpacetimeDB identity. 130 + */ 131 + export const registerPlayer = spacetimedb.reducer( 132 + { 133 + did: t.string(), 134 + handle: t.string(), 135 + displayName: t.string(), 136 + avatarUrl: t.string(), 137 + }, 138 + (ctx, { did, handle, displayName, avatarUrl }) => { 139 + if (!did || !did.startsWith('did:')) { 140 + throw new SenderError('Invalid DID format'); 141 + } 142 + 143 + const existing = ctx.db.player.identity.find(ctx.sender); 144 + if (existing) { 145 + // Update existing profile 146 + ctx.db.player.identity.update({ 147 + ...existing, 148 + did, 149 + handle, 150 + displayName, 151 + avatarUrl, 152 + }); 153 + } else { 154 + // New player 155 + ctx.db.player.insert({ 156 + identity: ctx.sender, 157 + did, 158 + handle, 159 + displayName, 160 + avatarUrl, 161 + createdAt: ctx.timestamp, 162 + }); 163 + } 164 + } 165 + ); 166 + 167 + // --------------------------------------------------------------------------- 168 + // Matchmaking 169 + // --------------------------------------------------------------------------- 170 + 171 + /** 172 + * Join the matchmaking queue. If another player is already waiting, immediately 173 + * create a new game and remove both players from the queue. 174 + */ 175 + export const joinQueue = spacetimedb.reducer((ctx) => { 176 + const identity = ctx.sender; 177 + 178 + // Must be a registered player 179 + const player = ctx.db.player.identity.find(identity); 180 + if (!player) { 181 + throw new SenderError('Must register before joining queue'); 182 + } 183 + 184 + // Cannot join if already in queue 185 + const alreadyQueued = ctx.db.matchQueue.identity.find(identity); 186 + if (alreadyQueued) { 187 + throw new SenderError('Already in matchmaking queue'); 188 + } 189 + 190 + // Cannot join if already in an active game 191 + for (const game of ctx.db.game.game_white.filter(identity)) { 192 + if (game.status === 'active') { 193 + throw new SenderError('Already in an active game'); 194 + } 195 + } 196 + for (const game of ctx.db.game.game_black.filter(identity)) { 197 + if (game.status === 'active') { 198 + throw new SenderError('Already in an active game'); 199 + } 200 + } 201 + 202 + // Check if someone is already waiting 203 + let opponent: { identity: typeof identity; joinedAt: typeof ctx.timestamp } | undefined; 204 + for (const entry of ctx.db.matchQueue.iter()) { 205 + if (entry.identity.toHexString() !== identity.toHexString()) { 206 + opponent = entry; 207 + break; 208 + } 209 + } 210 + 211 + if (opponent) { 212 + // Match found! Remove opponent from queue and create a game. 213 + ctx.db.matchQueue.identity.delete(opponent.identity); 214 + 215 + // Assign colors: the player who waited longer gets white (slight advantage 216 + // as a reward for patience). 217 + const whiteIdentity = opponent.identity; 218 + const blackIdentity = identity; 219 + 220 + ctx.db.game.insert({ 221 + id: 0n, // auto-increment 222 + whiteIdentity, 223 + blackIdentity, 224 + fen: STARTING_FEN, 225 + status: 'active', 226 + turn: 'w', 227 + winner: '', 228 + moveCount: 0, 229 + lastMoveAt: ctx.timestamp, 230 + createdAt: ctx.timestamp, 231 + }); 232 + 233 + console.info( 234 + `Game created: ${whiteIdentity.toHexString()} (white) vs ${blackIdentity.toHexString()} (black)` 235 + ); 236 + } else { 237 + // No opponent available — add to queue 238 + ctx.db.matchQueue.insert({ 239 + identity, 240 + joinedAt: ctx.timestamp, 241 + }); 242 + console.info(`Player queued: ${identity.toHexString()}`); 243 + } 244 + }); 245 + 246 + /** 247 + * Create a solo game where the caller plays both sides. All moves still go 248 + * through SpacetimeDB — same validation, same persistence. Useful for local 249 + * testing and casual practice. 250 + */ 251 + export const createSoloGame = spacetimedb.reducer((ctx) => { 252 + const identity = ctx.sender; 253 + 254 + const player = ctx.db.player.identity.find(identity); 255 + if (!player) { 256 + throw new SenderError('Must register before creating a game'); 257 + } 258 + 259 + // Don't allow if already in an active game 260 + for (const game of ctx.db.game.game_white.filter(identity)) { 261 + if (game.status === 'active') { 262 + throw new SenderError('Already in an active game'); 263 + } 264 + } 265 + for (const game of ctx.db.game.game_black.filter(identity)) { 266 + if (game.status === 'active') { 267 + throw new SenderError('Already in an active game'); 268 + } 269 + } 270 + 271 + // Leave the matchmaking queue if queued 272 + const queued = ctx.db.matchQueue.identity.find(identity); 273 + if (queued) { 274 + ctx.db.matchQueue.identity.delete(identity); 275 + } 276 + 277 + ctx.db.game.insert({ 278 + id: 0n, 279 + whiteIdentity: identity, 280 + blackIdentity: identity, 281 + fen: STARTING_FEN, 282 + status: 'active', 283 + turn: 'w', 284 + winner: '', 285 + moveCount: 0, 286 + lastMoveAt: ctx.timestamp, 287 + createdAt: ctx.timestamp, 288 + }); 289 + 290 + console.info(`Solo game created for ${identity.toHexString()}`); 291 + }); 292 + 293 + /** 294 + * Leave the matchmaking queue. 295 + */ 296 + export const leaveQueue = spacetimedb.reducer((ctx) => { 297 + const queued = ctx.db.matchQueue.identity.find(ctx.sender); 298 + if (!queued) { 299 + throw new SenderError('Not in matchmaking queue'); 300 + } 301 + ctx.db.matchQueue.identity.delete(ctx.sender); 302 + }); 303 + 304 + // --------------------------------------------------------------------------- 305 + // Gameplay 306 + // --------------------------------------------------------------------------- 307 + 308 + /** 309 + * Make a chess move. This is the core reducer — it validates the move using 310 + * chess.js and updates all game state atomically. 311 + */ 312 + export const makeMove = spacetimedb.reducer( 313 + { 314 + gameId: t.u64(), 315 + from: t.string(), 316 + to: t.string(), 317 + promotion: t.string(), 318 + }, 319 + (ctx, { gameId, from, to, promotion }) => { 320 + const game = ctx.db.game.id.find(gameId); 321 + if (!game) { 322 + throw new SenderError('Game not found'); 323 + } 324 + 325 + if (game.status !== 'active') { 326 + throw new SenderError('Game is not active'); 327 + } 328 + 329 + // Verify it's this player's turn 330 + const isWhite = game.whiteIdentity.toHexString() === ctx.sender.toHexString(); 331 + const isBlack = game.blackIdentity.toHexString() === ctx.sender.toHexString(); 332 + 333 + if (!isWhite && !isBlack) { 334 + throw new SenderError('You are not a player in this game'); 335 + } 336 + 337 + if ((game.turn === 'w' && !isWhite) || (game.turn === 'b' && !isBlack)) { 338 + throw new SenderError('Not your turn'); 339 + } 340 + 341 + // Validate the move with chess.js 342 + const chess = new Chess(game.fen); 343 + 344 + let move; 345 + try { 346 + move = chess.move({ 347 + from, 348 + to, 349 + promotion: promotion || undefined, 350 + }); 351 + } catch { 352 + throw new SenderError(`Illegal move: ${from} -> ${to}`); 353 + } 354 + 355 + if (!move) { 356 + throw new SenderError(`Illegal move: ${from} -> ${to}`); 357 + } 358 + 359 + // Determine game status after the move 360 + let status = 'active'; 361 + let winner = ''; 362 + 363 + if (chess.isCheckmate()) { 364 + status = 'checkmate'; 365 + winner = game.turn === 'w' ? 'white' : 'black'; // Current turn made the winning move 366 + } else if (chess.isStalemate()) { 367 + status = 'stalemate'; 368 + winner = 'draw'; 369 + } else if (chess.isDraw()) { 370 + // Covers insufficient material, 50-move rule, threefold repetition 371 + status = 'draw'; 372 + winner = 'draw'; 373 + } 374 + 375 + const newMoveCount = game.moveCount + 1; 376 + 377 + // Update the game 378 + ctx.db.game.id.update({ 379 + ...game, 380 + fen: chess.fen(), 381 + status, 382 + turn: chess.turn(), 383 + winner, 384 + moveCount: newMoveCount, 385 + lastMoveAt: ctx.timestamp, 386 + }); 387 + 388 + // Record the move 389 + ctx.db.gameMove.insert({ 390 + id: 0n, // auto-increment 391 + gameId, 392 + moveNumber: newMoveCount, 393 + san: move.san, 394 + from: move.from, 395 + to: move.to, 396 + fen: chess.fen(), 397 + playedBy: ctx.sender, 398 + createdAt: ctx.timestamp, 399 + }); 400 + 401 + if (status !== 'active') { 402 + console.info(`Game ${gameId} ended: ${status}, winner: ${winner}`); 403 + } 404 + } 405 + ); 406 + 407 + /** 408 + * Resign from a game. The opponent wins. 409 + */ 410 + export const resign = spacetimedb.reducer( 411 + { gameId: t.u64() }, 412 + (ctx, { gameId }) => { 413 + const game = ctx.db.game.id.find(gameId); 414 + if (!game) { 415 + throw new SenderError('Game not found'); 416 + } 417 + 418 + if (game.status !== 'active') { 419 + throw new SenderError('Game is not active'); 420 + } 421 + 422 + const isWhite = game.whiteIdentity.toHexString() === ctx.sender.toHexString(); 423 + const isBlack = game.blackIdentity.toHexString() === ctx.sender.toHexString(); 424 + 425 + if (!isWhite && !isBlack) { 426 + throw new SenderError('You are not a player in this game'); 427 + } 428 + 429 + const winner = isWhite ? 'black' : 'white'; 430 + 431 + ctx.db.game.id.update({ 432 + ...game, 433 + status: 'resigned', 434 + winner, 435 + lastMoveAt: ctx.timestamp, 436 + }); 437 + 438 + console.info(`Game ${gameId}: ${isWhite ? 'white' : 'black'} resigned, ${winner} wins`); 439 + } 440 + );
+14
server/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "strict": true, 4 + "skipLibCheck": true, 5 + "moduleResolution": "bundler", 6 + "jsx": "react-jsx", 7 + "target": "ESNext", 8 + "lib": ["ES2021", "dom"], 9 + "module": "ESNext", 10 + "isolatedModules": true, 11 + "noEmit": true 12 + }, 13 + "include": ["./**/*"] 14 + }
+4
spacetime.json
··· 1 + { 2 + "module-path": "./server", 3 + "server": "maincloud" 4 + }
+3
spacetime.local.json
··· 1 + { 2 + "database": "checkmate" 3 + }