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.
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
- Background receives
CLICK_CAPTURED - Calls
chrome.tabs.captureVisibleTab(tabId, {format: 'png', quality: 90}) - 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?
- Capacity: 5MB (localStorage) vs hundreds of MB (IndexedDB) — screenshots are large
- Async: Non-blocking, doesn't freeze UI thread
- Indexing: Query by recordingId, order, timestamp
- Transactions: Atomic multi-step operations
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
- Single call: All steps in one request → lower latency, lower cost, consistent tone
- Images optional: User can disable screenshot upload to AI (privacy/bandwidth)
- Fallback: No key = local template-based text ("Click the [element]")
- Streaming UI: Shows "Generating AI..." per step, updates as chunks arrive
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 += `\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:
- No backend: No server, no database, no analytics, no telemetry. Extension is entirely client-side.
- No content script tracking: Content script only activates on user-initiated recording. No passive monitoring.
- OpenRouter direct: API key stored in Chrome local storage (encrypted via
crypto.subtle). Requests go directly to OpenRouter; extension never sees the key in plaintext after initial save. - Images never sent to AI unless opted in: Default prompt excludes screenshots. User must enable "Include images in AI request" in settings.
- Export is local download:
chrome.downloads.downloadsaves to user's Downloads folder. No upload. - No account system: No email, no auth, no user IDs. Zero PII collected.
Threat model
| Threat | Mitigation |
|---|---|
| Extension compromised | No sensitive data stored; key encrypted; no server to breach |
| Malicious update | Chrome Web Store review; user must accept update |
| OpenRouter key theft | Encrypted at rest; sent only to OpenRouter via HTTPS |
| Screenshot leakage | Never leave device unless user exports |
Future Improvements (Roadmap)
- Video export: WebM/MP4 from recorded steps (MediaRecorder API)
- Collaborative editing: WebRTC peer-to-peer for real-time co-editing (no server)
- Plugin system: Custom annotation types, export formats, AI prompts
- Firefox/Safari support: Manifest V3 compatible; minor polyfills needed
- CLI companion: Node.js tool for batch export, CI integration
- Accessibility audit: Auto-detect missing alt text, contrast issues in recorded flows
Explore More
Try DocGen Studio yourself, or read backend architecture patterns for more technical content.