Write on the margins of the internet. Powered by the AT Protocol.

Add hosted loader documentation and overlay loader bookmarklet

- Create doc/discovery/overlay-build.md with build research
- Create doc/discovery/hosted-loader.md with architecture overview
- Add bookmarklet.overlay-loader.user.js for loading full overlay from CDN
- Update bookmarklet.ts and bookmarklets.html with overlay loader

rektide fff2955b 62f8cb1a

+745
+46
bookmarklet.overlay-loader.user.js
··· 1 + // ==UserScript== 2 + // @name Margin.at Overlay 3 + // @namespace https://margin.at/ 4 + // @version 1.0 5 + // @description Load the margin.at annotation overlay on any page - view and create annotations without installing the extension 6 + // @author margin.at 7 + // @match *://*/* 8 + // @grant none 9 + // @run-at document-idle 10 + // ==/UserScript== 11 + 12 + (function () { 13 + if (window.MarginOverlay) { 14 + console.log("[margin.at] Overlay already loaded"); 15 + return; 16 + } 17 + 18 + const SCRIPT_URL = "https://cdn.margin.at/overlay/margin-overlay.min.js"; 19 + const STYLE_URL = "https://cdn.margin.at/overlay/margin-overlay.css"; 20 + 21 + const existing = document.querySelector( 22 + `script[src="${SCRIPT_URL}"], script[src="${SCRIPT_URL}?"]`, 23 + ); 24 + if (existing) { 25 + console.log("[margin.at] Overlay script already injected"); 26 + return; 27 + } 28 + 29 + const script = document.createElement("script"); 30 + script.src = SCRIPT_URL; 31 + script.async = true; 32 + script.crossOrigin = "anonymous"; 33 + 34 + script.onload = function () { 35 + console.log("[margin.at] Overlay loaded"); 36 + if (window.MarginOverlay && window.MarginOverlay.init) { 37 + window.MarginOverlay.init(); 38 + } 39 + }; 40 + 41 + script.onerror = function () { 42 + console.error("[margin.at] Failed to load overlay"); 43 + }; 44 + 45 + document.head.appendChild(script); 46 + })();
+7
bookmarklet.ts
··· 95 95 `(async function(){const u=window.location.href;const s=window.getSelection().toString().trim();const q=new URLSearchParams(window.location.search).get("quote")||s;const t=prompt("Annotation text:");if(t===null)return;const g=prompt("Tags (comma-separated):");const b={url:u};if(q)b.selector={exact:q};if(t)b.text=t;if(g)b.tags=g.split(",").map(x=>x.trim()).filter(x=>x);try{const r=await fetch("https://margin.at/api/annotations",{method:"POST",headers:{"Content-Type":"application/json"},credentials:"include",body:JSON.stringify(b)});if(r.ok)alert("Posted!");else if(r.status===401){alert("Log in first");window.open("https://margin.at/login","_blank")}else alert("Error: "+await r.text())}catch(e){alert("Failed: "+e.message)}})()`, 96 96 ), 97 97 ); 98 + console.log("\n=== Overlay Loader (loads full annotation overlay from CDN) ==="); 99 + console.log( 100 + "javascript:" + 101 + encodeURIComponent( 102 + `(function(){if(window.MarginOverlay)return;var s=document.createElement("script");s.src="https://cdn.margin.at/overlay/margin-overlay.min.js";s.onload=function(){window.MarginOverlay&&window.MarginOverlay.init()};document.head.appendChild(s)})()`, 103 + ), 104 + ); 98 105 } 99 106 } 100 107
+16
bookmarklets.html
··· 98 98 <p class="tip">You must be logged into margin.at in your browser for this to work.</p> 99 99 </section> 100 100 101 + <section name="overlay-loader" class="section"> 102 + <h2>Overlay Loader</h2> 103 + <p name="overlay-loader-desc">Loads the full margin.at annotation overlay from CDN, showing all existing annotations on the page with highlights, popovers, and a compose modal. This provides the same experience as the browser extension without installation. <a class="script-link" href="bookmarklet.overlay-loader.user.js" download="margin-overlay-loader.user.js" title="Download userscript">🔗</a></p> 104 + <a name="overlay-loader-bookmarklet" class="bookmarklet" href="javascript:(function(){if(window.MarginOverlay)return;var%20s=document.createElement(%22script%22);s.src=%22https://cdn.margin.at/overlay/margin-overlay.min.js%22;s.onload=function(){window.MarginOverlay%26%26window.MarginOverlay.init()};document.head.appendChild(s)})()">📖 Load Overlay</a> 105 + 106 + <h3>What it does:</h3> 107 + <ul> 108 + <li>Injects the margin.at overlay script from CDN (~32KB)</li> 109 + <li>Fetches and displays all annotations for the current page</li> 110 + <li>Highlights annotated text on the page</li> 111 + <li>Shows popovers when clicking highlights</li> 112 + <li>Provides compose modal for creating new annotations</li> 113 + </ul> 114 + <p class="tip">You must be logged into margin.at in your browser to create annotations. Viewing annotations works without login.</p> 115 + </section> 116 + 101 117 <section name="api-reference"> 102 118 <h2>API Reference</h2> 103 119
+216
doc/discovery/hosted-loader.md
··· 1 + # Hosted Loader for Margin.at Overlay 2 + 3 + ## Overview 4 + 5 + This document explores the hosted loader approach for deploying the margin.at overlay as a bookmarklet/userscript that can annotate any webpage without requiring the browser extension. 6 + 7 + ## Journal - Tech Stack Review 8 + 9 + The margin.at project consists of: 10 + 11 + | Component | Tech | Purpose | 12 + |-----------|------|---------| 13 + | Extension | WXT + Vite + TypeScript | Browser extension with overlay | 14 + | Web | Astro + React | Main web application | 15 + | Backend | Go + Chi + SQLite | API server | 16 + 17 + The overlay system in the extension ([`extension/src/utils/overlay.ts`](../extension/src/utils/overlay.ts)) is a ~1095-line shadow DOM-based annotation overlay that: 18 + 19 + - Fetches annotations for the current page 20 + - Highlights annotated text using CSS Custom Highlight API 21 + - Shows popovers when clicking highlights 22 + - Provides compose modal for creating new annotations 23 + - Supports themes (light/dark) 24 + 25 + ## Journal - Problem Statement 26 + 27 + Browser extensions require installation and approval processes. A hosted loader bookmarklet would allow users to: 28 + 29 + 1. Try margin.at without installing anything 30 + 2. Use margin.at on restricted devices (work/school computers) 31 + 3. Share annotation links that work without extension 32 + 33 + The challenge: The overlay code is coupled to extension-specific APIs: 34 + - `@webext-core/messaging` for background script communication 35 + - WXT `storage` for preferences 36 + - `browser.cookies` for session handling 37 + - `browser.runtime.onMessage` for events 38 + 39 + ## Journal - Build Research 40 + 41 + Research documented in [`overlay-build.md`](./overlay-build.md) concluded: 42 + 43 + **Recommended approach**: Parallel Vite build with adapter modules 44 + 45 + - Reuses Vite (already in WXT dependency tree) 46 + - Creates adapter modules in `src/standalone/` that replace extension APIs 47 + - No changes to existing extension code 48 + - ~32KB minified bundle size 49 + - 1 new config file, 1 new npm script 50 + 51 + Key adapters needed: 52 + - `adapters/api.ts` - Direct fetch calls to margin.at API 53 + - `adapters/storage.ts` - localStorage for preferences 54 + - `overlay-standalone.ts` - Adapted overlay without extension deps 55 + 56 + ## Hosted Loader Architecture 57 + 58 + ``` 59 + ┌─────────────────┐ ┌──────────────────────┐ 60 + │ Bookmarklet │ │ CDN (cdn.margin.at) │ 61 + │ (user clicks) │─────▶│ │ 62 + └─────────────────┘ │ margin-overlay.js │ 63 + │ (~32KB minified) │ 64 + └──────────────────────┘ 65 + 66 + 67 + ┌──────────────────────┐ 68 + │ Page DOM │ 69 + │ - Shadow root │ 70 + │ - Highlights │ 71 + │ - Popovers │ 72 + └──────────────────────┘ 73 + 74 + 75 + ┌──────────────────────┐ 76 + │ margin.at API │ 77 + │ - /api/annotations │ 78 + │ - /api/targets │ 79 + │ - /auth/session │ 80 + └──────────────────────┘ 81 + ``` 82 + 83 + ## Loader Implementations 84 + 85 + ### Bookmarklet (inline) 86 + 87 + ```javascript 88 + javascript:(function(){ 89 + var s=document.createElement('script'); 90 + s.src='https://cdn.margin.at/overlay/margin-overlay.min.js'; 91 + s.onload=function(){window.MarginOverlay&&window.MarginOverlay.init()}; 92 + document.head.appendChild(s); 93 + })(); 94 + ``` 95 + 96 + ### Userscript (installable) 97 + 98 + ```javascript 99 + // ==UserScript== 100 + // @name Margin.at Overlay 101 + // @namespace https://margin.at/ 102 + // @version 1.0 103 + // @description Load margin.at annotation overlay on any page 104 + // @author margin.at 105 + // @match *://*/* 106 + // @grant none 107 + // ==/UserScript== 108 + 109 + (function(){ 110 + if (window.MarginOverlay) return; // Already loaded 111 + var s = document.createElement('script'); 112 + s.src = 'https://cdn.margin.at/overlay/margin-overlay.min.js'; 113 + s.onload = function() { window.MarginOverlay && window.MarginOverlay.init(); }; 114 + document.head.appendChild(s); 115 + })(); 116 + ``` 117 + 118 + ## Features and Capabilities 119 + 120 + | Feature | Extension | Hosted Loader | 121 + |---------|-----------|---------------| 122 + | View annotations | ✅ | ✅ | 123 + | Create annotations | ✅ | ✅ | 124 + | Highlights | ✅ | ✅ | 125 + | Tags/autocomplete | ✅ | ✅ | 126 + | Theme support | ✅ | ✅ | 127 + | PDF support | ✅ | ❌* | 128 + | Context menu | ✅ | ❌ | 129 + | Keyboard shortcuts | ✅ | ❌ | 130 + | Sidebar panel | ✅ | ❌ | 131 + | Offline mode | Partial | ❌ | 132 + 133 + *PDF requires special handling via PDF.js integration 134 + 135 + ## API Surfaces 136 + 137 + The hosted loader uses the same public API as the web app: 138 + 139 + ### Authentication 140 + - `GET /auth/session` - Check login status (uses cookies) 141 + - `GET /login` - Redirect to Bluesky OAuth 142 + 143 + ### Annotations 144 + - `GET /api/targets?source=<url>` - Get annotations for URL 145 + - `POST /api/annotations` - Create annotation 146 + - `PUT /api/annotations?uri=<uri>` - Update annotation 147 + - `DELETE /api/annotations?rkey=<rkey>` - Delete annotation 148 + 149 + ### Highlights 150 + - `POST /api/highlights` - Create highlight 151 + - `POST /api/highlights/convert` - Convert to annotation 152 + 153 + ### Tags 154 + - `GET /api/users/<did>/tags` - User's recent tags 155 + - `GET /api/trending-tags` - Trending tags 156 + 157 + ## Options and Alternatives 158 + 159 + ### Option A: Full Overlay Bundle (Recommended) 160 + - Bundle entire overlay with adapters 161 + - Single JS file from CDN 162 + - ~32KB minified 163 + 164 + ### Option B: Simplified Compose-Only 165 + - Minimal modal for creating annotations only 166 + - No highlighting/viewing 167 + - ~8KB minified 168 + 169 + ### Option C: Iframe Embed 170 + - Load margin.at/new in iframe overlay 171 + - No code sharing needed 172 + - Heavier, CORS complexity 173 + 174 + ### Option D: Web Component 175 + - Package as custom element `<margin-overlay>` 176 + - Better encapsulation 177 + - Same bundle size 178 + 179 + ## Decision Points 180 + 181 + 1. **CDN hosting**: Where to host the bundle? 182 + - Options: Cloudflare, margin.at origin, separate CDN 183 + - Recommendation: Cloudflare (already used for avatars) 184 + 185 + 2. **Versioning**: How to handle updates? 186 + - Option A: Semantic versioning in URL (`/overlay/v1.0.0/...`) 187 + - Option B: Always latest with cache headers 188 + - Recommendation: Option B with `Cache-Control: max-age=3600` 189 + 190 + 3. **Authentication UX**: How to handle logged-out users? 191 + - Option A: Show login prompt, redirect to margin.at 192 + - Option B: Show read-only mode 193 + - Recommendation: Option A with clear messaging 194 + 195 + 4. **Bundle format**: IIFE or ESM? 196 + - IIFE: Works everywhere, simpler 197 + - ESM: Modern, tree-shakeable 198 + - Recommendation: IIFE for bookmarklet compatibility 199 + 200 + ## Implementation Checklist 201 + 202 + - [ ] Create `src/standalone/` adapter modules 203 + - [ ] Create standalone entry point 204 + - [ ] Add Vite config for standalone build 205 + - [ ] Add npm script `build:standalone` 206 + - [ ] Test bundle independently 207 + - [ ] Set up CDN hosting 208 + - [ ] Create bookmarklet loader 209 + - [ ] Create userscript loader 210 + - [ ] Update documentation 211 + 212 + ## Related Files 213 + 214 + - [`overlay-build.md`](./overlay-build.md) - Build research 215 + - [`../bookmarklet.ts`](../bookmarklet.ts) - Simple bookmarklet generators 216 + - [`../bookmarklets.html`](../bookmarklets.html) - Bookmarklet installation page
+460
doc/discovery/overlay-build.md
··· 1 + # Overlay Standalone Bundle Build Research 2 + 3 + ## Overview 4 + 5 + This document explores options for building the margin.at overlay as a standalone bundle suitable for a hosted loader bookmarklet, with minimal changes to the existing WXT-based extension build. 6 + 7 + ## Current Build Setup 8 + 9 + ### Tools 10 + - **WXT v0.19.13** - Chrome/Firefox extension framework 11 + - **Vite** - Used internally by WXT for bundling 12 + - **TypeScript v5.6.3** 13 + - **React 18** - For popup/sidepanel UI (not used in overlay) 14 + - **@webext-core/messaging v1.4.0** - Type-safe extension messaging 15 + 16 + ### Package Scripts 17 + ```json 18 + { 19 + "dev": "wxt", 20 + "build": "wxt build", 21 + "build:firefox": "wxt build -b firefox" 22 + } 23 + ``` 24 + 25 + ### WXT Config Highlights 26 + - `srcDir: 'src'` - Source in `extension/src/` 27 + - Uses `@wxt-dev/module-react` 28 + - Manifest V3 with standard extension permissions 29 + 30 + ## Overlay Architecture 31 + 32 + ### Core Files 33 + 34 + | File | Lines | Purpose | 35 + |------|-------|---------| 36 + | `overlay.ts` | 1095 | Main overlay logic, DOM manipulation, popovers | 37 + | `overlay-styles.ts` | 701 | CSS-in-JS styles string | 38 + | `text-matcher.ts` | 367 | DOM text search with fuzzy matching | 39 + | `types.ts` | 80 | Type definitions and constants | 40 + | `messaging.ts` | 87 | Extension messaging via @webext-core/messaging | 41 + | `storage.ts` | 12 | Extension storage via WXT | 42 + | `api.ts` | 350 | Backend API client (uses extension cookies) | 43 + 44 + ### Entry Point 45 + ```typescript 46 + // content.ts 47 + export default defineContentScript({ 48 + matches: ['<all_urls>'], 49 + runAt: 'document_idle', 50 + async main(ctx) { 51 + await initContentScript(ctx); 52 + }, 53 + }); 54 + ``` 55 + 56 + ## Extension Dependencies Analysis 57 + 58 + ### Critical Extension-Specific APIs 59 + 60 + The overlay uses these extension-specific features that need replacement: 61 + 62 + #### 1. Messaging (`messaging.ts`) 63 + ```typescript 64 + // Current: Extension messaging 65 + sendMessage('checkSession', undefined) 66 + sendMessage('getUserTags', { did: session.did }) 67 + sendMessage('getTrendingTags', undefined) 68 + sendMessage('getAnnotations', { url: getPageUrl() }) 69 + sendMessage('createAnnotation', { ... }) 70 + sendMessage('convertHighlightToAnnotation', { ... }) 71 + sendMessage('updateBadge', { count }) 72 + ``` 73 + 74 + **Replacement**: Direct API calls to `https://margin.at/api/*` 75 + 76 + #### 2. Storage (`storage.ts`) 77 + ```typescript 78 + // Current: WXT extension storage 79 + export const overlayEnabledItem = storage.defineItem<boolean>('local:overlayEnabled', { fallback: true }); 80 + export const themeItem = storage.defineItem<'light' | 'dark' | 'system'>('local:theme', { fallback: 'system' }); 81 + export const apiUrlItem = storage.defineItem<string>('local:apiUrl', { fallback: 'https://margin.at' }); 82 + ``` 83 + 84 + **Replacement**: localStorage with reactive wrapper 85 + 86 + #### 3. Cookies (`api.ts`) 87 + ```typescript 88 + // Current: Extension cookie API 89 + const cookie = await browser.cookies.get({ 90 + url: apiUrl, 91 + name: 'margin_session', 92 + }); 93 + ``` 94 + 95 + **Replacement**: 96 + - For same-origin: `document.cookie` or `fetch` with `credentials: 'include'` 97 + - For cross-origin: The API already uses `credentials: 'include'`, cookies will work if CORS allows 98 + 99 + #### 4. Runtime Messaging (`overlay.ts`) 100 + ```typescript 101 + // Current: Listen for messages from background 102 + browser.runtime.onMessage.addListener((message) => { 103 + if (message.type === 'SHOW_INLINE_ANNOTATE') { ... } 104 + if (message.type === 'REFRESH_ANNOTATIONS') { ... } 105 + }); 106 + ``` 107 + 108 + **Replacement**: Custom event system or postMessage 109 + 110 + #### 5. Context Invalidation (`overlay.ts`) 111 + ```typescript 112 + ctx.onInvalidated(() => { 113 + // cleanup event listeners 114 + }); 115 + ``` 116 + 117 + **Replacement**: Simple cleanup function export 118 + 119 + ## Recommended Approach: Vite Library Build 120 + 121 + Since WXT already uses Vite internally, we can add a parallel Vite config for the standalone bundle with minimal project changes. 122 + 123 + ### Advantages 124 + - Reuses existing Vite infrastructure 125 + - No new bundler dependency 126 + - TypeScript support out of box 127 + - Tree-shaking for smaller bundle 128 + - Can share code with extension build 129 + 130 + ### Proposed Changes 131 + 132 + #### 1. Create Standalone Entry Point 133 + 134 + **New file: `extension/src/standalone/index.ts`** 135 + ```typescript 136 + import { initStandaloneOverlay } from './overlay-standalone'; 137 + 138 + // Auto-initialize when script loads 139 + if (document.readyState === 'loading') { 140 + document.addEventListener('DOMContentLoaded', () => initStandaloneOverlay()); 141 + } else { 142 + initStandaloneOverlay(); 143 + } 144 + 145 + // Also expose globally for manual control 146 + (window as any).MarginOverlay = { 147 + init: initStandaloneOverlay, 148 + refresh: () => window.dispatchEvent(new CustomEvent('margin:refresh')), 149 + destroy: () => window.dispatchEvent(new CustomEvent('margin:destroy')), 150 + }; 151 + ``` 152 + 153 + #### 2. Create Adapter Modules 154 + 155 + **New file: `extension/src/standalone/adapters/api.ts`** 156 + ```typescript 157 + import type { MarginSession, Annotation, TextSelector } from '@/utils/types'; 158 + 159 + const API_URL = 'https://margin.at'; 160 + 161 + async function apiRequest(path: string, options: RequestInit = {}): Promise<Response> { 162 + const headers: Record<string, string> = { 163 + 'Content-Type': 'application/json', 164 + ...(options.headers as Record<string, string>), 165 + }; 166 + 167 + return fetch(`${API_URL}/api${path}`, { 168 + ...options, 169 + headers, 170 + credentials: 'include', 171 + }); 172 + } 173 + 174 + export async function checkSession(): Promise<MarginSession> { 175 + const res = await fetch(`${API_URL}/auth/session`, { credentials: 'include' }); 176 + if (!res.ok) return { authenticated: false }; 177 + const data = await res.json(); 178 + return { 179 + authenticated: true, 180 + did: data.did, 181 + handle: data.handle, 182 + }; 183 + } 184 + 185 + export async function getAnnotations(url: string): Promise<Annotation[]> { 186 + const res = await fetch(`${API_URL}/api/targets?source=${encodeURIComponent(url)}`); 187 + if (!res.ok) return []; 188 + const data = await res.json(); 189 + return [...(data.annotations || []), ...(data.highlights || []), ...(data.bookmarks || [])]; 190 + } 191 + 192 + export async function createAnnotation(data: { 193 + url: string; 194 + text: string; 195 + title?: string; 196 + selector?: TextSelector; 197 + tags?: string[]; 198 + }): Promise<{ success: boolean; data?: Annotation; error?: string }> { 199 + const res = await apiRequest('/annotations', { 200 + method: 'POST', 201 + body: JSON.stringify(data), 202 + }); 203 + if (!res.ok) return { success: false, error: await res.text() }; 204 + return { success: true, data: await res.json() }; 205 + } 206 + 207 + export async function getUserTags(did: string): Promise<string[]> { 208 + const res = await apiRequest(`/users/${did}/tags?limit=50`); 209 + if (!res.ok) return []; 210 + return (await res.json()).map((t: { tag: string }) => t.tag); 211 + } 212 + 213 + export async function getTrendingTags(): Promise<string[]> { 214 + const res = await apiRequest('/trending-tags?limit=50'); 215 + if (!res.ok) return []; 216 + return (await res.json()).map((t: { tag: string }) => t.tag); 217 + } 218 + 219 + export async function convertHighlightToAnnotation(data: { 220 + highlightUri: string; 221 + url: string; 222 + text: string; 223 + title?: string; 224 + selector?: TextSelector; 225 + }): Promise<{ success: boolean; error?: string }> { 226 + const createResult = await createAnnotation({ 227 + url: data.url, 228 + text: data.text, 229 + title: data.title, 230 + selector: data.selector, 231 + }); 232 + 233 + if (!createResult.success) { 234 + return { success: false, error: createResult.error }; 235 + } 236 + 237 + // Delete highlight 238 + const rkey = data.highlightUri.split('/').pop(); 239 + if (rkey) { 240 + await apiRequest(`/highlights?rkey=${rkey}`, { method: 'DELETE' }); 241 + } 242 + 243 + return { success: true }; 244 + } 245 + ``` 246 + 247 + **New file: `extension/src/standalone/adapters/storage.ts`** 248 + ```typescript 249 + type ChangeListener<T> = (newValue: T) => void; 250 + 251 + class LocalStorageItem<T> { 252 + private key: string; 253 + private fallback: T; 254 + private listeners: Set<ChangeListener<T>> = new Set(); 255 + 256 + constructor(key: string, fallback: T) { 257 + this.key = key; 258 + this.fallback = fallback; 259 + 260 + // Listen for storage changes from other tabs 261 + window.addEventListener('storage', (e) => { 262 + if (e.key === key && e.newValue !== null) { 263 + const value = this.parseValue(e.newValue); 264 + this.listeners.forEach(l => l(value)); 265 + } 266 + }); 267 + } 268 + 269 + private parseValue(value: string | null): T { 270 + if (value === null) return this.fallback; 271 + try { 272 + return JSON.parse(value); 273 + } catch { 274 + return this.fallback; 275 + } 276 + } 277 + 278 + async getValue(): Promise<T> { 279 + return this.parseValue(localStorage.getItem(this.key)); 280 + } 281 + 282 + async setValue(value: T): Promise<void> { 283 + localStorage.setItem(this.key, JSON.stringify(value)); 284 + this.listeners.forEach(l => l(value)); 285 + } 286 + 287 + watch(callback: ChangeListener<T>): () => void { 288 + this.listeners.add(callback); 289 + return () => this.listeners.delete(callback); 290 + } 291 + } 292 + 293 + export const overlayEnabledItem = new LocalStorageItem('margin-overlay-enabled', true); 294 + export const themeItem = new LocalStorageItem<'light' | 'dark' | 'system'>('margin-theme', 'system'); 295 + ``` 296 + 297 + #### 3. Create Standalone Overlay Wrapper 298 + 299 + **New file: `extension/src/standalone/overlay-standalone.ts`** 300 + ```typescript 301 + import { overlayStyles } from '@/utils/overlay-styles'; 302 + import { DOMTextMatcher } from '@/utils/text-matcher'; 303 + import type { Annotation } from '@/utils/types'; 304 + import { APP_URL } from '@/utils/types'; 305 + import * as api from './adapters/api'; 306 + import { overlayEnabledItem, themeItem } from './adapters/storage'; 307 + 308 + // Inline icons (same as overlay.ts) 309 + const Icons = { /* ... copy from overlay.ts ... */ }; 310 + 311 + let cleanupFns: (() => void)[] = []; 312 + 313 + export async function initStandaloneOverlay() { 314 + // Same logic as initContentScript but: 315 + // - Uses api.* instead of sendMessage() 316 + // - Uses storage adapters instead of WXT storage 317 + // - Uses window events instead of browser.runtime 318 + // - No ctx.onInvalidated, just track cleanup functions 319 + 320 + // ... implementation mirrors overlay.ts ... 321 + 322 + // Return cleanup function 323 + return () => { 324 + cleanupFns.forEach(fn => fn()); 325 + cleanupFns = []; 326 + }; 327 + } 328 + ``` 329 + 330 + #### 4. Vite Config for Standalone Build 331 + 332 + **New file: `extension/vite.standalone.config.ts`** 333 + ```typescript 334 + import { defineConfig } from 'vite'; 335 + import { resolve } from 'path'; 336 + 337 + export default defineConfig({ 338 + build: { 339 + lib: { 340 + entry: resolve(__dirname, 'src/standalone/index.ts'), 341 + name: 'MarginOverlay', 342 + fileName: 'margin-overlay', 343 + formats: ['iife'], // Immediately-invoked for bookmarklet 344 + }, 345 + outDir: '../dist-standalone', 346 + emptyOutDir: true, 347 + minify: 'terser', 348 + sourcemap: false, 349 + }, 350 + resolve: { 351 + alias: { 352 + '@': resolve(__dirname, 'src'), 353 + }, 354 + }, 355 + define: { 356 + 'process.env.NODE_ENV': '"production"', 357 + }, 358 + }); 359 + ``` 360 + 361 + #### 5. Package.json Scripts 362 + 363 + Add to `extension/package.json`: 364 + ```json 365 + { 366 + "scripts": { 367 + "build:standalone": "vite build --config vite.standalone.config.ts", 368 + "build:all": "wxt build && pnpm build:standalone" 369 + } 370 + } 371 + ``` 372 + 373 + ## Alternative: esbuild Approach 374 + 375 + If minimizing tooling is preferred, esbuild can be used directly: 376 + 377 + **New file: `extension/build-standalone.ts`** 378 + ```typescript 379 + import * as esbuild from 'esbuild'; 380 + 381 + await esbuild.build({ 382 + entryPoints: ['src/standalone/index.ts'], 383 + bundle: true, 384 + minify: true, 385 + format: 'iife', 386 + globalName: 'MarginOverlay', 387 + outfile: '../dist-standalone/margin-overlay.min.js', 388 + alias: { 389 + '@': './src', 390 + }, 391 + external: [], // bundle everything 392 + }); 393 + ``` 394 + 395 + **Script:** `"build:standalone": "node build-standalone.ts"` 396 + 397 + ## Bundle Size Considerations 398 + 399 + | Component | Est. Size (minified) | 400 + |-----------|---------------------| 401 + | overlay.ts logic | ~15KB | 402 + | overlay-styles.ts CSS | ~8KB | 403 + | text-matcher.ts | ~5KB | 404 + | types.ts | ~1KB | 405 + | Adapter modules | ~3KB | 406 + | **Total** | **~32KB** | 407 + 408 + This is reasonable for a bookmarklet loader that fetches the full bundle from a CDN. 409 + 410 + ## File Structure Summary 411 + 412 + ``` 413 + extension/ 414 + ├── src/ 415 + │ ├── standalone/ # NEW 416 + │ │ ├── index.ts # Entry point 417 + │ │ ├── overlay-standalone.ts # Adapted overlay 418 + │ │ └── adapters/ 419 + │ │ ├── api.ts # Direct API calls 420 + │ │ └── storage.ts # localStorage adapter 421 + │ ├── utils/ # Existing (unchanged) 422 + │ └── entrypoints/ # Existing (unchanged) 423 + ├── vite.standalone.config.ts # NEW 424 + ├── wxt.config.ts # Existing (unchanged) 425 + └── package.json # Add build:standalone script 426 + ``` 427 + 428 + ## Implementation Checklist 429 + 430 + 1. [ ] Create `src/standalone/` directory structure 431 + 2. [ ] Implement `adapters/api.ts` with direct fetch calls 432 + 3. [ ] Implement `adapters/storage.ts` with localStorage 433 + 4. [ ] Create `overlay-standalone.ts` adapted from `overlay.ts` 434 + 5. [ ] Create `index.ts` entry point with auto-init 435 + 6. [ ] Add `vite.standalone.config.ts` 436 + 7. [ ] Add `build:standalone` script to package.json 437 + 8. [ ] Test bundle loads and initializes correctly 438 + 9. [ ] Create bookmarklet loader snippet 439 + 440 + ## Bookmarklet Loader Snippet 441 + 442 + ```javascript 443 + javascript:(function(){ 444 + var s=document.createElement('script'); 445 + s.src='https://cdn.margin.at/overlay/margin-overlay.min.js'; 446 + s.onload=function(){MarginOverlay.init()}; 447 + document.head.appendChild(s); 448 + })(); 449 + ``` 450 + 451 + ## Summary 452 + 453 + The recommended approach uses a parallel Vite build with adapter modules to decouple the overlay from extension-specific APIs. This requires: 454 + 455 + - **No changes** to existing extension code 456 + - **~400 lines** of new adapter code 457 + - **1 new config file** (vite.standalone.config.ts) 458 + - **1 new npm script** (build:standalone) 459 + 460 + The standalone bundle will be ~32KB minified and can be loaded via a simple bookmarklet that injects the script and initializes it.