Social Annotations in the Atmosphere
15
fork

Configure Feed

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

WIP: plans for simplification

+2245
+1733
ARCHITECTURE.md
··· 1 + # Synthesis Architecture Documentation 2 + 3 + ## Table of Contents 4 + 1. [Overview](#overview) 5 + 2. [System Architecture](#system-architecture) 6 + 3. [Component Deep Dive](#component-deep-dive) 7 + 4. [Data Flow](#data-flow) 8 + 5. [Message Passing Protocol](#message-passing-protocol) 9 + 6. [Authentication System](#authentication-system) 10 + 7. [Annotation Lifecycle](#annotation-lifecycle) 11 + 8. [Highlight System](#highlight-system) 12 + 9. [Selector System](#selector-system) 13 + 10. [Comment/Reply System](#comment-reply-system) 14 + 11. [Landing Page](#landing-page) 15 + 12. [Storage Architecture](#storage-architecture) 16 + 13. [Known Issues](#known-issues) 17 + 18 + --- 19 + 20 + ## Overview 21 + 22 + **Synthesis** is a browser extension for creating web annotations using the AT Protocol (ATProto/Bluesky infrastructure). It's a Hypothesis clone that stores annotations as ATProto records on personal data servers (PDS). 23 + 24 + ### Tech Stack 25 + - **Framework**: WXT (Web Extension Framework) 26 + - **Language**: TypeScript 27 + - **Authentication**: AT Protocol OAuth (`@atcute/oauth-browser-client`) 28 + - **API Client**: `@atproto/api` 29 + - **Selector Libraries**: `dom-anchor-text-quote`, `dom-anchor-text-position` 30 + - **Build Tool**: Vite (via WXT) 31 + 32 + ### Project Structure 33 + ``` 34 + seams.so/ 35 + ├── entrypoints/ # Extension entry points 36 + │ ├── background.ts # Service worker (context menus, auth events) 37 + │ ├── content.ts # Content script (page interaction) 38 + │ └── sidepanel/ 39 + │ └── main.ts # Sidepanel UI (annotation management) 40 + ├── lib/ # Core libraries 41 + │ ├── oauth.ts # AT Protocol OAuth flow 42 + │ ├── pds.ts # PDS API integration (CRUD operations) 43 + │ ├── types/ # TypeScript type definitions 44 + │ │ ├── annotation.ts # Annotation data model 45 + │ │ └── comment.ts # Comment data model 46 + │ ├── selectors/ # Selector generation/matching 47 + │ │ ├── generate.ts # Create selectors from selections 48 + │ │ └── match.ts # Find DOM ranges from selectors 49 + │ └── highlights/ # Visual highlight rendering 50 + │ ├── apply.ts # Apply highlights to page 51 + │ └── popover.ts # Annotation edit popover 52 + ├── public/ # Landing page (static site) 53 + │ ├── landing.js # Feed viewer (slices.network integration) 54 + │ └── landing.css # Landing page styles 55 + └── lexicon/ # AT Protocol lexicon definitions 56 + ``` 57 + 58 + --- 59 + 60 + ## System Architecture 61 + 62 + ### High-Level Components 63 + 64 + ``` 65 + ┌─────────────────────────────────────────────────────────────┐ 66 + │ Web Page │ 67 + │ ┌────────────────────────────────────────────────────┐ │ 68 + │ │ Content Script (content.ts) │ │ 69 + │ │ - Track text selections │ │ 70 + │ │ - Generate selectors │ │ 71 + │ │ - Apply highlights to DOM │ │ 72 + │ │ - Show annotation popovers │ │ 73 + │ └─────┬────────────────────────────────────────▲─────┘ │ 74 + │ │ messages messages │ │ 75 + └────────┼────────────────────────────────────────┼──────────┘ 76 + │ │ 77 + ▼ │ 78 + ┌─────────────────────────────────────────────────┼──────────┐ 79 + │ Background Script (background.ts) │ │ 80 + │ - Context menu registration │ │ 81 + │ - Extension icon click handler │ │ 82 + │ - Auth state change broadcast │ │ 83 + │ - (Currently minimal state management) │ │ 84 + └────────┬────────────────────────────────────────┘ │ 85 + │ │ 86 + ▼ │ 87 + ┌───────────────────────────────────────────────────┐ │ 88 + │ Sidepanel (sidepanel/main.ts) │ │ 89 + │ - Login UI (AT Protocol OAuth) │────────┘ 90 + │ - Annotation form (create/edit) │ 91 + │ - Annotation list (with comments/replies) │ 92 + │ - Profile menu │ 93 + └────────┬──────────────────────────────────────────┘ 94 + │ XRPC calls 95 + 96 + ┌────────────────────────────────────────────────────────────┐ 97 + │ AT Protocol / Bluesky Infrastructure │ 98 + │ - Personal Data Server (PDS) │ 99 + │ - OAuth authorization │ 100 + │ - Record storage (annotations, comments) │ 101 + └────────────────────────────────────────────────────────────┘ 102 + 103 + 104 + ┌────────────────────────────────────────────────────────────┐ 105 + │ slices.network (GraphQL API) │ 106 + │ - Public annotation feed aggregation │ 107 + │ - Used by landing page (landing.js) │ 108 + └────────────────────────────────────────────────────────────┘ 109 + ``` 110 + 111 + ### Component Relationships 112 + 113 + ```mermaid 114 + graph TB 115 + User[User] 116 + WebPage[Web Page] 117 + Content[Content Script] 118 + Background[Background Script] 119 + Sidepanel[Sidepanel UI] 120 + OAuth[OAuth Module] 121 + PDS[PDS Module] 122 + Selectors[Selector System] 123 + Highlights[Highlight System] 124 + ATProto[AT Protocol PDS] 125 + SlicesNet[slices.network] 126 + Landing[Landing Page] 127 + 128 + User -->|selects text| WebPage 129 + WebPage -->|mouseup event| Content 130 + Content -->|generates| Selectors 131 + Content -->|sends SELECTION_CHANGED| Sidepanel 132 + Content -->|renders| Highlights 133 + 134 + User -->|clicks icon| Background 135 + Background -->|opens| Sidepanel 136 + Background -->|registers| ContextMenu[Context Menu] 137 + 138 + Sidepanel -->|requests GET_SELECTION| Content 139 + Sidepanel -->|uses| OAuth 140 + Sidepanel -->|uses| PDS 141 + 142 + OAuth -->|XRPC calls| ATProto 143 + PDS -->|XRPC calls| ATProto 144 + 145 + ATProto -->|indexed by| SlicesNet 146 + SlicesNet -->|GraphQL| Landing 147 + 148 + User -->|visits seams.so| Landing 149 + ``` 150 + 151 + --- 152 + 153 + ## Component Deep Dive 154 + 155 + ### 1. Background Script (`entrypoints/background.ts`) 156 + 157 + **Purpose**: Service Worker that handles extension-wide events and initialization. 158 + 159 + **Responsibilities**: 160 + - **Context Menu**: Registers "Annotate" option on text selection 161 + - **Icon Click**: Opens sidepanel when extension icon is clicked (Chrome only) 162 + - **Auth Broadcast**: Relays authentication state changes between components 163 + - **Logout Handler**: Clears OAuth session on logout 164 + 165 + **Key Functions**: 166 + ```typescript 167 + // Context menu creation (on install) 168 + browser.contextMenus.create({ 169 + id: 'annotate-selection', 170 + title: 'Annotate', 171 + contexts: ['selection'], 172 + }); 173 + 174 + // Context menu click → save annotation 175 + browser.contextMenus.onClicked.addListener((info, tab) => { 176 + if (info.menuItemId === 'annotate-selection') { 177 + browser.tabs.sendMessage(tab.id, { type: 'SAVE_ANNOTATION' }); 178 + } 179 + }); 180 + 181 + // Extension icon click → open sidepanel 182 + browser.action.onClicked.addListener((tab) => { 183 + if (browser.sidePanel) { 184 + browser.sidePanel.open({ tabId: tab.id }); 185 + } 186 + }); 187 + ``` 188 + 189 + **Current Limitations**: 190 + - No centralized state management (planned in refactor) 191 + - No URL change tracking 192 + - No per-tab state storage 193 + 194 + --- 195 + 196 + ### 2. Content Script (`entrypoints/content.ts`) 197 + 198 + **Purpose**: Runs on every web page to track selections and render highlights. 199 + 200 + **Lifecycle**: Injected at `document_idle` (after DOM loads) 201 + 202 + **State**: 203 + ```typescript 204 + let currentSelection: { 205 + text: string; 206 + selectors: Selector[]; 207 + } | null = null; 208 + 209 + let currentAnnotations: Annotation[] = []; 210 + ``` 211 + 212 + **Event Handlers**: 213 + 214 + #### Text Selection Tracking 215 + ```typescript 216 + document.addEventListener('mouseup', () => { 217 + const selection = window.getSelection(); 218 + if (selection && selection.toString().trim().length > 0) { 219 + const text = selection.toString().trim(); 220 + const root = document.querySelector('main') || 221 + document.querySelector('article') || 222 + document.body; 223 + const selectors = generateSelectors(selection, root); 224 + 225 + currentSelection = { text, selectors }; 226 + 227 + // Notify sidepanel 228 + browser.runtime.sendMessage({ 229 + type: 'SELECTION_CHANGED', 230 + data: { text, selectors } 231 + }); 232 + } else { 233 + currentSelection = null; 234 + } 235 + }); 236 + ``` 237 + 238 + **Why `mouseup`?** 239 + - `selectionchange` fires too frequently (on every character) 240 + - `mouseup` captures the final selection after user releases mouse 241 + - Combined with `keyup` for keyboard selections (not currently implemented) 242 + 243 + #### Message Handlers 244 + 245 + **GET_SELECTION** (from sidepanel): 246 + ```typescript 247 + // Sidepanel requests current state on load 248 + if (message.type === 'GET_SELECTION') { 249 + sendResponse({ 250 + selection: currentSelection, 251 + url: window.location.href, 252 + title: document.title 253 + }); 254 + return true; 255 + } 256 + ``` 257 + 258 + **UPDATE_HIGHLIGHTS** (from sidepanel): 259 + ```typescript 260 + // Sidepanel sends annotations after creating/loading 261 + if (message.type === 'UPDATE_HIGHLIGHTS') { 262 + currentAnnotations = message.annotations || []; 263 + applyHighlights(currentAnnotations); // Render yellow highlights 264 + sendResponse({ success: true }); 265 + return true; 266 + } 267 + ``` 268 + 269 + **CLEAR_HIGHLIGHTS**: 270 + ```typescript 271 + if (message.type === 'CLEAR_HIGHLIGHTS') { 272 + clearHighlights(); 273 + currentAnnotations = []; 274 + sendResponse({ success: true }); 275 + return true; 276 + } 277 + ``` 278 + 279 + **Known Issues**: 280 + - No debouncing on selection events (can spam messages) 281 + - Selection inside `<input>`, `<textarea>`, or `contenteditable` not filtered 282 + - No handling of SPA navigation (URL changes without page reload) 283 + - Race condition: sidepanel may request state before content script is ready 284 + 285 + --- 286 + 287 + ### 3. Sidepanel UI (`entrypoints/sidepanel/main.ts`) 288 + 289 + **Purpose**: Main user interface for authentication, creating annotations, and viewing annotations. 290 + 291 + **UI Sections**: 292 + 1. **Auth Section** (login form) 293 + 2. **Content Section** (annotation form + list) 294 + 3. **Profile Menu** (avatar dropdown with logout) 295 + 296 + #### State Management 297 + 298 + ```typescript 299 + let currentUrl = ''; // Current page URL 300 + let currentSelection: { text: string; selectors: any[] } | null = null; 301 + let allComments: Comment[] = []; // All comments for current page 302 + const collapsedThreads = new Set<string>(); // Collapsed comment threads 303 + const activeReplyForms = new Set<string>(); // Active reply forms 304 + ``` 305 + 306 + #### Initialization Flow 307 + 308 + ```typescript 309 + // 1. Check for existing OAuth session 310 + loadSession().then(async session => { 311 + if (session) { 312 + // Fetch profile and show logged-in UI 313 + const profile = await getProfile(session); 314 + profileAvatar.src = profile.avatar; 315 + authSection.style.display = 'none'; 316 + contentSection.style.display = 'block'; 317 + } 318 + }); 319 + 320 + // 2. Get current page state from content script 321 + browser.tabs.query({ active: true, currentWindow: true }).then(tabs => { 322 + if (tabs[0]?.id) { 323 + browser.tabs.sendMessage(tabs[0].id, { type: 'GET_SELECTION' }) 324 + .then(response => { 325 + currentUrl = response.url; 326 + currentSelection = response.selection; 327 + 328 + if (currentSelection) { 329 + // Show annotation form with selected text 330 + annotationForm.style.display = 'block'; 331 + } 332 + 333 + loadAnnotations(currentUrl); // Fetch annotations for this page 334 + }); 335 + } 336 + }); 337 + ``` 338 + 339 + **Race Condition**: `GET_SELECTION` may fail if content script hasn't loaded yet. 340 + 341 + #### Login Flow 342 + 343 + ```typescript 344 + loginBtn.addEventListener('click', async () => { 345 + let handle = handleInput.value.trim(); 346 + 347 + // Strip @ prefix if present 348 + if (handle.startsWith('@')) { 349 + handle = handle.slice(1); 350 + } 351 + 352 + // Start OAuth flow 353 + await startLoginProcess(handle); 354 + 355 + // Reload session and show logged-in UI 356 + const session = await loadSession(); 357 + if (session) { 358 + const profile = await getProfile(session); 359 + profileAvatar.src = profile.avatar; 360 + authSection.style.display = 'none'; 361 + contentSection.style.display = 'block'; 362 + } 363 + }); 364 + ``` 365 + 366 + #### Creating Annotations 367 + 368 + ```typescript 369 + saveBtn.addEventListener('click', async () => { 370 + if (!currentSelection) { 371 + alert('Please select text on the page first'); 372 + return; 373 + } 374 + 375 + const body = annotationTextarea.value.trim(); 376 + 377 + const annotation: Annotation = { 378 + $type: 'community.lexicon.annotation.annotation', 379 + target: [{ 380 + source: currentUrl, // BUG: Can be empty if race condition occurs 381 + selector: currentSelection.selectors 382 + }], 383 + body: body || undefined, 384 + createdAt: new Date().toISOString() 385 + }; 386 + 387 + // Save locally (legacy - for backward compatibility) 388 + await saveAnnotationLocal(annotation); 389 + 390 + // Save to PDS (AT Protocol) 391 + try { 392 + const savedAnnotation = await createAnnotation(annotation); 393 + console.log('Annotation saved to PDS:', savedAnnotation); 394 + } catch (error) { 395 + console.error('Failed to save to PDS:', error); 396 + } 397 + 398 + // Reload annotations and update highlights 399 + await loadAnnotations(currentUrl); 400 + }); 401 + ``` 402 + 403 + **Bug**: `currentUrl` can be empty string if `GET_SELECTION` fails, resulting in annotations with empty `source` field. 404 + 405 + #### Loading Annotations 406 + 407 + ```typescript 408 + async function loadAnnotations(url: string) { 409 + try { 410 + // Fetch from PDS 411 + const allAnnotations = await listAnnotations(); 412 + allComments = await listComments(); 413 + 414 + // Filter by current URL 415 + pageAnnotations = allAnnotations.filter(ann => 416 + ann.target[0]?.source === url 417 + ); 418 + 419 + renderAnnotations(); // Update UI 420 + 421 + // Send highlights to content script 422 + await updateHighlights(pageAnnotations); 423 + } catch (error) { 424 + console.error('Failed to load annotations from PDS:', error); 425 + } 426 + } 427 + ``` 428 + 429 + #### Comment System 430 + 431 + **Comment Structure**: 432 + ```typescript 433 + interface Comment { 434 + $type: 'pub.leaflet.comment'; 435 + subject: string; // URI of annotation being commented on 436 + plaintext: string; // Comment text 437 + createdAt: string; 438 + reply?: { 439 + parent: string; // URI of parent comment (for threaded replies) 440 + }; 441 + uri?: string; // AT Protocol record URI 442 + cid?: string; // Content ID 443 + } 444 + ``` 445 + 446 + **Comment Threading**: 447 + - Top-level comments: `subject` points to annotation URI, no `reply` field 448 + - Replies: `subject` still points to annotation URI, `reply.parent` points to parent comment URI 449 + 450 + **Rendering Logic**: 451 + ```typescript 452 + // Build threaded comment tree 453 + function buildCommentThread(parentUri: string, parentHasReplies: boolean): string { 454 + const replies = allComments.filter(c => c.reply?.parent === parentUri); 455 + 456 + if (collapsedThreads.has(parentUri)) { 457 + return `<button>▸ ${replies.length} hidden</button>`; 458 + } 459 + 460 + return ` 461 + <div class="comment-thread"> 462 + ${replies.map(comment => ` 463 + <div class="comment"> 464 + <div class="comment-text">${comment.plaintext}</div> 465 + <button class="reply-btn">Reply</button> 466 + ${hasReplies ? buildCommentThread(comment.uri!, true) : ''} 467 + </div> 468 + `).join('')} 469 + </div> 470 + `; 471 + } 472 + ``` 473 + 474 + **Reply Creation**: 475 + ```typescript 476 + // Save reply button handler 477 + document.querySelectorAll('.save-reply-btn').forEach(btn => { 478 + btn.addEventListener('click', async (e) => { 479 + const form = e.target.closest('.reply-form'); 480 + const textarea = form.querySelector('.reply-input'); 481 + const parent = form.getAttribute('data-parent'); // Parent comment URI 482 + const plaintext = textarea.value.trim(); 483 + 484 + // Find parent comment to get subject (annotation URI) 485 + const parentComment = allComments.find(c => c.uri === parent); 486 + 487 + await createComment({ 488 + $type: 'pub.leaflet.comment', 489 + subject: parentComment.subject, // Still points to annotation 490 + plaintext, 491 + createdAt: new Date().toISOString(), 492 + reply: { parent }, // Link to parent comment 493 + }); 494 + 495 + await loadAnnotations(currentUrl); // Refresh 496 + }); 497 + }); 498 + ``` 499 + 500 + --- 501 + 502 + ## Data Flow 503 + 504 + ### Annotation Creation Flow 505 + 506 + ``` 507 + 1. User selects text on page 508 + 509 + 510 + 2. Content Script (mouseup handler) 511 + - Extracts selection text 512 + - Generates selectors (TextQuote + TextPosition) 513 + - Stores in currentSelection 514 + 515 + 516 + 3. Content Script → Sidepanel 517 + - Sends: { type: 'SELECTION_CHANGED', data: { text, selectors } } 518 + 519 + 520 + 4. Sidepanel receives message 521 + - Updates currentSelection 522 + - Shows annotation form with quoted text 523 + 524 + 525 + 5. User writes note and clicks "Save" 526 + 527 + 528 + 6. Sidepanel (saveBtn handler) 529 + - Creates Annotation object with: 530 + * target[0].source = currentUrl 531 + * target[0].selector = currentSelection.selectors 532 + * body = note text 533 + 534 + 535 + 7. Sidepanel → PDS Module 536 + - Calls: createAnnotation(annotation) 537 + 538 + 539 + 8. PDS Module → AT Protocol 540 + - XRPC call: com.atproto.repo.createRecord 541 + - Collection: community.lexicon.annotation.annotation 542 + - Returns: { uri, cid } 543 + 544 + 545 + 9. Sidepanel reloads annotations 546 + - Calls: listAnnotations() from PDS 547 + - Filters by currentUrl 548 + 549 + 550 + 10. Sidepanel → Content Script 551 + - Sends: { type: 'UPDATE_HIGHLIGHTS', annotations } 552 + 553 + 554 + 11. Content Script applies highlights 555 + - For each annotation: 556 + * Find DOM range using selectors (match.ts) 557 + * Wrap in <span class="synthesis-highlight"> 558 + * Add click handler to show popover 559 + ``` 560 + 561 + ### Page Load Flow 562 + 563 + ``` 564 + 1. Extension loads on page 565 + 566 + 567 + 2. Content Script initializes 568 + - Sets up mouseup listener 569 + - Waits for selection events 570 + 571 + 572 + 3. User clicks extension icon 573 + 574 + 575 + 4. Background Script (action.onClicked) 576 + - Opens sidepanel: browser.sidePanel.open() 577 + 578 + 579 + 5. Sidepanel loads (main.ts IIFE) 580 + - Checks for OAuth session 581 + - If logged in: show content section 582 + - If not: show login form 583 + 584 + 585 + 6. Sidepanel → Content Script 586 + - Sends: { type: 'GET_SELECTION' } 587 + - Receives: { url, title, selection } 588 + 589 + 590 + 7. Sidepanel stores state 591 + - currentUrl = response.url 592 + - currentSelection = response.selection 593 + - If selection exists: show annotation form 594 + 595 + 596 + 8. Sidepanel loads annotations 597 + - Calls: listAnnotations() + listComments() 598 + - Filters by currentUrl 599 + - Renders annotation cards with comments 600 + 601 + 602 + 9. Sidepanel → Content Script 603 + - Sends: { type: 'UPDATE_HIGHLIGHTS', annotations } 604 + 605 + 606 + 10. Content Script renders highlights 607 + - Yellow highlighted text appears on page 608 + - Click highlight → show popover 609 + ``` 610 + 611 + --- 612 + 613 + ## Message Passing Protocol 614 + 615 + ### Current Messages (Fragile Architecture) 616 + 617 + #### Content Script → Sidepanel 618 + | Message Type | Direction | Payload | Purpose | 619 + |--------------|-----------|---------|---------| 620 + | `SELECTION_CHANGED` | Content → Sidepanel | `{ text, selectors }` | Notify sidepanel of new selection | 621 + 622 + **How it works**: `browser.runtime.sendMessage()` broadcasts to all extension contexts. Sidepanel has a listener that updates its state. 623 + 624 + #### Sidepanel → Content Script 625 + | Message Type | Direction | Payload | Purpose | 626 + |--------------|-----------|---------|---------| 627 + | `GET_SELECTION` | Sidepanel → Content | - | Request current page state | 628 + | `UPDATE_HIGHLIGHTS` | Sidepanel → Content | `{ annotations }` | Render highlights on page | 629 + | `CLEAR_HIGHLIGHTS` | Sidepanel → Content | - | Remove all highlights | 630 + 631 + **How it works**: `browser.tabs.sendMessage(tabId, message)` sends to specific tab's content script. 632 + 633 + **Response format for GET_SELECTION**: 634 + ```typescript 635 + { 636 + selection: { text: string; selectors: Selector[] } | null, 637 + url: string, 638 + title: string 639 + } 640 + ``` 641 + 642 + #### Background Script Messages 643 + | Message Type | Direction | Payload | Purpose | 644 + |--------------|-----------|---------|---------| 645 + | `AUTH_STATE_CHANGED` | Background → All | `{ isAuthenticated: boolean }` | Broadcast auth state | 646 + | `LOGOUT` | Any → Background | - | Request logout | 647 + 648 + --- 649 + 650 + ## Authentication System 651 + 652 + ### AT Protocol OAuth Flow 653 + 654 + **Library**: `@atcute/oauth-browser-client` 655 + 656 + **OAuth Configuration**: 657 + ```typescript 658 + // lib/oauth.ts 659 + configureOAuth({ 660 + metadata: { 661 + client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 662 + redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI, 663 + }, 664 + }); 665 + ``` 666 + 667 + **Redirect Strategy**: 668 + - AT Protocol OAuth expects a web redirect URL 669 + - Extension uses `https://synthes-is.netlify.app/oauth-callback.html` 670 + - Callback page relays to `chromiumapp://` URL for extension capture 671 + 672 + ### Login Process 673 + 674 + ``` 675 + 1. User enters handle (e.g., "user.bsky.social") 676 + 677 + 678 + 2. Sidepanel → OAuth Module 679 + - Calls: startLoginProcess(handle) 680 + 681 + 682 + 3. OAuth Module 683 + - Calls: resolveFromIdentity(handle) 684 + - Returns: PDS metadata (authorization server URL, etc.) 685 + 686 + 687 + 4. OAuth Module 688 + - Calls: createAuthorizationUrl({ metadata, scope }) 689 + - Returns: Authorization URL (e.g., https://bsky.social/oauth/authorize?...) 690 + 691 + 692 + 5. OAuth Module → Browser 693 + - Calls: browser.identity.launchWebAuthFlow({ url, interactive: true }) 694 + - Opens popup with authorization page 695 + 696 + 697 + 6. User authorizes in popup 698 + 699 + 700 + 7. PDS redirects to: https://synthes-is.netlify.app/oauth-callback.html?code=...&state=... 701 + 702 + 703 + 8. Callback page redirects to: chromiumapp://[extension-id]/extension-callback.html?code=... 704 + 705 + 706 + 9. browser.identity.launchWebAuthFlow captures redirect 707 + - Returns: capturedUrl 708 + 709 + 710 + 10. OAuth Module 711 + - Parses OAuth params from URL (code, state, etc.) 712 + - Calls: finalizeAuthorization(params) 713 + - Exchanges code for tokens 714 + - Returns: OAuthSession { info: { sub: "did:plc:..." }, ... } 715 + 716 + 717 + 11. OAuth Module 718 + - Saves session to: browser.storage.local['synthesis-oauth:session'] 719 + 720 + 721 + 12. Sidepanel 722 + - Fetches profile: getProfile(session) 723 + - Shows logged-in UI 724 + ``` 725 + 726 + ### Session Management 727 + 728 + **Storage Key**: `synthesis-oauth:session` 729 + 730 + **Session Structure**: 731 + ```typescript 732 + interface OAuthSession { 733 + info: { 734 + sub: string; // DID (e.g., "did:plc:abc123...") 735 + // ... other fields 736 + }; 737 + // ... tokens and metadata 738 + } 739 + ``` 740 + 741 + **Session Functions**: 742 + ```typescript 743 + // Save session after login 744 + async function saveSession(session: OAuthSession) { 745 + await browser.storage.local.set({ 746 + 'synthesis-oauth:session': session 747 + }); 748 + } 749 + 750 + // Load session on startup 751 + async function loadSession(): Promise<OAuthSession | null> { 752 + const result = await browser.storage.local.get('synthesis-oauth:session'); 753 + return result['synthesis-oauth:session'] || null; 754 + } 755 + 756 + // Clear session on logout 757 + async function clearSession() { 758 + await browser.storage.local.remove('synthesis-oauth:session'); 759 + } 760 + 761 + // Get user profile 762 + async function getProfile(session: OAuthSession) { 763 + const agent = new OAuthUserAgent(session); 764 + const response = await agent.handle( 765 + '/xrpc/app.bsky.actor.getProfile?actor=' + session.info.sub 766 + ); 767 + return await response.json(); 768 + } 769 + ``` 770 + 771 + --- 772 + 773 + ## Annotation Lifecycle 774 + 775 + ### Data Model 776 + 777 + **AT Protocol Record Type**: `community.lexicon.annotation.annotation` 778 + 779 + **TypeScript Interface**: 780 + ```typescript 781 + interface Annotation { 782 + $type: 'community.lexicon.annotation.annotation'; 783 + target: Target[]; // What is being annotated 784 + body?: string; // Annotation note/comment 785 + tags?: string[]; // Tags for categorization 786 + document?: { 787 + title?: string; 788 + canonicalUri?: string; 789 + }; 790 + createdAt: string; // ISO 8601 timestamp 791 + 792 + // ATProto metadata (added after creation) 793 + uri?: string; // at://did:plc:abc123/collection/rkey 794 + cid?: string; // Content ID (hash) 795 + author?: { 796 + did: string; 797 + handle: string; 798 + displayName?: string; 799 + avatar?: string; 800 + }; 801 + } 802 + 803 + interface Target { 804 + source: string; // Page URL 805 + selector?: Selector[]; // How to find the text 806 + } 807 + ``` 808 + 809 + ### Selector Types 810 + 811 + **TextQuoteSelector** (Most robust - survives content changes): 812 + ```typescript 813 + { 814 + $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 815 + exact: "the selected text", 816 + prefix: "text before ", // Context before selection 817 + suffix: " text after" // Context after selection 818 + } 819 + ``` 820 + 821 + **TextPositionSelector** (Precise but fragile): 822 + ```typescript 823 + { 824 + $type: 'community.lexicon.annotation.annotation#textPositionSelector', 825 + start: 1234, // Character offset in document 826 + end: 1298 // Character offset in document 827 + } 828 + ``` 829 + 830 + **Strategy**: Generate both types. Try TextPositionSelector first (fast), fall back to TextQuoteSelector (slower but more robust). 831 + 832 + ### CRUD Operations 833 + 834 + All operations go through `lib/pds.ts` using `@atcute/oauth-browser-client`. 835 + 836 + #### Create Annotation 837 + ```typescript 838 + // lib/pds.ts 839 + async function createAnnotation(annotation: Annotation): Promise<Annotation> { 840 + const session = await loadSession(); 841 + const agent = new OAuthUserAgent(session); 842 + 843 + const response = await agent.handle('/xrpc/com.atproto.repo.createRecord', { 844 + method: 'POST', 845 + headers: { 'Content-Type': 'application/json' }, 846 + body: JSON.stringify({ 847 + repo: session.info.sub, // User's DID 848 + collection: 'community.lexicon.annotation.annotation', 849 + record: { 850 + $type: annotation.$type, 851 + target: annotation.target, 852 + body: annotation.body, 853 + tags: annotation.tags, 854 + document: annotation.document, 855 + createdAt: annotation.createdAt, 856 + }, 857 + }), 858 + }); 859 + 860 + const result = await response.json(); 861 + 862 + return { 863 + ...annotation, 864 + uri: result.uri, // at://did:plc:abc123/collection/rkey 865 + cid: result.cid, 866 + }; 867 + } 868 + ``` 869 + 870 + #### List Annotations 871 + ```typescript 872 + async function listAnnotations(): Promise<Annotation[]> { 873 + const session = await loadSession(); 874 + const agent = new OAuthUserAgent(session); 875 + 876 + const response = await agent.handle( 877 + `/xrpc/com.atproto.repo.listRecords?` + 878 + `repo=${session.info.sub}&` + 879 + `collection=community.lexicon.annotation.annotation`, 880 + { method: 'GET' } 881 + ); 882 + 883 + const result = await response.json(); 884 + 885 + return result.records.map((record: any) => ({ 886 + ...record.value, 887 + uri: record.uri, 888 + cid: record.cid, 889 + })); 890 + } 891 + ``` 892 + 893 + #### Delete Annotation 894 + ```typescript 895 + async function deleteAnnotation(uri: string): Promise<void> { 896 + const session = await loadSession(); 897 + const agent = new OAuthUserAgent(session); 898 + 899 + // Parse rkey from URI: at://did:plc:xxx/collection/rkey 900 + const rkey = uri.split("/").pop(); 901 + 902 + await agent.handle('/xrpc/com.atproto.repo.deleteRecord', { 903 + method: 'POST', 904 + headers: { 'Content-Type': 'application/json' }, 905 + body: JSON.stringify({ 906 + repo: session.info.sub, 907 + collection: 'community.lexicon.annotation.annotation', 908 + rkey, 909 + }), 910 + }); 911 + } 912 + ``` 913 + 914 + --- 915 + 916 + ## Highlight System 917 + 918 + ### Rendering Pipeline 919 + 920 + ``` 921 + Annotation → Selector Matching → DOM Range → Highlight Span 922 + ``` 923 + 924 + ### Step 1: Find DOM Range 925 + 926 + **File**: `lib/selectors/match.ts` 927 + 928 + ```typescript 929 + function findAnnotationRange( 930 + annotation: Annotation, 931 + container: HTMLElement = document.body 932 + ): Range | null { 933 + const selectors = annotation.target?.[0]?.selector; 934 + 935 + // Try each selector in order 936 + for (const selector of selectors) { 937 + let range: Range | null = null; 938 + 939 + switch (selector.$type) { 940 + case 'community.lexicon.annotation.annotation#textPositionSelector': 941 + range = matchTextPositionSelector(selector, container); 942 + break; 943 + case 'community.lexicon.annotation.annotation#textQuoteSelector': 944 + range = matchTextQuoteSelector(selector, container); 945 + break; 946 + } 947 + 948 + if (range) return range; 949 + } 950 + 951 + return null; 952 + } 953 + ``` 954 + 955 + **TextPositionSelector Matching**: 956 + ```typescript 957 + function matchTextPositionSelector( 958 + selector: TextPositionSelector, 959 + container: HTMLElement 960 + ): Range | null { 961 + try { 962 + // Uses dom-anchor-text-position library 963 + return textPosition.toRange(container, selector); 964 + } catch (e) { 965 + return null; 966 + } 967 + } 968 + ``` 969 + 970 + **TextQuoteSelector Matching**: 971 + ```typescript 972 + function matchTextQuoteSelector( 973 + selector: TextQuoteSelector, 974 + container: HTMLElement 975 + ): Range | null { 976 + try { 977 + // Uses dom-anchor-text-quote library 978 + return textQuote.toRange(container, selector); 979 + } catch (e) { 980 + return null; 981 + } 982 + } 983 + ``` 984 + 985 + ### Step 2: Apply Highlight 986 + 987 + **File**: `lib/highlights/apply.ts` 988 + 989 + ```typescript 990 + function highlightRange(range: Range, annotation: Annotation) { 991 + // Create highlight span 992 + const highlight = document.createElement('span'); 993 + highlight.className = 'synthesis-highlight'; 994 + highlight.dataset.annotationId = annotation.uri || annotation.createdAt; 995 + highlight.style.cssText = ` 996 + background-color: rgba(255, 235, 59, 0.6) !important; 997 + cursor: pointer !important; 998 + transition: background-color 0.2s !important; 999 + `; 1000 + 1001 + // Hover effect 1002 + highlight.addEventListener('mouseenter', () => { 1003 + highlight.style.backgroundColor = 'rgba(255, 235, 59, 0.8)'; 1004 + }); 1005 + 1006 + highlight.addEventListener('mouseleave', () => { 1007 + highlight.style.backgroundColor = 'rgba(255, 235, 59, 0.6)'; 1008 + }); 1009 + 1010 + // Click to show popover 1011 + highlight.addEventListener('click', (e) => { 1012 + e.preventDefault(); 1013 + e.stopPropagation(); 1014 + showAnnotationPopover(annotation, highlight, onSave, onDelete); 1015 + }); 1016 + 1017 + // Wrap the range content 1018 + const contents = range.extractContents(); // Extract nodes from DOM 1019 + highlight.appendChild(contents); // Put them in highlight span 1020 + range.insertNode(highlight); // Insert span back into DOM 1021 + } 1022 + ``` 1023 + 1024 + **Why `extractContents` instead of `surroundContents`?** 1025 + - `surroundContents` fails if the range spans multiple elements (e.g., `<p>some <strong>bold</strong> text</p>`) 1026 + - `extractContents` + `appendChild` + `insertNode` works for all cases 1027 + 1028 + ### Step 3: Clear Highlights 1029 + 1030 + ```typescript 1031 + function clearHighlights(container: HTMLElement = document.body) { 1032 + const highlights = container.querySelectorAll('.synthesis-highlight'); 1033 + 1034 + highlights.forEach(highlight => { 1035 + const parent = highlight.parentNode; 1036 + if (parent) { 1037 + // Move children back to parent (unwrap) 1038 + while (highlight.firstChild) { 1039 + parent.insertBefore(highlight.firstChild, highlight); 1040 + } 1041 + // Remove empty span 1042 + parent.removeChild(highlight); 1043 + // Merge adjacent text nodes 1044 + parent.normalize(); 1045 + } 1046 + }); 1047 + } 1048 + ``` 1049 + 1050 + ### Annotation Popover 1051 + 1052 + **File**: `lib/highlights/popover.ts` 1053 + 1054 + **Purpose**: Show edit/delete UI when clicking a highlight. 1055 + 1056 + ```typescript 1057 + function showAnnotationPopover( 1058 + annotation: Annotation, 1059 + targetElement: HTMLElement, 1060 + onSave: (updatedAnnotation: Annotation) => void, 1061 + onDelete: () => void 1062 + ) { 1063 + const popover = document.createElement('div'); 1064 + popover.className = 'synthesis-popover'; 1065 + 1066 + // Position below the highlight 1067 + const rect = targetElement.getBoundingClientRect(); 1068 + popover.style.left = `${rect.left + window.scrollX}px`; 1069 + popover.style.top = `${rect.bottom + window.scrollY + 5}px`; 1070 + 1071 + // Show quoted text + editable note 1072 + const quote = annotation.target[0]?.selector?.find( 1073 + s => s.$type === 'community.lexicon.annotation.annotation#textQuoteSelector' 1074 + ); 1075 + 1076 + popover.innerHTML = ` 1077 + <div class="quote">${quote?.exact || ''}</div> 1078 + <textarea class="note-input">${annotation.body || ''}</textarea> 1079 + <button class="save-btn">Save</button> 1080 + <button class="delete-btn">Delete</button> 1081 + `; 1082 + 1083 + document.body.appendChild(popover); 1084 + 1085 + // Save handler 1086 + saveBtn.addEventListener('click', () => { 1087 + const updatedAnnotation = { 1088 + ...annotation, 1089 + body: textarea.value.trim() || undefined, 1090 + }; 1091 + onSave(updatedAnnotation); 1092 + hidePopover(); 1093 + }); 1094 + 1095 + // Delete handler 1096 + deleteBtn.addEventListener('click', () => { 1097 + if (confirm('Delete this annotation?')) { 1098 + onDelete(); 1099 + hidePopover(); 1100 + } 1101 + }); 1102 + } 1103 + ``` 1104 + 1105 + **Update Flow (from popover)**: 1106 + ```typescript 1107 + // In lib/highlights/apply.ts 1108 + showAnnotationPopover( 1109 + annotation, 1110 + highlight, 1111 + // onSave callback 1112 + async (updatedAnnotation) => { 1113 + // Update in local storage (legacy) 1114 + const stored = await browser.storage.local.get('annotations'); 1115 + const annotations = stored.annotations || []; 1116 + const index = annotations.findIndex(a => a.createdAt === annotation.createdAt); 1117 + if (index !== -1) { 1118 + annotations[index] = updatedAnnotation; 1119 + await browser.storage.local.set({ annotations }); 1120 + } 1121 + 1122 + // Update in-memory object (so next click shows updated note) 1123 + Object.assign(annotation, updatedAnnotation); 1124 + }, 1125 + // onDelete callback 1126 + async () => { 1127 + // Remove from local storage 1128 + const stored = await browser.storage.local.get('annotations'); 1129 + const filtered = annotations.filter(a => a.createdAt !== annotation.createdAt); 1130 + await browser.storage.local.set({ annotations: filtered }); 1131 + 1132 + // Remove highlight from DOM 1133 + highlight.remove(); 1134 + } 1135 + ); 1136 + ``` 1137 + 1138 + **Note**: Popover save/delete only updates `browser.storage.local`, not the PDS. This is a bug/limitation. 1139 + 1140 + --- 1141 + 1142 + ## Selector System 1143 + 1144 + ### Generation (`lib/selectors/generate.ts`) 1145 + 1146 + **Purpose**: Convert a DOM Selection into portable selectors. 1147 + 1148 + ```typescript 1149 + function generateSelectors(selection: Selection, root?: HTMLElement): Selector[] { 1150 + if (!selection.rangeCount) return []; 1151 + 1152 + const range = selection.getRangeAt(0); 1153 + const container = root || document.body; 1154 + const selectors: Selector[] = []; 1155 + 1156 + // Generate TextQuoteSelector (robust) 1157 + const textQuoteSelector = generateTextQuoteSelector(range, container); 1158 + if (textQuoteSelector) selectors.push(textQuoteSelector); 1159 + 1160 + // Generate TextPositionSelector (precise) 1161 + const textPositionSelector = generateTextPositionSelector(range, container); 1162 + if (textPositionSelector) selectors.push(textPositionSelector); 1163 + 1164 + return selectors; 1165 + } 1166 + ``` 1167 + 1168 + **TextQuoteSelector Generation**: 1169 + ```typescript 1170 + function generateTextQuoteSelector( 1171 + range: Range, 1172 + root: HTMLElement 1173 + ): TextQuoteSelector | null { 1174 + const exact = range.toString().trim(); 1175 + if (!exact) return null; 1176 + 1177 + try { 1178 + // Uses dom-anchor-text-quote library 1179 + const selector = textQuote.fromRange(root, range); 1180 + 1181 + return { 1182 + $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 1183 + exact: selector.exact, 1184 + prefix: selector.prefix || undefined, 1185 + suffix: selector.suffix || undefined 1186 + }; 1187 + } catch (error) { 1188 + console.warn('Failed to generate TextQuoteSelector:', error); 1189 + return null; 1190 + } 1191 + } 1192 + ``` 1193 + 1194 + **How it works**: 1195 + - `exact`: The selected text itself 1196 + - `prefix`: 32 characters before the selection (for context) 1197 + - `suffix`: 32 characters after the selection (for context) 1198 + 1199 + **Example**: 1200 + ``` 1201 + Page text: "The quick brown fox jumps over the lazy dog." 1202 + Selection: "brown fox" 1203 + 1204 + TextQuoteSelector: 1205 + { 1206 + exact: "brown fox", 1207 + prefix: "The quick ", 1208 + suffix: " jumps over" 1209 + } 1210 + ``` 1211 + 1212 + **TextPositionSelector Generation**: 1213 + ```typescript 1214 + function generateTextPositionSelector( 1215 + range: Range, 1216 + root: HTMLElement 1217 + ): TextPositionSelector | null { 1218 + const exact = range.toString().trim(); 1219 + if (!exact) return null; 1220 + 1221 + try { 1222 + // Uses dom-anchor-text-position library 1223 + const selector = textPosition.fromRange(root, range); 1224 + 1225 + return { 1226 + $type: 'community.lexicon.annotation.annotation#textPositionSelector', 1227 + start: selector.start, 1228 + end: selector.end 1229 + }; 1230 + } catch (error) { 1231 + console.warn('Failed to generate TextPositionSelector:', error); 1232 + return null; 1233 + } 1234 + } 1235 + ``` 1236 + 1237 + **How it works**: 1238 + - `start`: Character offset from start of container's text content 1239 + - `end`: Character offset from start of container's text content 1240 + 1241 + **Example**: 1242 + ``` 1243 + Page text: "The quick brown fox jumps over the lazy dog." 1244 + Selection: "brown fox" 1245 + 1246 + TextPositionSelector: 1247 + { 1248 + start: 10, // 0-indexed position of "b" in "brown" 1249 + end: 19 // 0-indexed position after "x" in "fox" 1250 + } 1251 + ``` 1252 + 1253 + ### Matching (`lib/selectors/match.ts`) 1254 + 1255 + **Purpose**: Convert selectors back into DOM Range for highlighting. 1256 + 1257 + **Strategy**: 1258 + 1. Try TextPositionSelector first (fast, precise) 1259 + 2. If fails, try TextQuoteSelector (slower, more robust) 1260 + 1261 + **Why this order?** 1262 + - TextPositionSelector is O(n) where n = text length 1263 + - TextQuoteSelector is O(n*m) where m = search window size 1264 + - But TextQuoteSelector works even if content changed slightly 1265 + 1266 + --- 1267 + 1268 + ## Comment/Reply System 1269 + 1270 + ### Data Model 1271 + 1272 + **AT Protocol Record Type**: `pub.leaflet.comment` 1273 + 1274 + **TypeScript Interface**: 1275 + ```typescript 1276 + interface Comment { 1277 + $type: 'pub.leaflet.comment'; 1278 + subject: string; // URI of annotation being commented on 1279 + plaintext: string; // Comment text 1280 + createdAt: string; 1281 + reply?: { 1282 + parent: string; // URI of parent comment (for replies) 1283 + }; 1284 + facets?: RichtextFacet[]; // For mentions, links, hashtags 1285 + onPage?: string; // URL of page (optional) 1286 + 1287 + uri?: string; // at://did:plc:abc123/collection/rkey 1288 + cid?: string; 1289 + author?: { 1290 + did: string; 1291 + handle: string; 1292 + displayName?: string; 1293 + avatar?: string; 1294 + }; 1295 + } 1296 + ``` 1297 + 1298 + ### Comment Hierarchy 1299 + 1300 + ``` 1301 + Annotation (at://did:plc:abc/annotation/123) 1302 + ├─ Comment 1 (subject: annotation URI, no reply) 1303 + │ ├─ Reply 1.1 (subject: annotation URI, reply.parent: comment1 URI) 1304 + │ └─ Reply 1.2 (subject: annotation URI, reply.parent: comment1 URI) 1305 + │ └─ Reply 1.2.1 (subject: annotation URI, reply.parent: reply1.2 URI) 1306 + └─ Comment 2 (subject: annotation URI, no reply) 1307 + ``` 1308 + 1309 + **Key insight**: All comments/replies have the same `subject` (annotation URI). The `reply.parent` field creates the tree structure. 1310 + 1311 + ### Rendering Algorithm 1312 + 1313 + ```typescript 1314 + // Get top-level comments (no reply field) 1315 + const topLevelComments = allComments.filter(c => 1316 + c.subject === annotation.uri && !c.reply 1317 + ); 1318 + 1319 + // Recursively build thread 1320 + function buildCommentThread(parentUri: string): string { 1321 + const replies = allComments.filter(c => c.reply?.parent === parentUri); 1322 + 1323 + if (collapsedThreads.has(parentUri)) { 1324 + return `<button>▸ ${replies.length} hidden</button>`; 1325 + } 1326 + 1327 + return ` 1328 + <div class="comment-thread"> 1329 + ${replies.map(comment => ` 1330 + <div class="comment"> 1331 + ${comment.plaintext} 1332 + <button class="reply-btn">Reply</button> 1333 + ${buildCommentThread(comment.uri!)} <!-- Recursion --> 1334 + </div> 1335 + `).join('')} 1336 + </div> 1337 + `; 1338 + } 1339 + ``` 1340 + 1341 + ### Creating Comments 1342 + 1343 + **Top-level comment**: 1344 + ```typescript 1345 + await createComment({ 1346 + $type: 'pub.leaflet.comment', 1347 + subject: annotationUri, // at://did:plc:abc/annotation/123 1348 + plaintext: "Great annotation!", 1349 + createdAt: new Date().toISOString(), 1350 + // No reply field 1351 + }); 1352 + ``` 1353 + 1354 + **Reply to comment**: 1355 + ```typescript 1356 + await createComment({ 1357 + $type: 'pub.leaflet.comment', 1358 + subject: annotationUri, // Still the annotation URI 1359 + plaintext: "I agree!", 1360 + createdAt: new Date().toISOString(), 1361 + reply: { 1362 + parent: parentCommentUri // at://did:plc:abc/comment/456 1363 + }, 1364 + }); 1365 + ``` 1366 + 1367 + --- 1368 + 1369 + ## Landing Page 1370 + 1371 + **File**: `public/landing.js` + `public/landing.css` 1372 + 1373 + **Purpose**: Public-facing website at `seams.so` that shows recent annotations from all users. 1374 + 1375 + ### Architecture 1376 + 1377 + ``` 1378 + Landing Page (static HTML/JS) 1379 + 1380 + 1381 + slices.network GraphQL API 1382 + 1383 + 1384 + AT Protocol Network (federated PDS nodes) 1385 + ``` 1386 + 1387 + ### slices.network Integration 1388 + 1389 + **Slice ID**: `at://did:plc:dy6ekftqerqu5bcz76kgy6ux/network.slices.slice/3m3ugigrrz52k` 1390 + 1391 + **GraphQL Endpoint**: `https://api.slices.network/graphql?slice=[SLICE_ID]` 1392 + 1393 + **Query**: 1394 + ```graphql 1395 + query RecentAnnotations($cursor: String) { 1396 + communityLexiconAnnotationAnnotations( 1397 + sortBy: [{ field: "createdAt", direction: desc }] 1398 + first: 20 1399 + after: $cursor 1400 + ) { 1401 + edges { 1402 + cursor 1403 + node { 1404 + uri 1405 + did 1406 + actorHandle 1407 + target { 1408 + source 1409 + selector 1410 + } 1411 + body 1412 + createdAt 1413 + } 1414 + } 1415 + pageInfo { 1416 + hasNextPage 1417 + endCursor 1418 + } 1419 + } 1420 + } 1421 + ``` 1422 + 1423 + ### Data Flow 1424 + 1425 + ```typescript 1426 + // Fetch annotations 1427 + const response = await fetch(GRAPHQL_ENDPOINT, { 1428 + method: 'POST', 1429 + headers: { 'Content-Type': 'application/json' }, 1430 + body: JSON.stringify({ 1431 + query: QUERY, 1432 + variables: { cursor } 1433 + }) 1434 + }); 1435 + 1436 + const data = await response.json(); 1437 + const annotations = data.data.communityLexiconAnnotationAnnotations.edges 1438 + .map(edge => edge.node); 1439 + 1440 + // Render each annotation 1441 + for (const annotation of annotations) { 1442 + const quotedText = getQuotedText(annotation.target); 1443 + const sourceUrl = annotation.target[0]?.source; 1444 + const fragmentUrl = buildTextFragmentUrl(sourceUrl, quotedText); 1445 + 1446 + // Fetch avatar from Bluesky 1447 + const profile = await fetch( 1448 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${annotation.did}` 1449 + ); 1450 + const avatarUrl = profile.avatar; 1451 + 1452 + // Render card 1453 + renderAnnotationCard({ 1454 + quote: quotedText, 1455 + body: annotation.body, 1456 + author: annotation.actorHandle, 1457 + avatar: avatarUrl, 1458 + time: annotation.createdAt, 1459 + link: fragmentUrl 1460 + }); 1461 + } 1462 + ``` 1463 + 1464 + ### Text Fragment URL 1465 + 1466 + **Purpose**: Link to the annotated text on the original page. 1467 + 1468 + **Browser Feature**: Chrome supports scroll-to-text-highlight via `#:~:text=` fragment. 1469 + 1470 + **Implementation**: 1471 + ```typescript 1472 + function buildTextFragmentUrl(sourceUrl: string, textQuoteSelector: TextQuoteSelector) { 1473 + if (!sourceUrl || !textQuoteSelector?.exact) { 1474 + return sourceUrl; 1475 + } 1476 + 1477 + try { 1478 + const url = new URL(sourceUrl); 1479 + // Simple version: just exact text 1480 + url.hash = `:~:text=${encodeURIComponent(textQuoteSelector.exact)}`; 1481 + return url.toString(); 1482 + } catch { 1483 + return sourceUrl; 1484 + } 1485 + } 1486 + ``` 1487 + 1488 + **Example**: 1489 + ``` 1490 + Original: https://example.com/article 1491 + With fragment: https://example.com/article#:~:text=brown%20fox 1492 + 1493 + Browser behavior: 1494 + 1. Loads page 1495 + 2. Searches for "brown fox" 1496 + 3. Scrolls to match 1497 + 4. Highlights in yellow (native browser highlight, not extension) 1498 + ``` 1499 + 1500 + **Limitation**: Only works with simple `exact` text. Doesn't use `prefix`/`suffix` for context. 1501 + 1502 + --- 1503 + 1504 + ## Storage Architecture 1505 + 1506 + ### Extension Storage 1507 + 1508 + **API**: `browser.storage.local` (Chrome Extension Storage) 1509 + 1510 + **Keys**: 1511 + | Key | Value | Purpose | 1512 + |-----|-------|---------| 1513 + | `synthesis-oauth:session` | `OAuthSession` | OAuth tokens and user DID | 1514 + | `annotations` | `Annotation[]` | Legacy local storage (not used in current flow) | 1515 + 1516 + **Note**: Current implementation stores annotations primarily on PDS, not in local storage. Local storage is only used by the popover edit/delete feature (which is a bug - it should update PDS). 1517 + 1518 + ### AT Protocol Storage 1519 + 1520 + **Personal Data Server (PDS)**: User's own data repository (e.g., `https://bsky.social/xrpc`) 1521 + 1522 + **Collections**: 1523 + | Collection | Type | Records | 1524 + |------------|------|---------| 1525 + | `community.lexicon.annotation.annotation` | Annotations | User's annotations | 1526 + | `pub.leaflet.comment` | Comments | Comments and replies | 1527 + 1528 + **Record URIs**: 1529 + ``` 1530 + at://[did]/[collection]/[rkey] 1531 + 1532 + Examples: 1533 + - at://did:plc:abc123/community.lexicon.annotation.annotation/3k2j4h5g6f 1534 + - at://did:plc:abc123/pub.leaflet.comment/3k2j4h5g6f 1535 + ``` 1536 + 1537 + ### Storage Flow 1538 + 1539 + ``` 1540 + User creates annotation 1541 + 1542 + 1543 + Sidepanel → PDS Module → com.atproto.repo.createRecord 1544 + │ │ 1545 + │ ▼ 1546 + │ User's PDS (e.g., bsky.social) 1547 + │ │ 1548 + │ ▼ 1549 + │ Record stored with URI + CID 1550 + │ │ 1551 + │ ▼ 1552 + │ Federated to ATProto network 1553 + │ │ 1554 + ▼ ▼ 1555 + (Optional) Local storage slices.network indexes 1556 + ``` 1557 + 1558 + --- 1559 + 1560 + ## Known Issues 1561 + 1562 + ### 1. Race Condition on Sidepanel Load 1563 + 1564 + **Problem**: Sidepanel sends `GET_SELECTION` to content script on load, but content script may not be ready yet. 1565 + 1566 + **Result**: `currentUrl` is empty string, annotations saved with empty `source` field. 1567 + 1568 + **Location**: `entrypoints/sidepanel/main.ts:187-207` 1569 + 1570 + **Fix**: See `STATE_REFACTOR_PLAN.md` for event-driven architecture. 1571 + 1572 + ### 2. No SPA Navigation Tracking 1573 + 1574 + **Problem**: Content script doesn't detect URL changes from `pushState`/`replaceState`. 1575 + 1576 + **Result**: After navigating in a single-page app, annotations for the new page don't load until sidepanel is reopened. 1577 + 1578 + **Fix**: See `STATE_REFACTOR_PLAN.md` for `webNavigation.onHistoryStateUpdated` solution. 1579 + 1580 + ### 3. No Selection Debouncing 1581 + 1582 + **Problem**: `mouseup` event fires even for clicks (no actual selection). 1583 + 1584 + **Result**: Unnecessary `SELECTION_CHANGED` messages sent to sidepanel. 1585 + 1586 + **Fix**: Add 250ms debounce and ignore empty selections. 1587 + 1588 + ### 4. Editable Field Selections Not Filtered 1589 + 1590 + **Problem**: Selections inside `<input>`, `<textarea>`, or `contenteditable` are captured. 1591 + 1592 + **Result**: Confusing UX - user can't annotate form fields, but extension shows form. 1593 + 1594 + **Fix**: Check `document.activeElement` and ignore editable elements. 1595 + 1596 + ### 5. Popover Edit/Delete Doesn't Update PDS 1597 + 1598 + **Problem**: When editing an annotation via highlight popover, it only updates `browser.storage.local`. 1599 + 1600 + **Result**: Changes aren't synced to PDS or visible on other devices. 1601 + 1602 + **Location**: `lib/highlights/apply.ts:85-112` 1603 + 1604 + **Fix**: Call `updateAnnotation()` function in PDS module (needs to be implemented). 1605 + 1606 + ### 6. No Service Worker State Persistence 1607 + 1608 + **Problem**: Background script doesn't persist state across restarts (MV3 service workers suspend). 1609 + 1610 + **Result**: State is lost if service worker is terminated. 1611 + 1612 + **Fix**: Use `chrome.storage.session` (see `STATE_REFACTOR_PLAN.md`). 1613 + 1614 + ### 7. Text Fragment URL Limitation 1615 + 1616 + **Problem**: Landing page only uses `exact` text for text fragments, ignoring `prefix`/`suffix`. 1617 + 1618 + **Result**: If the same text appears multiple times on a page, browser may highlight the wrong instance. 1619 + 1620 + **Location**: `public/landing.js:159-171` 1621 + 1622 + **Fix**: Implement complex text fragment syntax with prefix/suffix (non-trivial). 1623 + 1624 + ### 8. No Cross-Tab Communication 1625 + 1626 + **Problem**: If user has multiple tabs open, creating an annotation in one tab doesn't update highlights in other tabs with the same URL. 1627 + 1628 + **Result**: User must manually reload sidepanel to see new annotations. 1629 + 1630 + **Fix**: Broadcast `ANNOTATIONS_CHANGED` event to all tabs with matching URL. 1631 + 1632 + ### 9. No Error Recovery for Selector Matching 1633 + 1634 + **Problem**: If all selectors fail to match (e.g., page content changed), annotation is silently skipped. 1635 + 1636 + **Result**: User sees annotations in list but no highlights on page. 1637 + 1638 + **Fix**: Show "Could not locate annotation" warning in UI, offer to re-anchor manually. 1639 + 1640 + ### 10. Comment Threading UI Complexity 1641 + 1642 + **Problem**: Nested replies can become deeply indented and hard to read. 1643 + 1644 + **Result**: Poor UX for long discussion threads. 1645 + 1646 + **Fix**: Implement "Show more replies" collapse/expand, or switch to flat threading with "Replying to @user" indicators. 1647 + 1648 + --- 1649 + 1650 + ## Summary Diagram 1651 + 1652 + ``` 1653 + ┌─────────────────────────────────────────────────────────────────┐ 1654 + │ USER ACTIONS │ 1655 + └──────────┬────────────────────────────────────────┬─────────────┘ 1656 + │ │ 1657 + │ Select text │ Click icon 1658 + │ │ 1659 + ┌──────────▼─────────────────┐ ┌─────────▼──────────────┐ 1660 + │ Web Page (Content) │ │ Extension UI │ 1661 + │ │ │ (Sidepanel) │ 1662 + │ ┌────────────────────────┐ │ │ │ 1663 + │ │ Content Script │ │ │ ┌────────────────────┐ │ 1664 + │ │ │ │ │ │ Auth Section │ │ 1665 + │ │ • Track selection │◄┼────────────┼─┤ - Login form │ │ 1666 + │ │ • Generate selectors │ │ GET_SEL │ │ - OAuth flow │ │ 1667 + │ │ • Apply highlights │ │ │ └────────────────────┘ │ 1668 + │ │ • Show popovers │ │ │ ┌────────────────────┐ │ 1669 + │ └──────┬─────────▲───────┘ │ │ │ Content Section │ │ 1670 + │ │ │ │ │ │ - Annotation form │ │ 1671 + │ │ SELECTION│UPDATE │ │ │ - Annotation list │ │ 1672 + │ │ _CHANGED │_HIGHLIGHTS │ │ - Comment threads │ │ 1673 + │ │ │ │ │ └────────────────────┘ │ 1674 + └────────┼─────────┼──────────┘ └─────────┬──────────────┘ 1675 + │ │ │ 1676 + │ │ │ createAnnotation 1677 + │ │ │ listAnnotations 1678 + ▼ │ │ createComment 1679 + ┌────────────────────────────┐ │ 1680 + │ Background Script │ │ 1681 + │ │ │ 1682 + │ • Context menu │ │ 1683 + │ • Auth broadcast │ │ 1684 + │ • Icon click → sidepanel │ │ 1685 + └────────────────────────────┘ │ 1686 + 1687 + ┌──────────────────────────────────────────┘ 1688 + 1689 + 1690 + ┌─────────────────────────────────────────────────────┐ 1691 + │ lib/ │ 1692 + │ │ 1693 + │ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │ 1694 + │ │ oauth.ts │ │ pds.ts │ │ selectors/ │ │ 1695 + │ │ │ │ │ │ │ │ 1696 + │ │ • OAuth │ │ • XRPC calls │ │ • generate │ │ 1697 + │ │ flow │ │ • Create │ │ • match │ │ 1698 + │ │ • Session │ │ • List │ └────────────┘ │ 1699 + │ │ mgmt │ │ • Delete │ ┌────────────┐ │ 1700 + │ └─────────────┘ └──────────────┘ │highlights/ │ │ 1701 + │ │ │ │ 1702 + │ │ • apply │ │ 1703 + │ │ • popover │ │ 1704 + │ └────────────┘ │ 1705 + └──────────────────┬──────────────────────────────────┘ 1706 + │ XRPC 1707 + 1708 + ┌──────────────────────────────────────────────────────┐ 1709 + │ AT Protocol Infrastructure │ 1710 + │ │ 1711 + │ ┌──────────────────┐ ┌────────────────────┐ │ 1712 + │ │ PDS (bsky.social)│ │ slices.network │ │ 1713 + │ │ │ │ │ │ 1714 + │ │ • OAuth server │────────▶│ • GraphQL API │ │ 1715 + │ │ • Record storage │ indexes │ • Annotation feed │ │ 1716 + │ └──────────────────┘ └────────────────────┘ │ 1717 + └─────────────────────────────────────────────────┬────┘ 1718 + 1719 + │ GraphQL 1720 + 1721 + ┌────────────────┐ 1722 + │ Landing Page │ 1723 + │ (seams.so) │ 1724 + │ │ 1725 + │ • Public feed │ 1726 + │ • Avatar fetch │ 1727 + │ • Text frags │ 1728 + └────────────────┘ 1729 + ``` 1730 + 1731 + --- 1732 + 1733 + This documentation represents the **current state** of the codebase as of the analysis. For the planned refactoring to event-driven architecture, see [`STATE_REFACTOR_PLAN.md`](file:///home/anish/usr/seams.so/STATE_REFACTOR_PLAN.md).
+512
STATE_REFACTOR_PLAN.md
··· 1 + # State Management Refactoring Plan 2 + 3 + ## Problem Statement 4 + 5 + The current extension architecture relies on fragile, on-demand message passing from the sidepanel to the content script to fetch the current URL and selection state. This leads to: 6 + 7 + - **Race conditions**: Sidepanel tries to pull state from content script on load (may not be ready) 8 + - **Empty source URLs**: Annotations saved with empty `source` field 9 + - **No SPA navigation tracking**: URL changes via pushState/replaceState aren't detected 10 + - **Fragmented state**: Each component maintains its own copy of url/selection state 11 + 12 + ## Current Architecture 13 + 14 + ``` 15 + Sidepanel (pulls) → Content Script 16 + - On mount: browser.tabs.sendMessage({ type: 'GET_SELECTION' }) 17 + - Content may not be ready 18 + - No proactive updates 19 + ``` 20 + 21 + ## Target Architecture (Oracle-Approved) 22 + 23 + ``` 24 + Content Script → Background (State Manager) ⇄ Sidepanel 25 + 26 + storage.session (persistence) 27 + ``` 28 + 29 + ### Key Principles 30 + 31 + 1. **Event-driven**: Content proactively pushes state updates 32 + 2. **MV3-resilient**: State persists in `chrome.storage.session` to survive service worker suspension 33 + 3. **Port-based communication**: Long-lived connections keep SW alive and prevent cross-tab leakage 34 + 4. **Background-managed URL tracking**: Use browser APIs (`tabs.onUpdated`, `webNavigation.onHistoryStateUpdated`) 35 + 5. **Debounced selection**: Avoid spam from `selectionchange` events 36 + 6. **Timestamped updates**: Prevent race conditions with `updatedAt` field 37 + 38 + ## Implementation Plan 39 + 40 + ### 1. Add webNavigation Permission 41 + 42 + **File**: `wxt.config.ts` or manifest 43 + 44 + ```typescript 45 + permissions: [ 46 + 'storage', 47 + 'contextMenus', 48 + 'sidePanel', 49 + 'webNavigation', // ADD THIS 50 + ] 51 + ``` 52 + 53 + ### 2. Background Script State Manager 54 + 55 + **File**: `entrypoints/background.ts` 56 + 57 + #### State Shape 58 + ```typescript 59 + interface TabState { 60 + url: string; 61 + title: string; 62 + selection: { text: string; selectors: Selector[] } | null; 63 + updatedAt: number; // Date.now() 64 + } 65 + 66 + // In-memory cache 67 + const tabStateCache = new Map<number, TabState>(); 68 + 69 + // Port registry 70 + const sidepanelPorts = new Map<number, chrome.runtime.Port>(); // tabId → port 71 + ``` 72 + 73 + #### Storage Session Integration 74 + - Mirror `tabStateCache` to `chrome.storage.session` 75 + - Throttle writes to ≤1/sec per tab 76 + - On SW restart: restore from `storage.session` 77 + 78 + #### Event Listeners 79 + 80 + **tabs.onUpdated** (URL/title changes) 81 + ```typescript 82 + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 83 + if (changeInfo.url || changeInfo.title) { 84 + updateTabState(tabId, { 85 + url: tab.url, 86 + title: tab.title, 87 + selection: null, // Clear selection on navigation 88 + updatedAt: Date.now(), 89 + }); 90 + 91 + // Clear highlights on content script 92 + browser.tabs.sendMessage(tabId, { type: 'CLEAR_HIGHLIGHTS' }).catch(() => {}); 93 + 94 + // Notify sidepanel 95 + broadcastToSidepanel(tabId); 96 + } 97 + }); 98 + ``` 99 + 100 + **webNavigation.onHistoryStateUpdated** (SPA navigation) 101 + ```typescript 102 + browser.webNavigation.onHistoryStateUpdated.addListener((details) => { 103 + if (details.frameId === 0) { // Top frame only 104 + updateTabState(details.tabId, { 105 + url: details.url, 106 + selection: null, 107 + updatedAt: Date.now(), 108 + }); 109 + 110 + browser.tabs.sendMessage(details.tabId, { type: 'CLEAR_HIGHLIGHTS' }).catch(() => {}); 111 + broadcastToSidepanel(details.tabId); 112 + } 113 + }); 114 + ``` 115 + 116 + **tabs.onRemoved** (cleanup) 117 + ```typescript 118 + browser.tabs.onRemoved.addListener((tabId) => { 119 + tabStateCache.delete(tabId); 120 + sidepanelPorts.delete(tabId); 121 + browser.storage.session.remove(`tab_${tabId}`); 122 + }); 123 + ``` 124 + 125 + #### Message Handlers 126 + 127 + **TAB_STATE_UPDATE** (from content script) 128 + ```typescript 129 + // Content sends: { type: 'TAB_STATE_UPDATE', url, title, selection, updatedAt } 130 + if (message.type === 'TAB_STATE_UPDATE') { 131 + const tabId = sender.tab?.id; 132 + if (!tabId) return; 133 + 134 + const currentState = tabStateCache.get(tabId); 135 + 136 + // Last-write-wins: only accept if newer 137 + if (!currentState || message.updatedAt > currentState.updatedAt) { 138 + updateTabState(tabId, message); 139 + broadcastToSidepanel(tabId); 140 + } 141 + 142 + sendResponse({ success: true }); 143 + return true; 144 + } 145 + ``` 146 + 147 + **GET_TAB_STATE** (from sidepanel) 148 + ```typescript 149 + // Sidepanel sends: { type: 'GET_TAB_STATE', tabId } 150 + if (message.type === 'GET_TAB_STATE') { 151 + const tabId = message.tabId; 152 + let state = tabStateCache.get(tabId); 153 + 154 + if (!state) { 155 + // Try storage.session 156 + const stored = await browser.storage.session.get(`tab_${tabId}`); 157 + state = stored[`tab_${tabId}`]; 158 + } 159 + 160 + if (!state) { 161 + // Cold start: request snapshot from content 162 + try { 163 + const snapshot = await browser.tabs.sendMessage(tabId, { 164 + type: 'GET_TAB_SNAPSHOT' 165 + }); 166 + state = { ...snapshot, updatedAt: Date.now() }; 167 + updateTabState(tabId, state); 168 + } catch (error) { 169 + // Content script not available (restricted page, PDF, etc.) 170 + sendResponse({ 171 + success: false, 172 + error: 'Content not available on this page' 173 + }); 174 + return true; 175 + } 176 + } 177 + 178 + sendResponse({ success: true, state }); 179 + return true; 180 + } 181 + ``` 182 + 183 + #### Port Management 184 + 185 + **Sidepanel connection** 186 + ```typescript 187 + browser.runtime.onConnect.addListener((port) => { 188 + if (port.name === 'sidepanel') { 189 + port.onMessage.addListener((message) => { 190 + if (message.type === 'REGISTER_TAB') { 191 + sidepanelPorts.set(message.tabId, port); 192 + } 193 + }); 194 + 195 + port.onDisconnect.addListener(() => { 196 + // Remove from registry 197 + for (const [tabId, p] of sidepanelPorts.entries()) { 198 + if (p === port) { 199 + sidepanelPorts.delete(tabId); 200 + break; 201 + } 202 + } 203 + }); 204 + } 205 + }); 206 + ``` 207 + 208 + #### Helper Functions 209 + 210 + ```typescript 211 + function updateTabState(tabId: number, updates: Partial<TabState>) { 212 + const current = tabStateCache.get(tabId) || {} as TabState; 213 + const newState = { ...current, ...updates }; 214 + tabStateCache.set(tabId, newState); 215 + 216 + // Throttled write to storage.session (implement throttle) 217 + throttledSaveToSession(tabId, newState); 218 + } 219 + 220 + function broadcastToSidepanel(tabId: number) { 221 + const port = sidepanelPorts.get(tabId); 222 + const state = tabStateCache.get(tabId); 223 + 224 + if (port && state) { 225 + port.postMessage({ 226 + type: 'TAB_STATE_CHANGED', 227 + tabId, 228 + state, 229 + }); 230 + } 231 + } 232 + ``` 233 + 234 + ### 3. Content Script Refactor 235 + 236 + **File**: `entrypoints/content.ts` 237 + 238 + #### Debounced Selection Tracking 239 + 240 + ```typescript 241 + let selectionDebounceTimer: number | null = null; 242 + const SELECTION_DEBOUNCE_MS = 250; 243 + 244 + document.addEventListener('selectionchange', () => { 245 + if (selectionDebounceTimer) { 246 + clearTimeout(selectionDebounceTimer); 247 + } 248 + 249 + selectionDebounceTimer = setTimeout(() => { 250 + handleSelectionChange(); 251 + }, SELECTION_DEBOUNCE_MS); 252 + }); 253 + 254 + // Also handle immediate updates on mouseup/keyup 255 + document.addEventListener('mouseup', handleSelectionChange); 256 + document.addEventListener('keyup', handleSelectionChange); 257 + 258 + function handleSelectionChange() { 259 + const selection = window.getSelection(); 260 + 261 + if (!selection || selection.toString().trim().length === 0) { 262 + sendStateUpdate(null); 263 + currentSelection = null; 264 + return; 265 + } 266 + 267 + // Ignore selections in editable fields 268 + const activeElement = document.activeElement; 269 + if (activeElement && ( 270 + activeElement.tagName === 'INPUT' || 271 + activeElement.tagName === 'TEXTAREA' || 272 + activeElement.hasAttribute('contenteditable') 273 + )) { 274 + return; 275 + } 276 + 277 + const text = selection.toString().trim(); 278 + const root = document.querySelector('main') || document.querySelector('article') || document.body; 279 + const selectors = generateSelectors(selection, root); 280 + 281 + currentSelection = { text, selectors }; 282 + sendStateUpdate(currentSelection); 283 + } 284 + 285 + function sendStateUpdate(selection: { text: string; selectors: Selector[] } | null) { 286 + browser.runtime.sendMessage({ 287 + type: 'TAB_STATE_UPDATE', 288 + url: window.location.href, 289 + title: document.title, 290 + selection, 291 + updatedAt: Date.now(), 292 + }).catch(() => { 293 + // Background might not be ready 294 + }); 295 + } 296 + ``` 297 + 298 + #### Initial State Push 299 + 300 + ```typescript 301 + // On page load (document_idle is default for WXT) 302 + sendStateUpdate(null); // Initial state with no selection 303 + ``` 304 + 305 + #### Snapshot Responder 306 + 307 + ```typescript 308 + browser.runtime.onMessage.addListener((message, sender, sendResponse) => { 309 + if (message.type === 'GET_TAB_SNAPSHOT') { 310 + sendResponse({ 311 + url: window.location.href, 312 + title: document.title, 313 + selection: currentSelection, 314 + }); 315 + return true; 316 + } 317 + 318 + // Keep existing handlers: UPDATE_HIGHLIGHTS, CLEAR_HIGHLIGHTS 319 + // ... 320 + }); 321 + ``` 322 + 323 + #### Remove Old GET_SELECTION Handler 324 + 325 + ```typescript 326 + // DELETE THIS: 327 + // if (message.type === 'GET_SELECTION') { 328 + // sendResponse({ 329 + // selection: currentSelection, 330 + // url: window.location.href, 331 + // title: document.title 332 + // }); 333 + // return true; 334 + // } 335 + ``` 336 + 337 + ### 4. Sidepanel Refactor 338 + 339 + **File**: `entrypoints/sidepanel/main.ts` 340 + 341 + #### Port Connection & Tab Registration 342 + 343 + ```typescript 344 + let currentTabId: number | null = null; 345 + let backgroundPort: chrome.runtime.Port | null = null; 346 + 347 + // On mount 348 + (async function() { 349 + // Get current tab 350 + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); 351 + currentTabId = tabs[0]?.id || null; 352 + 353 + if (!currentTabId) { 354 + console.error('No active tab found'); 355 + return; 356 + } 357 + 358 + // Connect to background via port 359 + backgroundPort = browser.runtime.connect({ name: 'sidepanel' }); 360 + 361 + // Register this sidepanel with the tab 362 + backgroundPort.postMessage({ 363 + type: 'REGISTER_TAB', 364 + tabId: currentTabId, 365 + }); 366 + 367 + // Request initial state 368 + browser.runtime.sendMessage({ 369 + type: 'GET_TAB_STATE', 370 + tabId: currentTabId, 371 + }).then(response => { 372 + if (response.success) { 373 + updateUIState(response.state); 374 + } else { 375 + // Show friendly message for restricted pages 376 + showRestrictedPageMessage(response.error); 377 + } 378 + }); 379 + 380 + // Listen for state changes via port 381 + backgroundPort.onMessage.addListener((message) => { 382 + if (message.type === 'TAB_STATE_CHANGED' && message.tabId === currentTabId) { 383 + updateUIState(message.state); 384 + } 385 + }); 386 + })(); 387 + 388 + function updateUIState(state: { url: string; title: string; selection: any }) { 389 + // Update currentUrl 390 + if (currentUrl !== state.url) { 391 + currentUrl = state.url; 392 + loadAnnotations(currentUrl); // Reload annotations for new URL 393 + } 394 + 395 + // Update currentSelection 396 + currentSelection = state.selection; 397 + 398 + if (currentSelection && currentSelection.text && selectedTextEl) { 399 + selectedTextEl.innerHTML = `<blockquote>${currentSelection.text}</blockquote>`; 400 + if (annotationForm) annotationForm.style.display = 'block'; 401 + } else { 402 + if (annotationForm) annotationForm.style.display = 'none'; 403 + } 404 + } 405 + 406 + function showRestrictedPageMessage(error: string) { 407 + if (annotationsContainer) { 408 + annotationsContainer.innerHTML = ` 409 + <p class="error">This page doesn't support annotations.</p> 410 + <p class="error-detail">${error}</p> 411 + `; 412 + } 413 + if (annotationForm) annotationForm.style.display = 'none'; 414 + } 415 + ``` 416 + 417 + #### Remove Old Initialization Code 418 + 419 + ```typescript 420 + // DELETE THIS (lines 187-207): 421 + // browser.tabs.query({ active: true, currentWindow: true }).then(tabs => { 422 + // if (tabs[0]?.id) { 423 + // browser.tabs.sendMessage(tabs[0].id, { type: 'GET_SELECTION' }).then(response => { 424 + // currentUrl = response.url; 425 + // currentSelection = response.selection; 426 + // // ... 427 + // }).catch(err => { 428 + // console.error('Failed to get selection:', err); 429 + // }); 430 + // } 431 + // }); 432 + ``` 433 + 434 + #### Keep SELECTION_CHANGED Listener (for backward compatibility during transition) 435 + 436 + ```typescript 437 + // Can remove this once background state is fully working 438 + browser.runtime.onMessage.addListener((message) => { 439 + if (message.type === 'SELECTION_CHANGED') { 440 + // Deprecated: background should handle this now 441 + console.warn('Deprecated SELECTION_CHANGED message received'); 442 + } 443 + }); 444 + ``` 445 + 446 + ## Message Protocol Summary 447 + 448 + ### Content → Background 449 + - **TAB_STATE_UPDATE**: `{ type, url, title, selection, updatedAt }` 450 + 451 + ### Sidepanel → Background 452 + - **GET_TAB_STATE**: `{ type, tabId }` 453 + - **REGISTER_TAB** (via port): `{ type, tabId }` 454 + 455 + ### Background → Sidepanel 456 + - **TAB_STATE_CHANGED** (via port): `{ type, tabId, state: { url, title, selection, updatedAt } }` 457 + 458 + ### Background → Content 459 + - **GET_TAB_SNAPSHOT**: `{ type }` → Response: `{ url, title, selection }` 460 + - **CLEAR_HIGHLIGHTS**: `{ type }` (on URL change) 461 + 462 + ## Testing Checklist 463 + 464 + - [ ] Initial page load: sidepanel shows correct URL and no selection 465 + - [ ] Text selection: sidepanel updates within 250ms 466 + - [ ] Clear selection: sidepanel hides annotation form 467 + - [ ] SPA navigation (pushState): URL updates, selection clears, highlights clear 468 + - [ ] Traditional navigation: URL updates, selection clears 469 + - [ ] Service worker suspend/resume: state persists via storage.session 470 + - [ ] Restricted pages (chrome://, PDFs): friendly error message shown 471 + - [ ] Multi-tab: each sidepanel only receives updates for its tab 472 + - [ ] Selections in input/textarea: ignored 473 + - [ ] Rapid selection changes: debounced correctly 474 + 475 + ## Rollout Strategy 476 + 477 + 1. **Phase 1**: Add webNavigation permission and background state manager 478 + 2. **Phase 2**: Update content script with debouncing and snapshot responder 479 + 3. **Phase 3**: Update sidepanel to use ports and new protocol 480 + 4. **Phase 4**: Remove deprecated GET_SELECTION handler and SELECTION_CHANGED broadcasts 481 + 5. **Phase 5**: Test thoroughly, especially SPA navigation and service worker suspension 482 + 483 + ## Performance Considerations 484 + 485 + - **Debouncing**: 250ms prevents excessive message spam 486 + - **Throttled storage writes**: Max 1 write/sec per tab to `storage.session` 487 + - **Selector generation**: Only on stable selections (after debounce or pointer events) 488 + - **Port-based communication**: Keeps SW alive, avoids message routing overhead 489 + 490 + ## Edge Cases Handled 491 + 492 + 1. **Service worker suspension**: State persists in `storage.session`, restored on wake 493 + 2. **Content script not ready**: Background falls back to snapshot request 494 + 3. **Restricted pages**: Graceful error message shown to user 495 + 4. **Stale updates**: Timestamped updates prevent out-of-order state changes 496 + 5. **Cross-tab contamination**: Port-based delivery ensures tab isolation 497 + 6. **Editable fields**: Selections inside inputs/textareas are ignored 498 + 7. **Multi-frame pages**: Only top frame (frameId === 0) is tracked 499 + 500 + ## Future Enhancements (Out of Scope) 501 + 502 + - Multi-frame annotation support (include frameId in state) 503 + - Cross-session recovery (persist to `storage.local`) 504 + - Incremental selector generation for performance 505 + - Multi-tab sidepanel support with dynamic tab switching 506 + 507 + ## References 508 + 509 + - [Chrome Extension Messaging](https://developer.chrome.com/docs/extensions/mv3/messaging/) 510 + - [Service Worker Lifecycle](https://developer.chrome.com/docs/extensions/mv3/service_workers/) 511 + - [chrome.storage.session API](https://developer.chrome.com/docs/extensions/reference/storage/#property-session) 512 + - [webNavigation API](https://developer.chrome.com/docs/extensions/reference/webNavigation/)