Extension

Chrome Manifest V3 extension. Version 0.15.0, shipped in two variants (see Build variants).

Entry Points

File Role
content.ts Injected into every page, captures DOM events
background.ts Service worker, manages sessions and WebSocket
popup.ts Extension popup with tabs (Status, Trails, About)
sidepanel.ts Side panel with trail replay engine
trail-db.ts IndexedDB wrapper for local trail persistence
config.ts Constants (host URL, throttle values)
types.ts Shared TypeScript interfaces
blincr-picker.ts "Blincr this element" picker + create form
capture-policy.ts Capture boundary: deny/nav-only classification, URL auth-param stripping, sensitive-field rules (pure, unit-tested)
crypto.ts E12.3.1 keyring: Web Crypto AES-GCM content keys, per-trail HKDF derivation, recovery-code export/import
transform.ts E12.7 locality-ladder resolver for future Lens AI transforms (P8/E8.3, unbuilt) — no dispatcher yet, nothing to dispatch to

Content Script

Initializes once per page (guards with __blincr_injected flag). Registers:

  • Click capture — Records element tag, text, href, CSS selector path
  • Scroll capture — Throttled by SCROLL_THROTTLE_MS, records scroll position
  • Selection capture — Fires on selectionchange, records selected text
  • Clipboard capture — Tracks copy events with copied text content
  • SPA navigation — Listens to popstate and History API monkey-patches
  • Overlay shell — Injects the overlay manager container into the page

Events are sent to background via chrome.runtime.sendMessage as BROWSE_EVENT.

Background Service Worker

Manages session lifecycle:

  • START_SESSION — Creates a new DO session, connects WebSocket
  • JOIN_SESSION — Joins existing session by ID
  • STOP_SESSION — Disconnects WebSocket, saves trail locally
  • GET_STATUS — Returns current session state
  • BROWSE_EVENT — Forwards event to WebSocket (if live) and stores locally
  • GET_TRAIL — Returns current trail from memory
  • REPLAY_COMMAND — Forwards replay controls to side panel
  • ADD_WAYPOINT — Inserts a named waypoint event into the trail
  • GET_FINGERPRINT — Returns persistent install ID from chrome.storage.local
  • GET_OVERLAYS / ACTIVATE_COMMENT_MODE — Overlay plugin coordination
  • CREATE_BLINCR — Authenticated POST /api/user/blincrs (see Blincrs below)

Capture boundary (E12.2)

The cheapest data to protect is data never captured. Every page gets one of three capture modes, decided in capture-policy.ts (pure module, tested in tests/capture-policy.test.ts) and enforced at two layers — the content script attaches no capture listeners at all on protected pages (pause is physical), and background.ts's sendBrowseEvent re-checks the policy so no relay path can bypass it:

  • deny — built-in categories (banking & payments, health portals, government & identity, adult) plus any site the user marked "Never capture here" in the popup. Nothing is captured; during an active session a dismissible "capture paused" badge shows the boundary working.
  • nav_only — personal-workspace domains (webmail, chat: Gmail, Outlook, WhatsApp Web, Slack, Teams, …) where page content is largely other people's data. Only URL + title navigation events are captured — no fingerprint, clicks, scrolls, selections, or clipboard.
  • full — normal capture, hardened: auth-shaped query/fragment params (token, access_token, sig, api_key, code, …) and URL userinfo are stripped from every captured URL; clicks on password / hidden / cc-* / one-time-code fields record only {x, y, redacted: true}; and INPUT/TEXTAREA/contenteditable text never enters click payloads.

Settings live in chrome.storage.local (blincr_capture_policy): user never-capture sites, extra workspace domains, disabled categories. The popup shows the current tab's classification and a one-click "Never capture on " toggle; the background relay picks changes up via storage.onChanged, the content script on next injection.

Blincrs ("blincr this element")

A blincr is a standing page watcher. Signed-in users get cloud blincrs (account-owned, run 24/7 by the watch-worker cron); signed-out users get local blincrs (E14.1 — see below), which run entirely in the browser. Entry point: popup button "👁 Blincr this element" → chrome.tabs.sendMessage(tabId, { kind: "ACTIVATE_BLINCR_MODE" }) → content.ts → blincr-picker.ts.

  • Picker (activateBlincrPicker()): hover-highlights the element under the cursor; a click builds a CSS selector (reusing content.ts's buildSelector), guesses a recipe (price-looking text → lt comparator with a threshold at 90% of the parsed value; otherwise changed), and opens a small form (label, comparator, threshold, check frequency).
  • Create: the form sends { kind: "CREATE_BLINCR", input } to background.ts, which calls createBlincrRemote().

Auth bridge (why cookies permission is needed)

The extension has no session of its own — it piggybacks the user's existing blincr.com login. The dashboard's session cookie is HttpOnly + SameSite=Lax, which the browser never attaches to a chrome-extension:// origin's fetch (that origin counts as cross-site). So background.ts reads the cookie value directly via chrome.cookies.get({ url: API_BASE, name: "blincr_session" }) — the cookies permission plus the existing <all_urls> host permission make this a same-product read, not a third-party leak — and forwards it as an X-Blincr-Ext-Session header. getSessionToken() in src/ar.ts accepts either the cookie or this header. If no cookie is found (user not logged in on blincr.com), the watcher is saved locally instead (see below) — the toast explains the trade-off.

Local blincrs (E14.1 — autonomy ladder L0)

See docs/autonomy-ladder.md for the full doctrine. In the picker, every watcher gets an explicit "Run where?" choice — "In this browser (private, only while open)" (default) or "On blincr.com (24/7, needs sign-in)" — with the trade-off shown live underneath. background.ts (handleCreateBlincr) honors that choice: local always saves in-browser; cloud requires sign-in and returns a named error (blincr.watch.not_authenticated) rather than silently downgrading to local. Older callers that send no choice keep the original best-effort-cloud-else-local fallback.

  • Engine: the shared platform-neutral core (extension/src/blincr-engine-core.ts — compare, intervals, regex/JSON-LD extraction; the workers re-export it via src/blincr-engine.ts). Local state transitions (extension/src/blincr-local.ts, pure, unit-tested) mirror the cron runner: baseline-not-change, 6h alert cooldown, sight-lost after 5 consecutive failures.
  • Scheduler: a persistent chrome.alarms alarm (blincr-local-checks, every 5 min) wakes the service worker, which fetches due pages itself (<all_urls> host permission) and honors each watcher's interval bucket.
  • CSS extraction: MV3 service workers have no DOM, so an offscreen document (offscreen/offscreen.html + dist/offscreen.js, offscreen permission, reason DOM_PARSER) answers OFFSCREEN_CSS_EXTRACT requests with DOMParser + querySelector. Regex and JSON-LD recipes run inline.
  • Alerts: chrome.notifications (notifications permission) plus a per-watcher history (last 20) shown in the popup's Trails tab, where local watchers can be paused, resumed, deleted, or upgraded (☁ button → LOCAL_BLINCR_UPGRADE re-creates the watcher on blincr.com and deletes the local copy only on success — a failed upgrade leaves it untouched).
  • Limits, by design: checks run only while the browser is open; no webhooks or agent handoff (those are the cloud tier). The choice UI says exactly that — informed choice, no silent upsell.

Popup

Three-tab interface:

  1. Status — Session controls (start/stop/join), connection indicator, participant list
  2. Trails — Local trail library from IndexedDB with search, folder management
  3. About — Platform info, version badge (__VERSION__, __BUILD_DATE__)

Record agent session (E13.4): a checkbox next to Start explicitly labels the session as agent browsing. The toolbar badge turns purple ("AI"), the owner participant is saved with role: "agent", and the finished trail gets isAgent: true and is auto-filed into the "Agent sessions" folder — it never appears in the popup's recent-trails list. Labeling is explicit only; no cadence heuristics decide for the user.

Side Panel

Opens via chrome.sidePanel.open(). Contains the trail replay engine with play/pause, step forward/back, speed control, skip-to-next-page, and seek.

The library's trail detail view offers Replay, Export (raw .blincr file), For AI (agent-ready blincr-trail-bundle/1 export — JSON with full events plus a Markdown digest, built entirely client-side so it works for every visibility tier; see docs/trail-bundle.md), Upload (E12.3.2 encrypted backup — works for any trail now, not just ones from a live co-browse session), Share privately (E12.3.3 — mints a T2 link and copies it, key fragment included, to the clipboard), and Delete.

The library toolbar's Recovery key button opens keyring management: generate or re-show the device's recovery code, import one from another device, and "Restore backups from cloud" (pulls any T1/T2 backup not already stored locally, decrypting with the imported keyring).

E2EE keyring & backup (E12.3)

  • crypto.ts — see the entry-points table above. One master secret per device; every trail's content key is derived via HKDF, never stored per-trail. formatRecoveryCode/fromBase32 handle the human-facing recovery code; encryptTrail/decryptTrail handle the AES-GCM envelope ({v, iv, ciphertext}, all base64); exportKeyForFragment produces the base64url key for a T2 share link's #k= fragment.
  • background.ts message kinds: DB_UPLOAD_TRAIL (rewritten — encrypts and uploads any trail, not just live-session ones), DB_SHARE_PRIVATE, DB_RESTORE_BACKUPS, DB_HAS_KEYRING, DB_GENERATE_RECOVERY_CODE, DB_CURRENT_RECOVERY_CODE, DB_IMPORT_RECOVERY_CODE.
  • Server API: docs/backend-api.md (POST /api/user/trails/backup, GET /api/user/trails/:id/backup, POST /api/user/trails/:id/share-private).
  • LocalTrail.cloudTrailId (trail-db.ts) tracks the server-side cloud_trails row id once a trail has been backed up, so share/restore calls can address the right row.

Crawl trails (E17)

A crawl item is a placeable overlay item anchored to a filtered list page. It holds a recipe and an Execute button, and turns that list into a repeatable, resumable extraction that runs entirely in the user's own browser under the user's own session. Nothing reaches blincr's backend: crawl output is T0.

Modules (extension/src/crawl/):

File Role
dom.ts Everything that touches the crawled page — row generalisation, relative selectors, extraction, one pagination step
driver.ts The run loop in the service worker: phases, navigation, checkpointing, images, cancellation
page-agent.ts Content-script handler for one CRAWL_PAGE_OP
politeness.ts Per-domain governor enforcing MASTER-PLAN §4.13
robots.ts robots.txt parse, longest-match-wins, Crawl-delay
identity.ts Row identity across runs; URL normalisation; FNV-1a hashing
merge.ts Re-run reconciliation — add / change / soft-mark gone
store.ts Recipe, run, row and image persistence
export.ts JSON envelope, filename, stored-only ZIP writer
capabilities.ts Optional-permission probe and request
bridge.ts Message routing, trail recording, downloads

Run phases. paginating → listing → detailing → done. Pagination is exhausted first, because on an ajax list extracting as you scroll means extracting a partial list. The loop lives in the driver, not the page: a crawl tab is usually unfocused, and Chrome throttles hidden-tab timers to roughly once a minute, so an in-page while (…) await sleep(3000) would stall exactly when the feature is doing its job. Driver-side looping also routes every cycle through the politeness governor.

Politeness. MASTER-PLAN §4.6 (≥60s/domain) governs unattended blincr watchers. A crawl is a different actor and has its own budget under §4.13: jittered 2–4s by default, per-recipe configurable, hard floor 1s, one request at a time per domain, effective rate shown to the user. robots.txt can slow a crawl down and never speed it up. robots is honored unless the user marks the origin as one they are authorised on.

Row identity decides both resume and re-run, so the strategy is stored on every row and shown in the recipe panel. Preference order: element id → stable data-* attribute → detail URL → user-marked key field → content hash. Volatile candidates (data-index, data-reactid, bare small integers, UUIDs) are rejected — they look stable within one run and break every re-run.

Resume. Every row is written to IndexedDB as it completes, never batched. The detail pass skips rows that already carry detail, so resume is the normal path rather than crash-recovery code. On resume the user is asked whether to re-verify the list or trust the stored row set — blincr does not choose.

Re-run never deletes. A row that has vanished is soft-marked with goneAt and kept; a reappearing row clears the mark. The first-disappeared timestamp is preserved across later runs.

Output is JSON (crawlSchema: 1). The file is written from a page context (crawl/download.ts: URL.createObjectURL + <a download>), which needs no permission — the service worker returns bytes rather than calling chrome.downloads.download, the one API that would require one. Images are collected as URLs by default, or downloaded into a content-hash-deduplicated zip with a manifest.json on the build that carries unlimitedStorage. xlsx is a later converter over the same envelope.

Build variants

unlimitedStorage cannot be an optional permission — Chrome grants storage quota at install time and rejects it in optional_permissions, so there is no runtime grant to offer and an "Enable image storage" button would be a promise Chrome refuses to keep. Whether a user can keep image files is therefore a property of the build they installed, and blincr ships two:

Variant unlimitedStorage Images
pkg/blincr-full — "Blincr Co-Browse" yes URLs and image files
pkg/blincr-lite — "Blincr Co-Browse Lite" no image URLs only
cd extension && npm run package     # build.js + package-variants.js

The JavaScript is byte-identical in both. Nothing is compiled per variant: crawl/capabilities.ts reads the live manifest via chrome.runtime.getManifest(), so a build can never disagree with what the code believes it may do. Root manifest.json is the canonical full variant and the lite manifest is derived from it — never the reverse — which keeps local development and the Playwright suite on the full build. package-variants.js refuses to run if the root manifest has drifted (no unlimitedStorage in permissions, or it turning up in optional_permissions), and tests/extension-manifest.test.ts guards the same invariant in CI-able form.

Editing a recipe (crawl/recipe-diff.ts): the crawl panel's ✎ Edit reopens the authoring form hydrated from the stored recipe. A cosmetic change (label, friendly name, delay, budgets, robots flag) saves silently. A structural one (renamed machineName, a field reading a different element or attribute, a changed transform, row selector, list URL, pagination, key field or detail link) invalidates what was collected: the user is told "You need to run the extraction again after this change" with the exact run and row counts that will be cleared, and on confirm they are. No aliasing, no migration — an export whose columns silently disagree with its header is worse than losing a run the user can repeat.

A run is a trail. The driver emits real BrowseEvents, seeded with the pre-crawl navigation (the filter-setting walk that produced the list URL) and closed with the collected rows. A recipient replays how the result set was reached and sees the rows without re-running anything.

Anchored item persistence (E15.11) and the locator (E18)

Items persist without a session. sendOverlayEvent posts a BROWSE_EVENT, and background.ts's sendBrowseEvent begins by dropping everything when no co-browse session is active. Overlay events are therefore written to the overlays store before that gate (extension/src/overlay-store.ts), and GET_OVERLAYS answers from the store, merged with the live session's materialised items (remote participants' overlays arrive over the relay and are not stored; stored items win on conflict). The store is the record, the relay is distribution. Overlays are T0 under MASTER-PLAN.md §4.10 — local-first, never uploaded by this path.

Arrival band (plugins/arrival-band.ts): on load, if the store reports items for this exact URL, a top band names the count and the authors, with "Show me" (reveals the rail and jumps to the first item) and "Dismiss" (remembered per URL for 14 days). It reads the store rather than the live registry so it can answer before anything has rendered.

Locator rail (plugins/locator.ts + plugins/locator-layout.ts): a fixed column of marks down the right edge, one per anchored item, ordered by the anchor's document position. Item types are told apart by ring colour — the LED never varies, only the ring (watcher red, crawl sky, comment blue, note copper). Keyboard: roving tabindex, so the rail is one tab stop and Arrow/Home/End move within it. Reserves a tenth of the viewport top and bottom, wraps into further columns leftward (capped at 3), highlights the entry currently in view, greys an entry whose anchor no longer resolves, and scrolls to the item with a pulse on click. Fed from the overlay manager's live registry, never from storage — the rail can then never list an item the page is not actually rendering. Hidden entirely when the page has no items, on capture-denied pages, and in crawl driver tabs.

Re-anchoring (plugins/anchor-handle.ts): each item carries a ⚓ handle. Dragging the item nudges its offset and keeps its anchor (E15.7); dragging the handle outlines the element under the cursor and re-anchors on release, with Esc to cancel and a drop on empty space detaching to viewport strategy. The item keeps its on-screen position across a re-anchor — the gesture changes what it is attached to, not where it sits.

The wrapping arithmetic is a pure function in locator-layout.ts (tests/locator-layout.test.ts); everything else is covered by tests-e2e/locator.spec.ts.

Trail Database (IndexedDB)

Database name: blincr-trails, version 4.

Object stores:

  • trails (keyPath: trailId) — Indexes: createdAt, folderId, title, syncStatus. cloudTrailId (optional) links a synced trail to its cloud_trails row.
  • folders (keyPath: folderId) — Indexes: parentId, name
  • crawlRecipes (keyPath: recipeId) — Indexes: listUrl, updatedAt (v2)
  • crawlRuns (keyPath: runId) — Indexes: recipeId, startedAt (v2)
  • crawlRows (keyPath: [runId, identity]) — Index: runId (v2)
  • crawlImages (keyPath: hash) — deduplicated image blobs (v2)
  • overlays (keyPath: overlayId) — Indexes: url, updatedAt. Anchored items, independent of any session (v3)

Every upgrade step is purely additive and guarded by a contains check, so an older database gains the new stores and keeps everything already in it.

Operations: CRUD for trails and folders, search by title/domain/tag, split, merge, export (single or full backup), import.

Overlay Plugin System

Extensible annotation layer managed by overlay-manager.ts.

  • Plugins register via registerPlugin() and provide render, update, remove, and createInputUI methods.
  • Overlays are positioned by viewport coordinates or CSS selector + offset.
  • Persisted as overlay events in the trail.

Built-in plugins:

  • comment — Persistent page annotations with five styles (default, highlight, question, warning, success).
  • site-comment — Resizable rich-text box for longer commentary (E15.2.2).
  • blincr-marker — Visual anchor for a standing page watcher (E15.3).
  • crawl-item — Recipe + Execute button for a list-page crawl (E17.1.2). The recipe lives in IndexedDB keyed by recipeId; only a thin summary rides in the overlay data, so a 30-field recipe never replicates through the co-browse relay on every update.

Build

cd extension && node build.js

Uses esbuild. Injects __VERSION__ and __BUILD_DATE__ via define. Output goes to extension/dist/. Manifest version must be bumped on every source change.

dist/ is committed (2026-08-13). The manifest points at dist/background.js and dist/content.js, so an ignored dist/ means a fresh clone cannot load the extension at all — Chrome reports "Could not load background script", then "Could not load manifest". While blincr is hand-tested on one machine and moved by push/pull, loading straight from a clone is worth more than keeping generated files out of git.

The two costs are mitigated rather than accepted silently:

  • Stale bundles. tests/extension-dist.test.ts fails if the committed bundle does not contain the manifest's current version. Since any change under extension/ must bump that version, "version present in the bundle" is an exact proxy for "built from this source". Verified by deliberately bumping without rebuilding — it fails with the rebuild command in the message.
  • Diff noise. .gitattributes marks dist/** generated. A conflict there is resolved by rebuilding, never by merging.

Revisit when CI publishes release artifacts.