01Abstract

ComparEdge Advisor is a Manifest V3 Chrome extension that passively identifies B2B SaaS and AI tools as the user browses the web, and delivers contextual product intelligence -- pricing, ratings, pros/cons, and competitor alternatives -- without requiring any user action or account creation.

The extension operates on a static content script + pull model: the content script is declared in the manifest and automatically injected by Chrome into every page. Upon page load, the content script queries the background service worker for product data associated with the current domain. If a match exists and the product has not been dismissed by the user, an interactive card is rendered inside an isolated Shadow DOM container.

Design Philosophy

Zero tracking. No telemetry. No remote code execution. No eval(). All logic and product data is bundled locally. The only external network request is a voluntary 48-hour data sync with the ComparEdge API -- which is read-only and contains no user-identifiable information.

02Specifications

Products
508
Active (2 discontinued)
Domain Mappings
508
eTLD+1 entries
Bundled Logos
533
32x32 WebP
Sync Interval
48h
Via chrome.alarms
Dismiss TTL
30d
Per-product, per-browser
Manifest
V3
Chromium 88+
activeTabstoragescriptingtabsalarmshost_permissions: <all_urls>No remote code executionNo eval()

03System Architecture

The extension is composed of four primary components, each with a distinct execution context and responsibility boundary:

BACKGROUND SERVICE WORKER -- background.js
Persistent (within MV3 lifetime) process responsible for domain resolution, data delivery, badge management, and 48h alarm-driven sync. Imports data.js via importScripts(). Handles all chrome.runtime.onMessage events from content scripts and popup.
CONTENT SCRIPT -- content.js + content.css
Statically declared in manifest; injected by Chrome at document_end into every page. Immediately sends CE_READY to background on load. Renders all UI inside an isolated Shadow DOM. Has zero access to page JavaScript context.
POPUP -- popup.html + popup.js + popup.css
Extension toolbar popup. Provides four tabs: Current Page (product summary), Search (fuzzy product search), Dismissed (manage 30-day dismissals), Settings (theme, behaviour). Shares data.js loaded via <script> tag for direct data access.
DATA LAYER -- data.js
Self-contained JS file defining the CE_DATA global object. Contains products map (508 entries), domainMap (464 eTLD+1 to slug mappings), and alternatives map. Used via importScripts in background and <script> in popup.

Manifest & Permissions

// manifest.json -- abbreviated
{
  "manifest_version": 3,
  "permissions": ["activeTab", "storage", "scripting", "tabs", "alarms"],
  "host_permissions": ["<all_urls>"],
  "background": { "service_worker": "background.js" },
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.js"],
    "run_at": "document_end"
  }]
}

Service Worker

The service worker (background.js) is the central coordinator. It handles three classes of events:

EventHandlerBehaviour
onUpdated (complete)handleTab()Updates toolbar badge when tab finishes loading
onActivatedhandleTab()Updates badge when user switches tabs
alarms.onAlarmsync handlerFetches fresh data from sync endpoint every 48h
onMessage (CE_READY)data deliveryReturns product data to content script on page load
onMessage (CE_RECHECK)restore flowSends CE_INIT to tab after user restores dismissed product
onInstalledinitClears legacy injection cache, triggers immediate sync

Content Script

The content script follows a pull model: instead of waiting for the background to push data, it requests data upon initialisation. This eliminates all timing-dependent race conditions inherent to the previous push-injection model.

// content.js -- initialisation sequence
(function() {
  // 1. Register message listener (CE_INIT + CE_THEME)
  chrome.runtime.onMessage.addListener((msg) => { /* ... */ });

  // 2. Pull model -- request data from background
  chrome.runtime.sendMessage({ type: 'CE_READY' }, (response) => {
    if (chrome.runtime.lastError) return;
    if (response?.product) initCard(response.slug, response.product, response.alts);
  });
})();

The popup renders four tab panels. Tab state is ephemeral (resets on popup close). Product data is accessed directly from CE_DATA for the Search tab, avoiding async IPC overhead.

TabData SourceDescription
This Pagebackground via chrome.tabs.queryShows product card for the active tab URL
SearchCE_DATA directReal-time fuzzy search across all 508 products
Dismissedchrome.storage.localLists all dismissed products with restore action
Settingschrome.storage.localTheme, auto-show, badge visibility, card position

Data Layer

data.js is the sole source of product intelligence in the bundled extension. It defines the CE_DATA global on the globalThis / window scope depending on execution context.

// CE_DATA structure
const CE_DATA = {
  products: {
    "shopify": { n: "Shopify", r: 4.5, p: 25, f: false, cat: "website-builders",
      d: "...", pr: ["..."], co: ["..."] },
    // 507 more entries...
  },
  domainMap: {
    "shopify.com": "shopify",
    "notion.so":   "notion",
    // 462 more entries...
  },
  alternatives: {
    "shopify": [{ s: "wix", n: "Wix", r: 4.2 }, /* ... */]
  }
};

04Domain Matching Algorithm

The domain resolution algorithm performs eTLD+1 extraction with subdomain walk-up, ensuring that app.notion.so, www.notion.so, and notion.so all correctly resolve to the notion product slug.

function extractBaseDomain(hostname) {
  hostname = hostname.replace(/^www\./, '');

  // Try exact match first
  if (CE_DATA.domainMap[hostname]) return hostname;

  // Walk up subdomains: app.hubspot.com -> hubspot.com -> com
  const parts = hostname.split('.');
  for (let i = 1; i < parts.length - 1; i++) {
    const candidate = parts.slice(i).join('.');
    if (CE_DATA.domainMap[candidate]) return candidate;
  }
  return null;
}

Resolution Steps

  • Strip www. prefix from hostname
  • Check exact match in domainMap
  • Walk up subdomain hierarchy (e.g. app.hubspot.com to hubspot.com)
  • Return null if no match found (no card rendered, badge cleared)

05Injection Model

Version 1.0 adopts a static content script declaration model, superseding the previous dynamic injection approach (chrome.scripting.executeScript) which suffered from injection cache invalidation bugs and race conditions.

Chrome page load -- content.js injected at document_end
+-- sendMessage CE_READY -- background.js
+-- slug not found -- sendResponse(null) no card
+-- dismissed ------ sendResponse(null) no card
+-- match found ---- sendResponse({slug, product, alts})
+-- initCard() - Shadow DOM card rendered
Why Static Injection

Dynamic injection required managing an injection cache (ce_injected in storage) to prevent double-injection. This cache became stale after extension updates, page reloads, and tab switches, causing silent injection failures. Static declaration removes this complexity entirely -- Chrome handles lifecycle management natively.

06Card UI & Shadow DOM

The card UI is rendered inside a Shadow DOM attached to a custom host element (id="comparedge-advisor-root") appended to document.body. This provides complete style isolation -- page CSS cannot leak in, and extension CSS cannot leak out.

// Shadow DOM initialisation
const host = document.createElement('div');
host.id = 'comparedge-advisor-root';
const shadow = host.attachShadow({ mode: 'closed' });

// CSS variables defined on #ceWrapper (not :host -- avoids all:initial conflict)
shadow.innerHTML = `
  <style>${getStyles()}</style>
  <div id="ceWrapper">
    <div class="ce-badge" id="ceBadge">...</div>
    <div class="ce-card" id="ceCard">...</div>
  </div>
`;
document.body.appendChild(host);

Card Structure

ElementClassDescription
Wrapper#ceWrapperCSS variable scope; .ce-light class for theme
Badge.ce-badgeFloating 44x44px button; shows owl icon + rating chip
Card.ce-card320px panel; max-height 80vh; flex-column
Header.ce-card-headerDrag handle; product name + rating; minimize + close buttons
Body.ce-card-bodyScrollable content area; stars, price, description, pros/cons, alternatives
FooterinlineCTA buttons: "Compare" and "Full Review"

Drag & Persist

The card is freely draggable via mousedown/mousemove/mouseup listeners on the header. Position is persisted to chrome.storage.local under the ce_position key. On card initialisation, saved position is validated against the current viewport dimensions -- if out-of-bounds, the position is cleared and the card resets to its default bottom-right anchor.

// Bounds validation on position restore
const left = parseFloat(pos.left);
const top  = parseFloat(pos.top);
const maxLeft = window.innerWidth  - 340;
const maxTop  = window.innerHeight - 80;
if (left >= 0 && left <= maxLeft && top >= 0 && top <= maxTop) {
  host.style.left = pos.left; host.style.top = pos.top;
} else {
  chrome.storage.local.remove('ce_position'); // reset
}

07Card Lifecycle

States

StateTriggerDOM EffectStorage
Visible (open)initCard() or badge click.ce-card.ce-open added-
Minimizedminimize button clickcard closes, badge shows-
Dismissed (30d)close button clickhost.remove()ce_dismissed[slug] = Date.now()
RestoredPopup - Dismissed - Restorepopup sends CE_INIT to tabdelete ce_dismissed[slug]

Dismiss Flow

User clicks close button
+-- async write: ce_dismissed[slug] = Date.now()
+-- host.remove() element removed from DOM

Restore Flow

Popup: Dismissed tab - Restore
+-- delete ce_dismissed[slug] from storage
+-- chrome.tabs.sendMessage(tabId, CE_INIT)
+-- content.js listener fires - initCard()

08Theme System

The extension supports three theme modes: dark (default), light, and auto (follows prefers-color-scheme). Theme state is applied to two distinct scopes:

ScopeMechanismClass Toggle
Popupdocument.documentElement.classListtheme-dark / theme-light on <html>
Content cardshadow.getElementById('ceWrapper').classListce-light on #ceWrapper

Real-time Theme Propagation

When the user changes theme in the popup Settings tab, applyTheme() updates the popup DOM and simultaneously sends a CE_THEME message to the active tab content script, which then calls applyCardTheme(). This ensures both surfaces update without requiring a page reload.

// popup.js -- theme change handler
function applyTheme(theme) {
  document.documentElement.classList.remove('theme-dark', 'theme-light');
  const isDark = theme === 'dark' || (theme === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
  document.documentElement.classList.add(isDark ? 'theme-dark' : 'theme-light');
  chrome.tabs.query({ active: true, lastFocusedWindow: true }, (tabs) => {
    if (tabs[0]?.id)
      chrome.tabs.sendMessage(tabs[0].id, { type: 'CE_THEME', theme }).catch(() => {});
  });
}

09Remote Sync

The extension maintains a voluntary 48-hour data refresh cycle to ensure product data stays current without requiring an extension update. The sync endpoint returns a JSON payload structurally identical to CE_DATA.

PropertyValue
Endpointhttps://comparedge.com/extension-sync.json
MethodGET
FrequencyEvery 48 hours via chrome.alarms
TriggerAlso fires immediately on onInstalled
Storage keyce_remote_data + ce_sync_time
FallbackBundled CE_DATA used if sync fails or stale
Data Priority

When serving a CE_READY request, getProductData(slug) checks ce_remote_data first. If the remote data contains an entry for the slug, it takes precedence over the bundled data. This allows product information to be updated without publishing a new extension version.

10Security Model

Privacy Architecture

The extension collects zero user data. There are no analytics calls, no telemetry, no user identifiers, and no event tracking of any kind. The complete list of external network requests made by the extension:

  • GET https://comparedge.com/extension-sync.json -- once every 48 hours, contains no user data, purely reads public product data

All other operations (domain matching, card rendering, settings, dismiss tracking) are entirely local to the browser.

XSS Mitigation

All string values from the remote sync payload (product names, descriptions, pros/cons) are HTML-encoded via a sanitize function before insertion into innerHTML:

function s(str) {
  if (typeof str !== 'string') return '';
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

This ensures that even a compromised sync endpoint cannot inject executable HTML or JavaScript into the card UI.

Content Security Policy

The extension enforces a strict CSP for extension pages:

"script-src 'self'; object-src 'self'"

This prohibits inline scripts, eval(), remote script loading, and all plugin objects within the popup and options pages. Content scripts run in an isolated world and are not subject to the page own CSP.

Shadow DOM Isolation

The card UI uses a closed Shadow DOM (attachShadow({ mode: 'closed' })). This means:

  • Page JavaScript cannot access the shadow root via element.shadowRoot
  • Page CSS selectors cannot target elements inside the shadow tree
  • Extension CSS cannot inadvertently style page elements
  • User scripts and page-level XSS cannot tamper with the card UI

11Storage Schema

All persistent state is stored in chrome.storage.local. The following keys are used:

KeyTypeDescriptionLifetime
ce_dismissedRecord<slug, timestamp>Products dismissed by user; values are Date.now() timestamps. TTL = 30 days.30 days per entry
ce_settings{ theme, autoShow, showBadge, position }User preferences from Settings tabPermanent
ce_position{ left: string, top: string }Last saved card drag position (CSS pixel values)Until reset
ce_remote_dataCE_DATA shapeRemote sync payload; overrides bundled data per-slugUntil next sync
ce_sync_timenumberUnix timestamp of last successful syncPermanent

12Message API

Message TypeDirectionPayloadDescription
CE_READYcontent - background-Content script requests product data for current page. Background responds with { slug, product, alts } or null.
CE_INITbackground/popup - content{ slug, product, alts }Instructs content script to render the product card. Used in restore flow.
CE_THEMEpopup - content{ theme: 'dark'|'light'|'auto' }Propagates theme change to card in real time.
CE_RECHECKpopup - background{ tabId, url }Triggers background to send CE_INIT to the specified tab after restore.

13Settings API

KeyValuesDefaultDescription
theme'dark' | 'light' | 'auto''dark'UI colour scheme for popup and content card
autoShowbooleantrueAutomatically open card (vs show only badge)
showBadgebooleantrueShow floating badge when card is minimized
position'bottom-right' | 'bottom-left''bottom-right'Default card anchor position

14Product Data Schema

Each product entry in CE_DATA.products conforms to the following shape:

FieldTypeDescription
nstringProduct display name
rnumberAggregate rating (0-5, typically 2 decimal places)
pnumber | nullStarting price per month (USD)
fbooleanHas free plan
catstringCategory slug (e.g. "crm", "ai-tools")
dstringShort description (1-2 sentences)
prstring[]Pros list (3-5 items)
costring[]Cons list (2-4 items)

Alternative entries in CE_DATA.alternatives[slug]:

FieldTypeDescription
sstringSlug of the alternative product
nstringDisplay name
rnumberRating

15Performance

MetricValueNotes
Extension package size~1.5 MBDominated by 533 WebP logos (32x32)
Content script execution<5msIIFE, single async message round-trip
CE_READY response time<10msStorage read + object lookup
Card first render<20msAfter CE_READY response received
DOM overhead per page1 element#comparedge-advisor-root + shadow tree (only on matched pages)
Memory (non-matched pages)~12 KBcontent.js parse + one pending message + listener
Sync payload size~180 KBCompressed; fetched once per 48h

16Changelog

All version history, release notes, and architectural changes are tracked in the extension changelog. Each entry documents new features, bug fixes, security updates, and data layer changes with exact dates and version numbers.

The changelog follows Semantic Versioning: MAJOR.MINOR.PATCH. Breaking changes increment MAJOR, new features increment MINOR, bug fixes increment PATCH.

Current release: v1.0.0
Released May 17, 2026 - Initial public release
View Full Changelog