···11-{"id":"seams.so-iji","title":"Update AT Protocol OAuth config with Chrome Web Store extension ID","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-11-14T20:54:15.969349171-08:00","updated_at":"2025-11-14T21:09:37.328022945-08:00"}
···11-{"id":"seams.so-iji","content_hash":"3f105051d1c42a39ebc3af2d850ed6b60a58bfefa2753ce246692a44b3f57ab3","title":"Update AT Protocol OAuth config with Chrome Web Store extension ID","description":"","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-11-14T20:54:15.969349171-08:00","updated_at":"2025-11-14T21:09:37.328022945-08:00","source_repo":"."}
···11+{"id":"seams.so-btm","content_hash":"cc23540734a41c32c1bfab62a7d682eeda62166167d038a47afc22dea5626273","title":"Implement URL Share Intent for Proxy PWA","description":"","status":"open","priority":2,"issue_type":"feature","created_at":"2025-11-18T23:26:19.717576506-08:00","updated_at":"2025-11-18T23:26:19.717576506-08:00","source_repo":"."}
22+{"id":"seams.so-iji","content_hash":"3f105051d1c42a39ebc3af2d850ed6b60a58bfefa2753ce246692a44b3f57ab3","title":"Update AT Protocol OAuth config with Chrome Web Store extension ID","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-14T20:54:15.969349171-08:00","updated_at":"2025-11-14T21:10:31.150138092-08:00","closed_at":"2025-11-14T21:10:31.150138092-08:00","source_repo":"."}
33+{"id":"seams.so-rlq","content_hash":"1812949c8de2a7f3465bee85295f92c3d923568d47e9b4df15dc25c01ddc798f","title":"Configure Proxy Service as PWA","description":"","status":"open","priority":2,"issue_type":"feature","created_at":"2025-11-18T23:26:16.152745718-08:00","updated_at":"2025-11-18T23:26:16.152745718-08:00","source_repo":"."}
44+{"id":"seams.so-s8c","content_hash":"be3aca226e4e9749d46f83c0efa735455b25a44d019b4a005bc0669acb0f3596","title":"Ensure persistent login for Proxy PWA","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-11-18T23:26:19.859051558-08:00","updated_at":"2025-11-18T23:26:19.859051558-08:00","source_repo":"."}
55+{"id":"seams.so-vp1","content_hash":"3ba0850cb5f709e86796104a6804d665d1de9f9eddb63f919728ad711f8d4ed6","title":"Add Shadow DOM wrapper for sidebar iframe container","description":"","status":"open","priority":3,"issue_type":"task","created_at":"2025-11-15T02:07:42.906585543-08:00","updated_at":"2025-11-15T02:07:42.906585543-08:00","source_repo":"."}
···11// OAuth callback handler for via-client
22-import { handleOAuthCallback } from '@/lib/oauth-web';
22+import { handleOAuthCallback, WebStorageAdapter } from '@seams/core';
3344console.log('[oauth-callback] Processing OAuth callback');
55···7788async function processCallback() {
99 try {
1010- const session = await handleOAuthCallback();
1010+ const storage = new WebStorageAdapter();
1111+ const config = {
1212+ clientId: import.meta.env.VITE_OAUTH_CLIENT_ID || 'https://seams.so/oauth/client-metadata.json',
1313+ redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI || 'https://sure.seams.so/oauth-callback.html',
1414+ scope: import.meta.env.VITE_OAUTH_SCOPE || 'atproto transition:generic',
1515+ };
1616+ console.log('[oauth-callback] Config:', config);
1717+ const session = await handleOAuthCallback(storage, config);
11181219 if (session) {
1320 if (statusEl) statusEl.textContent = 'Login successful! Redirecting...';
1421 console.log('[oauth-callback] Login successful');
15221616- // Redirect back to the page the user was on
1717- // For now, redirect to the proxy home
1818- setTimeout(() => {
1919- window.location.href = '/proxy/https://example.com';
2020- }, 1000);
2323+ // Check if we are in a popup
2424+ if (window.opener) {
2525+ console.log('[oauth-callback] Sending message to opener');
2626+ window.opener.postMessage({
2727+ type: 'SEAMS_OAUTH_CALLBACK',
2828+ url: window.location.href
2929+ }, '*');
3030+3131+ // Close popup after a delay
3232+ setTimeout(() => {
3333+ window.close();
3434+ }, 500);
3535+ } else {
3636+ // Not a popup (mobile or full page redirect)
3737+ // Redirect back to the page the user was on
3838+ // We can't easily know the "previous" page in a full redirect unless we stored it in sessionStorage BEFORE leaving.
3939+ // But the sidebar iframe is where the login started. If we navigated the whole iframe, we lost state.
4040+ // If we navigated the TOP window, we lost state too unless we stored it.
4141+4242+ // The sidebar puts `seams_login_redirect` in sessionStorage before starting?
4343+ // Wait, `startLoginProcess` is called in Sidebar.
4444+4545+ // If we are here, we successfully logged in.
4646+ if (statusEl) statusEl.textContent = 'Login successful! You can close this window.';
4747+4848+ // For now, redirect to the proxy home or stored redirect
4949+ const previousUrl = sessionStorage.getItem('seams_login_redirect') || '/';
5050+ setTimeout(() => {
5151+ window.location.href = previousUrl;
5252+ }, 1000);
5353+ }
2154 } else {
2255 if (statusEl) statusEl.textContent = 'No OAuth response found';
2356 console.log('[oauth-callback] No OAuth response');
···11+# Comprehensive Simplification & Reliability Plan
22+33+Based on the Oracle's review, this plan focuses on simplifying the architecture by enforcing "Storage as Source of Truth," standardizing messaging, and aligning with AT Protocol patterns.
44+55+## Phase 1: Core Protocol & Runtime Definition
66+**Goal:** Establish a shared vocabulary for all components to prevent drift and ad-hoc logic.
77+88+- [ ] **Create `packages/core/src/constants.ts`**
99+ - Move Protocol Constants: `ANNOTATION_COLLECTION`, `COMMENT_COLLECTION`.
1010+ - Define Storage Keys: `STORAGE_KEY_PREFIX` (e.g., `seams:`), `ANNOTATIONS_KEY_PREFIX`.
1111+ - Define XRPC Endpoints: `XRPC_CREATE_RECORD`, `XRPC_GET_RECORD`.
1212+- [ ] **Create `packages/core/src/messages.ts`**
1313+ - Define a strict Union Type for all internal messages:
1414+ - `SYNC_CACHE`: Trigger a sync from backend to storage.
1515+ - `GET_STATE`: Request current selection/auth state.
1616+ - `SELECTION_CHANGED`: Notify that user selected text.
1717+ - `PAGE_URL_CHANGED`: Notify that the URL changed (SPA nav).
1818+ - `LOGIN / LOGOUT`: Auth state changes.
1919+- [ ] **Refactor `packages/core/src/types.ts`**
2020+ - Ensure `Annotation` type clearly distinguishes between "UI Shape" and "ATProto Record Shape" (if they differ).
2121+ - Add strict types for `Selector`s.
2222+2323+## Phase 2: Unifying the Message Bus
2424+**Goal:** Remove ad-hoc `sendMessage` calls and use a type-safe wrapper.
2525+2626+- [ ] **Create `Messenger` class in Core**
2727+ - A simple wrapper around `browser.runtime.sendMessage` (Extension) and `postMessage` (Proxy).
2828+ - Methods: `send(msg: Message)`, `on(type, handler)`.
2929+- [ ] **Refactor Extension to use `Messenger`**
3030+ - Update `background/extension.ts` and `content/extension.ts`.
3131+ - Remove any raw string message matching.
3232+- [ ] **Refactor Proxy to use `Messenger`**
3333+ - Update `via-client/main.ts` and `via-client/sidebar.ts` to match the same protocol.
3434+3535+## Phase 3: Storage-First Enforcement
3636+**Goal:** Decouple UI from Network. The UI should *only* render what is in Storage.
3737+3838+- [ ] **Audit & Refactor `ContentScript`**
3939+ - Verify it *only* renders in response to `storage.onChanged`.
4040+ - Remove any direct "fetch and render" logic triggered by page load (replace with "load from storage, then trigger background sync").
4141+- [ ] **Audit & Refactor `Sidebar`**
4242+ - Ensure it reads annotations from storage, not from a message response.
4343+- [ ] **Standardize Sync Logic**
4444+ - **Extension:** Ensure `BackgroundWorker` is the *only* writer to `annotations:*` storage keys.
4545+ - **Proxy:** Create a simple "Worker" in the client script (or sidebar) that polls the backend and writes to `localStorage`.
4646+4747+## Phase 4: AT Protocol & Backend Alignment
4848+**Goal:** Treat the PDS/Backend interaction as a stable API surface.
4949+5050+- [ ] **Refactor `packages/core/src/pds/index.ts`**
5151+ - Use constants from `constants.ts`.
5252+ - Ensure `createAnnotation` uses the strict Record type.
5353+- [ ] **Align Backend (`server/`)**
5454+ - Update Go structs to match the Core `Annotation` types/constants (manually for now, but strict).
5555+ - Ensure the "Index" endpoint expects exactly what the frontend sends.
5656+5757+## Phase 5: Documentation
5858+- [ ] **Update `README.md` or create `ARCHITECTURE.md`**
5959+ - Document the "Storage Flow" (Diagrams from Oracle).
6060+ - Document the "Message Vocabulary".
···22export type { ContentScriptOptions } from './script';
33export { ExtensionContentScript } from './extension';
44export type { ExtensionContentScriptOptions } from './extension';
55+export * from './mobile';
···44export * from './background';
55export * from './content';
66export * from './utils';
77+export * from './oauth';
88+export * from './oauth/launchers';
99+export * from './pds';
1010+export * from './sidebar';
···11+// TextRange and TextPosition utilities
22+// Adapted from Hypothesis client (BSD/MIT licensed)
33+// https://github.com/hypothesis/client/blob/main/src/annotator/anchoring/text-range.ts
44+55+function nodeTextLength(node: Node): number {
66+ switch (node.nodeType) {
77+ case Node.ELEMENT_NODE:
88+ case Node.TEXT_NODE:
99+ return node.textContent?.length ?? 0;
1010+ default:
1111+ return 0;
1212+ }
1313+}
1414+1515+function previousSiblingsTextLength(node: Node): number {
1616+ let sibling = node.previousSibling;
1717+ let length = 0;
1818+ while (sibling) {
1919+ length += nodeTextLength(sibling);
2020+ sibling = sibling.previousSibling;
2121+ }
2222+ return length;
2323+}
2424+2525+export class TextPosition {
2626+ public element: Element;
2727+ public offset: number;
2828+2929+ constructor(element: Element, offset: number) {
3030+ if (offset < 0) {
3131+ throw new Error('Offset is invalid');
3232+ }
3333+ this.element = element;
3434+ this.offset = offset;
3535+ }
3636+3737+ static fromPoint(node: Node, offset: number): TextPosition {
3838+ switch (node.nodeType) {
3939+ case Node.TEXT_NODE: {
4040+ if (!node.parentElement) {
4141+ throw new Error('Text node has no parent');
4242+ }
4343+ const textOffset = previousSiblingsTextLength(node) + offset;
4444+ return new TextPosition(node.parentElement, textOffset);
4545+ }
4646+ case Node.ELEMENT_NODE: {
4747+ let textOffset = 0;
4848+ for (let i = 0; i < offset; i++) {
4949+ textOffset += nodeTextLength(node.childNodes[i]);
5050+ }
5151+ return new TextPosition(node as Element, textOffset);
5252+ }
5353+ default:
5454+ throw new Error('Node is not an element or text node');
5555+ }
5656+ }
5757+5858+ relativeTo(parent: Element): TextPosition {
5959+ if (!parent.contains(this.element)) {
6060+ throw new Error('Parent is not an ancestor of current element');
6161+ }
6262+6363+ let el = this.element;
6464+ let offset = this.offset;
6565+ while (el !== parent) {
6666+ offset += previousSiblingsTextLength(el);
6767+ el = el.parentElement!;
6868+ }
6969+7070+ return new TextPosition(el, offset);
7171+ }
7272+7373+ resolve(): { node: Text; offset: number } {
7474+ const result = resolveOffsets(this.element, this.offset);
7575+ if (result.length === 0) {
7676+ throw new RangeError('Offset exceeds text length');
7777+ }
7878+ return result[0];
7979+ }
8080+}
8181+8282+function resolveOffsets(
8383+ element: Element,
8484+ ...offsets: number[]
8585+): Array<{ node: Text; offset: number }> {
8686+ let nextOffset = offsets.shift();
8787+ const nodeIter = element.ownerDocument.createNodeIterator(
8888+ element,
8989+ NodeFilter.SHOW_TEXT
9090+ );
9191+ const results: Array<{ node: Text; offset: number }> = [];
9292+9393+ let currentNode = nodeIter.nextNode() as Text | null;
9494+ let textNode: Text | null = null;
9595+ let length = 0;
9696+9797+ while (nextOffset !== undefined && currentNode) {
9898+ textNode = currentNode;
9999+ if (length + textNode.data.length > nextOffset) {
100100+ results.push({ node: textNode, offset: nextOffset - length });
101101+ nextOffset = offsets.shift();
102102+ } else {
103103+ currentNode = nodeIter.nextNode() as Text | null;
104104+ length += textNode.data.length;
105105+ }
106106+ }
107107+108108+ // Boundary case
109109+ while (nextOffset !== undefined && textNode && length === nextOffset) {
110110+ results.push({ node: textNode, offset: textNode.data.length });
111111+ nextOffset = offsets.shift();
112112+ }
113113+114114+ if (nextOffset !== undefined) {
115115+ throw new RangeError('Offset exceeds text length');
116116+ }
117117+118118+ return results;
119119+}
120120+121121+export class TextRange {
122122+ public start: TextPosition;
123123+ public end: TextPosition;
124124+125125+ constructor(start: TextPosition, end: TextPosition) {
126126+ this.start = start;
127127+ this.end = end;
128128+ }
129129+130130+ static fromRange(range: Range): TextRange {
131131+ const start = TextPosition.fromPoint(
132132+ range.startContainer,
133133+ range.startOffset
134134+ );
135135+ const end = TextPosition.fromPoint(range.endContainer, range.endOffset);
136136+ return new TextRange(start, end);
137137+ }
138138+139139+ toRange(): Range {
140140+ let start;
141141+ let end;
142142+143143+ if (
144144+ this.start.element === this.end.element &&
145145+ this.start.offset <= this.end.offset
146146+ ) {
147147+ const resolved = resolveOffsets(
148148+ this.start.element,
149149+ this.start.offset,
150150+ this.end.offset
151151+ );
152152+ start = resolved[0];
153153+ end = resolved[1];
154154+ } else {
155155+ start = this.start.resolve();
156156+ end = this.end.resolve();
157157+ }
158158+159159+ const range = new Range();
160160+ range.setStart(start.node, start.offset);
161161+ range.setEnd(end.node, end.offset);
162162+ return range;
163163+ }
164164+}
+8-2
packages/core/src/utils/selectors/match.ts
···17171818 console.log('[synthesis] Trying to match annotation with', selectors.length, 'selectors');
19192020- // Try each selector in order
2121- for (const selector of selectors) {
2020+ // Try TextQuoteSelector first (position-independent, works after DOM mutations)
2121+ // Then fall back to TextPositionSelector (faster but fragile)
2222+ const quoteSelector = selectors.find(s => s.$type === 'community.lexicon.annotation.annotation#textQuoteSelector');
2323+ const positionSelector = selectors.find(s => s.$type === 'community.lexicon.annotation.annotation#textPositionSelector');
2424+2525+ const selectorsToTry = [quoteSelector, positionSelector].filter(Boolean);
2626+2727+ for (const selector of selectorsToTry) {
2228 let range: Range | null = null;
23292430 console.log('[synthesis] Trying selector type:', selector.$type);
···11import type { PluginOption } from "vite";
22-import metadata from "../public/oauth/client-metadata.json";
22+import metadata from "../landing/oauth/client-metadata.json";
3344type OAuthConfig = {
55 client_id: string;
+30
scripts/postbuild-via.sh
···11+#!/usr/bin/env bash
22+# Post-build script for via proxy - fixes HTML paths and copies to correct location
33+44+set -e
55+66+echo "📝 Post-processing via build..."
77+88+# Copy BUILT HTML files from the nested output directory to static root
99+# Vite outputs to proxy/static/proxy/via-html/ because of input structure
1010+cp proxy/static/proxy/via-html/*.html proxy/static/
1111+1212+# Clean up the nested directory structure created by Vite
1313+rm -rf proxy/static/proxy
1414+1515+# Copy CSS and font files from landing to static root
1616+echo "🎨 Copying shared assets from landing..."
1717+cp landing/landing.css proxy/static/
1818+cp landing/fonts.css proxy/static/
1919+cp landing/landing.js proxy/static/
2020+mkdir -p proxy/static/fonts
2121+cp landing/fonts/* proxy/static/fonts/
2222+2323+# Copy client-metadata.json from landing to static root (proxy shares the same client ID)
2424+cp landing/oauth/client-metadata.json proxy/static/
2525+2626+# Fix asset paths in HTML files (add /static prefix)
2727+# sed -i 's|src="/seams-|src="/static/seams-|g' proxy/static/*.html
2828+# sed -i 's|href="/assets/|href="/static/assets/|g' proxy/static/*.html
2929+3030+echo "✅ Via build post-processing complete"
+2-2
scripts/start-via.sh
···2525 exit 1
2626fi
27272828-# Start pywb in background (must run from pywb-test directory)
2828+# Start pywb in background (must run from proxy directory)
2929echo "📦 Starting pywb on port 8081..."
3030-(cd pywb-test && LD_LIBRARY_PATH="$LD_LIBRARY_PATH" wayback) &
3030+(cd proxy && LD_LIBRARY_PATH="$LD_LIBRARY_PATH" wayback -p 8081) &
3131PYWB_PID=$!
32323333# Wait for pywb to start
+1-1
server/cmd/server/main.go
···6767 r.Get("/health", handler.Health)
68686969 // Serve static files for everything else
7070- publicDir := getEnv("PUBLIC_DIR", "../public")
7070+ publicDir := getEnv("PUBLIC_DIR", "../landing")
7171 log.Printf("Serving static files from %s", publicDir)
72727373 // Create final handler that checks API routes first, then static files