Technical Deep-Dive · Chrome Extension Architecture

How DocGen Studio Works Under the Hood

DocGen Studio is a Manifest V3 Chrome extension built with vanilla JavaScript (no framework). It captures clicks via content scripts, annotates screenshots using Canvas API, persists state in IndexedDB, and optionally integrates with OpenRouter for AI-assisted drafting. This article walks through each subsystem with code references.

For developers · ~12 min read · See the user guide →

Manifest V3 Configuration

Manifest V3 requires service workers for background logic, declarative net request for permissions, and explicit host permissions. DocGen Studio uses minimal permissions: activeTab, scripting, downloads, storage.

Key manifest entries

{
  "manifest_version": 3,
  "name": "DocGen Studio",
  "version": "1.0.0",
  "permissions": ["activeTab", "scripting", "downloads", "storage"],
  "host_permissions": [""],
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "content_scripts": [{
    "matches": [""],
    "js": ["content.js"],
    "run_at": "document_idle"
  }],
  "action": {
    "default_popup": "popup.html",
    "default_icon": "icon.png"
  },
  "web_accessible_resources": [{
    "resources": ["injected.js"],
    "matches": [""]
  }]
}

Why activeTab + host_permissions?

activeTab grants temporary access to the current tab on user action (clicking extension icon). host_permissions: [""] allows content script injection on any page without warnings. The combination enables recording on any site the user visits.

Click Capture: Content Script + Event Delegation

Single delegated listener on document captures all clicks, extracts element metadata, and sends to background via chrome.runtime.sendMessage. Avoids per-element listeners (memory leaks, SPA navigation issues).

Content script (simplified)

// content.js
let isRecording = false;

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.type === "START_RECORDING") {
    isRecording = true;
    sendResponse({ok: true});
  } else if (msg.type === "STOP_RECORDING") {
    isRecording = false;
    sendResponse({ok: true});
  }
});

document.addEventListener("click", async (e) => {
  if (!isRecording) return;
  if (e.target.closest("#docgen-ignore")) return; // Ignore extension UI

  const element = e.target;
  const rect = element.getBoundingClientRect();
  
  const clickData = {
    timestamp: Date.now(),
    url: window.location.href,
    title: document.title,
    selector: generateSelector(element),  // CSS selector for replay
    tagName: element.tagName,
    text: element.innerText?.slice(0, 100),
    attributes: extractAttributes(element), // id, class, role, aria-label, etc.
    position: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
    viewport: { width: window.innerWidth, height: window.innerHeight }
  };

  chrome.runtime.sendMessage({ type: "CLICK_CAPTURED", data: clickData });
}, true); // Capture phase for priority

Selector generation (for replay/highlight)

function generateSelector(el) {
  if (el.id) return `#${el.id}`;
  const parts = [];
  while (el && el !== document.body) {
    let part = el.tagName.toLowerCase();
    if (el.className) {
      const classes = el.className.split(/\s+/).filter(c => !c.startsWith("docgen-"));
      if (classes.length) part += "." + classes.join(".");
    }
    const siblings = Array.from(el.parentElement?.children || []);
    const sameTag = siblings.filter(s => s.tagName === el.tagName);
    if (sameTag.length > 1) {
      part += `:nth-of-type(${sameTag.indexOf(el) + 1})`;
    }
    parts.unshift(part);
    el = el.parentElement;
  }
  return parts.join(" > ");
}

SPA navigation handling

Content script persists across SPA navigations (no reload). chrome.webNavigation.onHistoryStateUpdated in background detects route changes and notifies content script to update current URL context.

Screenshot Capture & Canvas Annotation

chrome.tabs.captureVisibleTab captures the viewport; Canvas API draws annotations (arrow, label, highlight box) at recorded coordinates. Focused Crop (60%) creates a zoomed region around the click target.

Capture flow

  1. Background receives CLICK_CAPTURED
  2. Calls chrome.tabs.captureVisibleTab(tabId, {format: 'png', quality: 90})
  3. Returns data URL → passed to offscreen document for annotation

Offscreen document for Canvas (Manifest V3 requirement)

// offscreen.html (declared in manifest)
<canvas id="canvas"></canvas>
<script src="offscreen.js" type="module"></script>

// offscreen.js
chrome.runtime.onMessage.addListener(async (msg) => {
  if (msg.type === "ANNOTATE_SCREENSHOT") {
    const { screenshotDataUrl, clickData, options } = msg;
    const img = await loadImage(screenshotDataUrl);
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
    
    // Set canvas to screenshot size (or cropped size)
    const { width, height } = calculateCanvasSize(img, clickData, options);
    canvas.width = width;
    canvas.height = height;
    
    // Draw screenshot (full or cropped)
    if (options.focusedCrop) {
      drawFocusedCrop(ctx, img, clickData);
    } else {
      ctx.drawImage(img, 0, 0, width, height);
    }
    
    // Draw annotation: highlight box, arrow, label
    drawAnnotation(ctx, clickData, options);
    
    // Return annotated data URL
    const annotatedDataUrl = canvas.toDataURL("image/png");
    chrome.runtime.sendMessage({ type: "ANNOTATION_COMPLETE", dataUrl: annotatedDataUrl });
  }
});

Annotation drawing (simplified)

function drawAnnotation(ctx, clickData, options) {
  const { position } = clickData;
  const scale = options.focusedCrop ? 1.67 : 1; // 60% crop = 1/0.6
  
  const x = position.x * scale;
  const y = position.y * scale;
  const w = position.width * scale;
  const h = position.height * scale;
  
  // Highlight box (extra thick)
  ctx.strokeStyle = "#00D4FF";
  ctx.lineWidth = 4 * scale;
  ctx.strokeRect(x - 2, y - 2, w + 4, h + 4);
  
  // Arrow from label to element center
  const labelX = x + w / 2;
  const labelY = y - 30 * scale;
  ctx.beginPath();
  ctx.moveTo(labelX, labelY);
  ctx.lineTo(x + w/2, y);
  ctx.strokeStyle = "#00D4FF";
  ctx.lineWidth = 3 * scale;
  ctx.stroke();
  
  // Label background
  ctx.fillStyle = "#00D4FF";
  ctx.font = `bold ${14 * scale}px Inter, sans-serif`;
  const text = `Click ${clickData.tagName}`;
  const metrics = ctx.measureText(text);
  ctx.fillRect(labelX - metrics.width/2 - 8, labelY - 20, metrics.width + 16, 24);
  
  // Label text
  ctx.fillStyle = "#0B1020";
  ctx.fillText(text, labelX - metrics.width/2, labelY - 4);
}

Focused Crop (60%) math

Viewport coordinates → crop region centered on click with 60% of viewport dimensions. Ensures annotation stays readable in dense UIs.

State Management: IndexedDB (idb wrapper)

All recording state (clicks, screenshots, settings) lives in IndexedDB via the idb library. Survives extension restarts, popup closes, browser restarts. No cloud sync.

Schema

// db.js
import { openDB } from 'idb';

const DB_NAME = 'docgen-studio';
const STORES = {
  recordings: 'recordings',      // Active recording session
  steps: 'steps',                // Individual steps (click + screenshot)
  settings: 'settings',          // User preferences
  apiKeys: 'apiKeys'             // Encrypted OpenRouter key
};

export async function getDB() {
  return openDB(DB_NAME, 1, {
    upgrade(db) {
      db.createObjectStore(STORES.recordings, { keyPath: 'id' });
      db.createObjectStore(STORES.steps, { keyPath: 'id', autoIncrement: true });
      db.createObjectStore(STORES.settings, { keyPath: 'key' });
      db.createObjectStore(STORES.apiKeys, { keyPath: 'provider' });
    }
  });
}

Recording session lifecycle

// Start recording
async function startRecording() {
  const db = await getDB();
  const recording = {
    id: crypto.randomUUID(),
    startedAt: Date.now(),
    url: tab.url,
    title: tab.title,
    stepCount: 0
  };
  await db.put(STORES.recordings, recording);
  // Notify content script
  chrome.tabs.sendMessage(tabId, { type: "START_RECORDING" });
}

// On click captured (background)
async function onClickCaptured(clickData, screenshotDataUrl) {
  const db = await getDB();
  const tx = db.transaction(STORES.steps, 'readwrite');
  await tx.store.add({
    recordingId: currentRecordingId,
    clickData,
    screenshot: screenshotDataUrl,
    order: ++stepCount,
    createdAt: Date.now()
  });
  await tx.done;
}

Why IndexedDB over localStorage?

OpenRouter AI Integration: Single-Call Multimodal

One API call sends all click labels + screenshots (as base64) to OpenRouter; returns structured JSON with document title, summary, and step instructions. User provides their own API key and model choice.

Request payload (simplified)

const buildAIPrompt = (steps, options) => {
  const clickLabels = steps.map((s, i) => 
    `Step ${i+1}: User clicked a ${s.clickData.tagName.toLowerCase()} ` +
    `labeled "${s.clickData.text || 'unnamed'}" at (${s.clickData.position.x}, ${s.clickData.position.y})`
  ).join("\n");

  return {
    model: options.model, // e.g., "google/gemini-2.0-flash-lite-preview-02-05:free"
    messages: [
      {
        role: "system",
        content: `You are a technical writer creating a SaaS how-to guide. 
        Given a sequence of user clicks, produce:
        1. Document title (starts with "How to...")
        2. 1-2 sentence overview
        3. For each step: title (imperative) + instruction (1-2 sentences)
        Output ONLY valid JSON matching the schema.`
      },
      {
        role: "user",
        content: [
          { type: "text", text: `Clicks:\n${clickLabels}` },
          ...(options.includeImages ? steps.map(s => ({
            type: "image_url",
            image_url: { url: s.screenshot } // base64 data URL
          })) : [])
        ]
      }
    ],
    response_format: { type: "json_object" },
    temperature: 0.3,
    max_tokens: 4000
  };
};

Response schema (validated client-side)

{
  "documentTitle": "How to fill out and submit a form",
  "overview": "This guide walks through completing the contact form...",
  "steps": [
    {
      "title": "Navigate to the contact page",
      "instruction": "Click the Contact link in the navigation bar to open the form page."
    },
    {
      "title": "Enter your name",
      "instruction": "Click the Name field and type your full name."
    }
    // ...
  ]
}

Key design decisions

PDF & Markdown Export

Markdown: string templating. PDF: jsPDF with auto-table for steps, embedded annotated images. Both generated client-side; no server.

Markdown generation

function generateMarkdown(doc) {
  let md = `# ${doc.title}\n\n`;
  md += `${doc.overview}\n\n`;
  md += `*Generated with DocGen Studio on ${new Date().toLocaleDateString()}*\n\n`;
  md += `## Steps\n\n`;
  
  doc.steps.forEach((step, i) => {
    md += `### ${i+1}. ${step.title}\n\n`;
    md += `${step.instruction}\n\n`;
    if (step.screenshot) {
      // Embed as base64 (large) or reference local file
      md += `![Step ${i+1}](${step.screenshot})\n\n`;
    }
  });
  
  return md;
}

// Download
function downloadMarkdown(md, title) {
  const blob = new Blob([md], { type: "text/markdown" });
  const url = URL.createObjectURL(blob);
  chrome.downloads.download({
    url,
    filename: `${slugify(title)}.md`,
    saveAs: true
  });
}

PDF generation (jsPDF + autoTable)

import { jsPDF } from "jspdf";
import "jspdf-autotable";

async function generatePDF(doc) {
  const pdf = new jsPDF({ unit: "mm", format: "a4" });
  const pageWidth = pdf.internal.pageSize.getWidth();
  let y = 20;

  // Title
  pdf.setFontSize(22);
  pdf.text(doc.title, pageWidth/2, y, { align: "center" });
  y += 10;

  // Overview
  pdf.setFontSize(11);
  const overviewLines = pdf.splitTextToSize(doc.overview, pageWidth - 40);
  pdf.text(overviewLines, 20, y);
  y += overviewLines.length * 5 + 10;

  // Steps table
  const tableData = doc.steps.map((step, i) => [
    `${i+1}`,
    step.title,
    step.instruction,
    step.screenshot ? "📷" : ""
  ]);

  pdf.autoTable({
    startY: y,
    head: [["#", "Step", "Instruction", ""]],
    body: tableData,
    theme: "striped",
    headStyles: { fillColor: [99, 102, 241] },
    columnStyles: {
      0: { cellWidth: 10 },
      1: { cellWidth: 50 },
      2: { cellWidth: 110 },
      3: { cellWidth: 10 }
    },
    didDrawCell: async (data) => {
      if (data.column.index === 3 && data.cell.section === "body") {
        const step = doc.steps[data.row.index];
        if (step.screenshot) {
          const img = await loadImage(step.screenshot);
          pdf.addImage(img, "PNG", data.cell.x + 2, data.cell.y + 2, 6, 6);
        }
      }
    }
  });

  pdf.save(`${slugify(doc.title)}.pdf`);
}

Privacy by Design

Architecture decisions that enforce local-first privacy:

Threat model

Threat Mitigation
Extension compromisedNo sensitive data stored; key encrypted; no server to breach
Malicious updateChrome Web Store review; user must accept update
OpenRouter key theftEncrypted at rest; sent only to OpenRouter via HTTPS
Screenshot leakageNever leave device unless user exports

Future Improvements (Roadmap)

Explore More

Try DocGen Studio yourself, or read backend architecture patterns for more technical content.