Build Scroll Atlas in Firefox

Build a Firefox extension that records documents and panes as PNG tile sets.

Introduction

30 Second Summary

A webpage can hold far more than the slice visible on your screen. A normal screenshot can miss content inside long pages or split panes.

In this project, you will build Scroll Atlas as a Firefox Manifest V3 extension that records finite webpages as numbered PNG tiles. You will also add a bounded mode for pages that load more content during scrolling.

What You'll Build

Your finished extension turns one capture into separate numbered image sets for the document plus every detected scrolling pane.

By the end of this project, you'll have:

  • A numbered document atlas that exposes content beyond the visible browser area.
  • Separate two-axis pane recordings that reveal far-right cells plus bottom rows inside independent scroll regions.
  • A local capture manifest that reports tile coordinates, dynamic growth, cancellation status, plus restored scroll positions.
  • Secret Mission: Stress-test Scroll Atlas on a second hostile split-view page to audit its manifest for complete region coverage.

Are there any prerequisites?

No prior extension experience is required.

You only need a Mac. Step 1 covers Firefox plus Visual Studio Code setup if either is missing.

Before We Start

This step commits you to a finite webpage or split-view interface before the hands-on work begins. Your choice gives Scroll Atlas a concrete reason to record the document and its independent scrolling regions as separate PNG tile atlases.

Set Up the Firefox Extension

Scroll Atlas needs a browser environment that can load local extension files before its capture logic exists. A plain Firefox project keeps the focus on page geometry without adding a build tool.

You will prepare Visual Studio Code. You will then build a Manifest V3 extension shell with a working toolbar popup.

In this step, get ready to:
  • Prepare Firefox plus Visual Studio Code on your Mac.
  • Build the extension shell inside the scroll-atlas folder.
  • Verify the popup through a temporary Firefox load.
Prepare your Mac

Firefox runs the extension during development. Visual Studio Code keeps every source file together in one workspace.

Start by checking whether Firefox is already available on your Mac.

✔️ Firefox opens

  • Press Cmd+Space to open Spotlight.
  • Type Firefox into Spotlight.
  • Press Return to open Firefox.

Firefox is ready to load your extension during development.

ⓧ Firefox is missing

  • Visit the official Firefox download page.
  • Download Firefox for macOS.
  • Complete the installation using the prompts on your Mac.
  • Press Cmd+Space to open Spotlight.
  • Type Firefox into Spotlight.
  • Press Return to open Firefox.

Firefox is now available for temporary extension testing.

Next, check whether Visual Studio Code is available.

✔️ Visual Studio Code opens

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into Spotlight.
  • Press Return to open Visual Studio Code.

Your editor is ready for the Scroll Atlas source files.

ⓧ Visual Studio Code is missing

  • Visit the official Visual Studio Code download page.
  • Download the macOS disk image.
  • Open the downloaded .dmg file.
  • Drag Visual Studio Code.app into the Applications folder.
  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into Spotlight.
  • Press Return to open Visual Studio Code.

Visual Studio Code is now ready to hold your extension workspace.

Having trouble opening either app?

Confirm each application appears in your Mac's Applications folder. macOS may also ask you to confirm that you want to open a newly downloaded application.

Still stuck? Help me install Firefox or Visual Studio Code on macOS.

A dedicated folder keeps the manifest beside every file it references. You will place scroll-atlas on your Desktop so it remains easy to locate from Firefox.

  • Click an empty area of your macOS desktop.
  • Press Shift+Command+N to create a folder.
  • Enter scroll-atlas as the folder name.
  • Press Return to finish creating the folder.
  • Switch back to Visual Studio Code.
  • Select File from the menu bar.
  • Select Open Folder....
  • Select Desktop in the file dialog.
  • Select the scroll-atlas folder.
  • Select Open.

What if Workspace Trust appears?

Visual Studio Code may ask whether you trust the folder's authors. This folder is one you just created locally.

  • Select Yes, I trust the authors to open the workspace.

Good progress. The scroll-atlas workspace now gives every extension file one clear home.

Build the extension shell

A WebExtension starts with manifest.json. The manifest tells Firefox which permissions the extension needs plus which page opens from its toolbar action.

Create the complete file set first. Each new filename appears immediately in the Visual Studio Code Explorer.

  • Select the Explorer view in the left Activity Bar.
  • Select the New File... button.
  • Enter manifest.json as the first filename.
  • Use New File... to create popup.html.
  • Use New File... to create popup.css.
  • Use New File... to create popup.js.
  • Use New File... to create background.js.
  • Use New File... to create recorder.js.
  • Use New File... to create test-page.html.

You should now see all seven files beneath scroll-atlas in the Explorer.

The manifest declares only the permissions needed by the finished extension. The activeTab permission grants temporary access after you interact with the toolbar action.

  • Select manifest.json in the Explorer.
  • Replace its contents with this manifest:
{
  "manifest_version": 3,
  "name": "Scroll Atlas",
  "version": "1.0.0",
  "description": "Records a finite page and its independent scroll regions as local PNG tile sets.",
  "permissions": ["activeTab", "scripting", "downloads"],
  "background": {
    "scripts": ["background.js"],
    "service_worker": "background.js"
  },
  "action": {
    "default_title": "Scroll Atlas",
    "default_popup": "popup.html"
  },
  "browser_specific_settings": {
    "gecko": {
      "id": "scroll-atlas@local.example",
      "data_collection_permissions": {
        "required": ["none"]
      }
    }
  }
}

What does the manifest define?

  • The permissions array enables temporary tab access plus script injection and local downloads.
  • The background object names background.js for Firefox plus the documented service worker fallback field.
  • The action object connects the toolbar button to popup.html.
  • The Gecko settings identify the extension plus declare that it requires no data collection.
  • Save manifest.json.
  • Check the editor for red JSON error markers. You should see none.

Seeing a JSON error marker?

Check every comma plus quotation mark against the reference. JSON rejects missing commas and trailing characters.

Need help? Help me find the syntax error in my Scroll Atlas manifest.

The popup is the extension's control panel. Its HTML provides finite-page mode by default plus an explicit option for pages that grow while scrolling.

  • Select popup.html in the Explorer.
  • Add the popup structure with this code:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Scroll Atlas</title>
    <link rel="stylesheet" href="popup.css" />
  </head>
  <body>
    <main>
      <h1>Scroll Atlas</h1>
      <p class="hint">Keep this tab active until capture finishes.</p>

      <label class="mode">
        <input id="dynamic-mode" type="checkbox" />
        <span>This page loads more while scrolling</span>
      </label>

      <label for="max-passes">Maximum loading passes</label>
      <select id="max-passes" disabled>
        <option value="3">3 passes</option>
        <option value="6" selected>6 passes</option>
        <option value="10">10 passes</option>
      </select>

      <button id="start" type="button">Start capture</button>
      <p id="status" role="status">Finite-page mode is ready.</p>
    </main>
    <script src="popup.js"></script>
  </body>
</html>

What does the popup contain?

  • The dynamic-mode checkbox lets the user identify a page that grows while scrolling.
  • The disabled max-passes selector sets a bounded number of loading passes.
  • The start button reserves the action that begins capture in the next step.
  • The status paragraph gives the user visible mode feedback.
  • Save popup.html.
  • Check the editor for red HTML error markers. You should see none.

Seeing an HTML error marker?

Confirm every opening element has the matching closing element shown in the reference. Keep the external script near the end of the body.

Need help? Help me find the mismatch in my popup HTML.

The stylesheet turns the raw controls into a compact popup. Start with its color scheme plus the main layout.

  • Select popup.css in the Explorer.
  • Add the popup foundation with this code:
:root {
  color-scheme: light dark;
  font: 14px/1.4 system-ui, sans-serif;
}

body {
  margin: 0;
  min-width: 320px;
}

main {
  display: grid;
  gap: 12px;
  padding: 16px;
}

What does this layout do?

  • The root settings allow the popup to follow the browser's light or dark theme.
  • The body keeps a stable minimum width without adding outer margin.
  • The main grid gives each popup control consistent spacing.
  • Save popup.css.
  • Check the editor for red CSS error markers. You should see none.

Seeing a CSS error marker?

Check that each declaration ends inside the correct brace. Confirm the root selector begins with a colon.

Need help? Help me debug the popup layout CSS.

The next styles separate the supporting hint from the mode control. They also align the checkbox with its label.

  • Continue in popup.css.
  • Add these rules below the existing layout:
h1,
p {
  margin: 0;
}

.hint {
  color: #667085;
}

.mode {
  display: flex;
  gap: 8px;
  align-items: flex-start;
}

How do these rules help?

  • The shared margin rule removes browser spacing around the heading plus paragraphs.
  • The hint color gives supporting text less visual weight.
  • The mode row places the checkbox beside its description.
  • Save popup.css.
  • Check that the file now contains the root styles followed by the new text plus mode rules.

Are the new rules replacing the first ones?

Place this block below the closing brace for main. Replacing the first block removes the popup's width plus grid layout.

Need help? Show me where to place the second popup CSS block.

The final styles give the selector plus button a usable size. They also make waiting states plus long status messages visible.

  • Continue in popup.css.
  • Add the control styles below the mode rule:
select,
button {
  min-height: 38px;
  font: inherit;
}

button {
  border: 0;
  border-radius: 8px;
  background: #2457d6;
  color: white;
  cursor: pointer;
  font-weight: 700;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

#status {
  min-height: 40px;
  overflow-wrap: anywhere;
}

What do the control styles change?

  • The shared sizing keeps the selector plus button easy to use.
  • The button rule creates the blue primary action.
  • The disabled rule communicates that a capture is busy.
  • The status rule reserves space for feedback plus wraps long messages.
  • Save popup.css.
  • Check that the file ends with the #status rule.

Is the button missing its styles?

Confirm the button rules sit outside the .mode block. A missing closing brace can trap them inside the previous selector.

Need help? Help me fix the control styles in popup.css.

The popup script reads the controls from the page. For this shell, it only switches between finite-page feedback plus bounded dynamic-page feedback.

  • Select popup.js in the Explorer.
  • Add the control setup with this code:
const extensionApi = globalThis.browser ?? globalThis.chrome;

const dynamicMode = document.querySelector("#dynamic-mode");
const maxPasses = document.querySelector("#max-passes");
const startButton = document.querySelector("#start");
const status = document.querySelector("#status");

dynamicMode.addEventListener("change", () => {
  maxPasses.disabled = !dynamicMode.checked;
  status.textContent = dynamicMode.checked
    ? "Dynamic mode will perform bounded loading passes."
    : "Finite-page mode is ready.";
});

What does the popup script do?

  • The API reference supports Firefox's browser namespace plus the compatible chrome namespace.
  • The four selectors connect JavaScript to the popup controls.
  • The change listener enables the loading-pass selector only when dynamic mode is selected.
  • The status message tells the user which capture policy is active.
  • Save popup.js.
  • Check the editor for red JavaScript error markers. You should see none.

Seeing a JavaScript error marker?

Check the selector quotation marks plus the closing characters for the change listener. Each selector must match its HTML identifier exactly.

Need help? Help me debug the popup mode listener.

The background script becomes the bridge for screenshot plus download requests in the next step. This setup only initializes the shared extension API reference.

  • Select background.js in the Explorer.
  • Add the API initialization line:
const extensionApi = globalThis.browser ?? globalThis.chrome;

Why initialize the API here?

The background context needs the same cross-browser API reference as the popup. Later message handlers build on this constant.

  • Save background.js.
  • Leave recorder.js empty for the future injected recorder.
  • Leave test-page.html empty for the future split-view fixture.

Do the files show unsaved changes?

Look for a filled circle on any open editor tab. Save each changed file before Firefox loads the extension.

Need help? Help me confirm which Scroll Atlas files still need saving.

Use this comparison before loading the extension. The second tab shows the complete file state for this step.

✔️ Awesome, I've got everything!

Great. Confirm all seven filenames appear under scroll-atlas. Save every changed file before continuing.

ⓧ I'd like to double check the full code

Compare each non-empty file with the references below. Keep recorder.js plus test-page.html empty during this step.

{
  "manifest_version": 3,
  "name": "Scroll Atlas",
  "version": "1.0.0",
  "description": "Records a finite page and its independent scroll regions as local PNG tile sets.",
  "permissions": ["activeTab", "scripting", "downloads"],
  "background": {
    "scripts": ["background.js"],
    "service_worker": "background.js"
  },
  "action": {
    "default_title": "Scroll Atlas",
    "default_popup": "popup.html"
  },
  "browser_specific_settings": {
    "gecko": {
      "id": "scroll-atlas@local.example",
      "data_collection_permissions": {
        "required": ["none"]
      }
    }
  }
}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Scroll Atlas</title>
    <link rel="stylesheet" href="popup.css" />
  </head>
  <body>
    <main>
      <h1>Scroll Atlas</h1>
      <p class="hint">Keep this tab active until capture finishes.</p>

      <label class="mode">
        <input id="dynamic-mode" type="checkbox" />
        <span>This page loads more while scrolling</span>
      </label>

      <label for="max-passes">Maximum loading passes</label>
      <select id="max-passes" disabled>
        <option value="3">3 passes</option>
        <option value="6" selected>6 passes</option>
        <option value="10">10 passes</option>
      </select>

      <button id="start" type="button">Start capture</button>
      <p id="status" role="status">Finite-page mode is ready.</p>
    </main>
    <script src="popup.js"></script>
  </body>
</html>
:root {
  color-scheme: light dark;
  font: 14px/1.4 system-ui, sans-serif;
}

body {
  margin: 0;
  min-width: 320px;
}

main {
  display: grid;
  gap: 12px;
  padding: 16px;
}

h1,
p {
  margin: 0;
}

.hint {
  color: #667085;
}

.mode {
  display: flex;
  gap: 8px;
  align-items: flex-start;
}

select,
button {
  min-height: 38px;
  font: inherit;
}

button {
  border: 0;
  border-radius: 8px;
  background: #2457d6;
  color: white;
  cursor: pointer;
  font-weight: 700;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

#status {
  min-height: 40px;
  overflow-wrap: anywhere;
}
const extensionApi = globalThis.browser ?? globalThis.chrome;

const dynamicMode = document.querySelector("#dynamic-mode");
const maxPasses = document.querySelector("#max-passes");
const startButton = document.querySelector("#start");
const status = document.querySelector("#status");

dynamicMode.addEventListener("change", () => {
  maxPasses.disabled = !dynamicMode.checked;
  status.textContent = dynamicMode.checked
    ? "Dynamic mode will perform bounded loading passes."
    : "Finite-page mode is ready.";
});
const extensionApi = globalThis.browser ?? globalThis.chrome;

The recorder.js file should exist with no content.

The test-page.html file should exist with no content.

Load and verify the temporary add-on

Firefox can load an unpacked extension directly from its manifest. A temporary installation lasts until you remove it or restart Firefox.

Before you load the extension, do you expect Firefox to accept every manifest reference plus open the popup without a policy error?

  • Switch back to Firefox.
  • Enter about:debugging in the address bar.
  • Press Return.
  • Select This Firefox in the left sidebar.
  • Select Load Temporary Add-on.
  • Open the Desktop folder in the file picker.
  • Open the scroll-atlas folder.
  • Select manifest.json.
  • Select Open to load the extension.

You should see Scroll Atlas listed as a temporary extension on the This Firefox page. Firefox should show no manifest error.

  • Select the Scroll Atlas toolbar action.

The popup should show the Scroll Atlas heading plus the finite-page controls. You should also see the disabled loading-pass selector plus the blue Start capture button.

  • Select This page loads more while scrolling in the popup.

The loading-pass selector becomes enabled. The status changes to the bounded dynamic-mode message.

  • Clear This page loads more while scrolling.

The loading-pass selector becomes disabled again. The status returns to finite-page mode without a Content Security Policy error.

Does the temporary add-on fail to load?

  • Confirm you selected scroll-atlas/manifest.json instead of a source file beside it.
  • Confirm background.js plus popup.html appear beside the manifest.
  • Check the manifest for a red JSON error marker in Visual Studio Code.

Still stuck? Help me diagnose why Firefox rejects my temporary Scroll Atlas extension.

Does the popup open without styling or interaction?

  • Confirm popup.css plus popup.js are in the same folder as popup.html.
  • Return to about:debugging after saving your changes.
  • Select Reload on the Scroll Atlas extension card.

Need help? Help me debug my Scroll Atlas popup files.

Your extension shell is live in Firefox. Next, you will connect the popup to a real screenshot download of the active tab.

Capture the First Visible Screenshot

The Scroll Atlas popup is ready, but its button still needs a path to the screenshot and download features in Firefox.

First, you will build one complete message path from the popup to the page to the background script. A downloaded PNG proves that every extension context can cooperate before the recorder grows into a tile atlas.

In this step, get ready to:
  • Route screenshot requests through the background script.
  • Inject the recorder into the active tab.
  • Download one viewport screenshot through the popup.
Connect capture and download messages

The background script can use extension APIs that the page cannot call directly. It receives capture requests from the recorder before returning image data.

  • In the Visual Studio Code Explorer sidebar, select background.js.
  • Replace the existing contents with this message listener:
const extensionApi = globalThis.browser ?? globalThis.chrome;
const pendingObjectUrls = new Map();

extensionApi.runtime.onMessage.addListener((message, sender) => {
  if (message.type === "scroll-atlas:capture") {
    if (!sender.tab) {
      return Promise.reject(new Error("Capture request has no source tab."));
    }

    return extensionApi.tabs.captureVisibleTab(sender.tab.windowId, {
      format: "png",
      rect: message.rect,
      scale: 1,
    });
  }

  if (message.type === "scroll-atlas:download") {
    return startDownload(message.dataUrl, message.filename);
  }

  return false;
});

What does this listener do?

  • The scroll-atlas:capture branch confirms that the request came from a browser tab.
  • The captureVisibleTab() call returns a screenshot data URL in PNG format.
  • The rect value can describe a page rectangle. This first recorder leaves it undefined to capture the visible viewport.
  • The scroll-atlas:download branch passes the returned image to the download helper.
  • Save background.js.
  • Switch back to the about:debugging page from earlier.
  • Click Reload on the Scroll Atlas temporary extension card.

The extension remains listed without a manifest or background-script error.

Does the extension report a background error?

Confirm that the first line still defines extensionApi. Check the braces around both message branches.

Still stuck? Help me check my background message listener.

The listener now knows when a download is requested. The next helper turns the screenshot data into an object URL that Firefox can save.

  • Return to background.js in Visual Studio Code.
  • Add this function below the message listener:
async function startDownload(dataUrl, filename) {
  const response = await fetch(dataUrl);
  const blob = await response.blob();
  const objectUrl = URL.createObjectURL(blob);

  try {
    const downloadId = await extensionApi.downloads.download({
      url: objectUrl,
      filename,
      conflictAction: "uniquify",
      saveAs: false,
    });
    pendingObjectUrls.set(downloadId, objectUrl);
    return downloadId;
  } catch (error) {
    URL.revokeObjectURL(objectUrl);
    throw error;
  }
}

How does the download helper work?

  • The helper converts the screenshot data URL into a blob.
  • The object URL gives the downloads API a local URL it can save.
  • The uniquify conflict action protects an existing file with the same name.
  • The pendingObjectUrls map keeps each object URL available until its download finishes.
  • Save background.js.
  • Switch back to about:debugging.
  • Click Reload on the Scroll Atlas extension card.

The extension reloads successfully with the download helper available.

Does the extension fail to reload?

Check that startDownload() sits after the message listener. Confirm that the function ends with one closing brace.

Still stuck? Help me find the syntax problem in the download helper.

Object URLs occupy browser memory while they remain active. The final listener releases each URL when its download completes or fails.

  • Return to background.js.
  • Append this download cleanup listener below startDownload():
extensionApi.downloads.onChanged.addListener((change) => {
  const objectUrl = pendingObjectUrls.get(change.id);
  if (!objectUrl) {
    return;
  }

  const finished = change.state?.current === "complete";
  const failed = Boolean(change.error?.current);

  if (finished || failed) {
    URL.revokeObjectURL(objectUrl);
    pendingObjectUrls.delete(change.id);
  }
});

Why track download changes?

  • The listener matches each changed download to its stored object URL.
  • A completed download releases its object URL.
  • A failed download also releases its object URL.
  • Save background.js.
  • Switch back to about:debugging.
  • Click Reload on the Scroll Atlas extension card.

The temporary extension reloads without a background-script error.

Does cleanup trigger an error?

Confirm that pendingObjectUrls is declared at the top of the file. Check that change.id uses the same capitalization in both places.

Still stuck? Help me debug the download cleanup listener.

✔️ Awesome, I've got everything!

Your background script can now capture a PNG data URL and save it through Firefox.

ⓧ I'd like to double check the full code

The complete background.js file should match this reference.

const extensionApi = globalThis.browser ?? globalThis.chrome;
const pendingObjectUrls = new Map();

extensionApi.runtime.onMessage.addListener((message, sender) => {
  if (message.type === "scroll-atlas:capture") {
    if (!sender.tab) {
      return Promise.reject(new Error("Capture request has no source tab."));
    }

    return extensionApi.tabs.captureVisibleTab(sender.tab.windowId, {
      format: "png",
      rect: message.rect,
      scale: 1,
    });
  }

  if (message.type === "scroll-atlas:download") {
    return startDownload(message.dataUrl, message.filename);
  }

  return false;
});

async function startDownload(dataUrl, filename) {
  const response = await fetch(dataUrl);
  const blob = await response.blob();
  const objectUrl = URL.createObjectURL(blob);

  try {
    const downloadId = await extensionApi.downloads.download({
      url: objectUrl,
      filename,
      conflictAction: "uniquify",
      saveAs: false,
    });
    pendingObjectUrls.set(downloadId, objectUrl);
    return downloadId;
  } catch (error) {
    URL.revokeObjectURL(objectUrl);
    throw error;
  }
}

extensionApi.downloads.onChanged.addListener((change) => {
  const objectUrl = pendingObjectUrls.get(change.id);
  if (!objectUrl) {
    return;
  }

  const finished = change.state?.current === "complete";
  const failed = Boolean(change.error?.current);

  if (finished || failed) {
    URL.revokeObjectURL(objectUrl);
    pendingObjectUrls.delete(change.id);
  }
});
Inject the visible-page recorder

A content script runs inside the active webpage. It asks the background script for an image before asking it to save that image.

  • In the Visual Studio Code Explorer sidebar, select recorder.js.
  • Replace the existing contents with this installation guard and message listener:
(() => {
  if (globalThis.__scrollAtlasInstalled) {
    return;
  }

  globalThis.__scrollAtlasInstalled = true;
  const extensionApi = globalThis.browser ?? globalThis.chrome;
  let activeRun = null;

  extensionApi.runtime.onMessage.addListener((message) => {
    if (message.type !== "scroll-atlas:start") {
      return false;
    }

    if (activeRun) {
      return Promise.resolve({
        started: false,
        reason: "A Scroll Atlas capture is already running in this tab.",
      });
    }

    activeRun = runCapture(message.options).finally(() => {
      activeRun = null;
    });

    return Promise.resolve({ started: true });
  });
})();

How does the recorder start safely?

  • The installation guard prevents repeated popup clicks from registering duplicate listeners.
  • The listener accepts only the scroll-atlas:start message.
  • The activeRun value prevents two captures from running in the same tab.
  • Save recorder.js.

Is the recorder wrapper incomplete?

Confirm that the file starts with (() => {. Confirm that the final line is })();.

Still stuck? Help me check the recorder installation wrapper.

The listener delegates each accepted request to runCapture(). This first implementation makes one capture request before forwarding the returned image to the download handler.

  • In recorder.js, locate the final })(); line.
  • Insert this function immediately above that line:
  async function runCapture() {
    const dataUrl = await extensionApi.runtime.sendMessage({
      type: "scroll-atlas:capture",
    });

    await extensionApi.runtime.sendMessage({
      type: "scroll-atlas:download",
      dataUrl,
      filename: "Scroll Atlas/visible-capture.png",
    });
  }

What does this first capture do?

  • The first message asks the background script for a visible PNG capture.
  • The second message sends that image back with the filename Scroll Atlas/visible-capture.png.
  • The background script saves the file beneath the browser's default download directory.
  • Save recorder.js.
  • Switch back to about:debugging.
  • Click Reload on the Scroll Atlas extension card.

The temporary extension remains loaded with the recorder file ready for injection.

Does the recorder fail after injection?

Confirm that runCapture() appears inside the outer wrapper. It must sit above the final })(); line.

Still stuck? Help me place runCapture inside recorder.js.

✔️ Awesome, I've got everything!

Your recorder can now accept one start message and request one local PNG download.

ⓧ I'd like to double check the full code

The complete recorder.js file for this step should match this reference.

(() => {
  if (globalThis.__scrollAtlasInstalled) {
    return;
  }

  globalThis.__scrollAtlasInstalled = true;
  const extensionApi = globalThis.browser ?? globalThis.chrome;
  let activeRun = null;

  extensionApi.runtime.onMessage.addListener((message) => {
    if (message.type !== "scroll-atlas:start") {
      return false;
    }

    if (activeRun) {
      return Promise.resolve({
        started: false,
        reason: "A Scroll Atlas capture is already running in this tab.",
      });
    }

    activeRun = runCapture(message.options).finally(() => {
      activeRun = null;
    });

    return Promise.resolve({ started: true });
  });

  async function runCapture() {
    const dataUrl = await extensionApi.runtime.sendMessage({
      type: "scroll-atlas:capture",
    });

    await extensionApi.runtime.sendMessage({
      type: "scroll-atlas:download",
      dataUrl,
      filename: "Scroll Atlas/visible-capture.png",
    });
  }
})();

The popup now needs to identify the active tab before injecting the recorder. Its final message includes the selected capture mode and a numeric loading-pass value.

  • In the Visual Studio Code Explorer sidebar, select popup.js.
  • Add this button listener below the existing dynamic-mode listener:
startButton.addEventListener("click", async () => {
  startButton.disabled = true;
  status.textContent = "Starting capture...";

  try {
    const [tab] = await extensionApi.tabs.query({
      active: true,
      currentWindow: true,
    });

    if (!tab || tab.id === undefined) {
      throw new Error("No active tab is available.");
    }

    const results = await extensionApi.scripting.executeScript({
      target: { tabId: tab.id },
      files: ["recorder.js"],
    });
  } catch (error) {
    status.textContent = `Could not start: ${error.message}`;
    startButton.disabled = false;
  }
});

How does the popup reach the page?

  • The active-tab query returns the selected tab in the current Firefox window.
  • The missing-tab check stops the request when Firefox cannot provide a tab identifier.
  • The script injection loads recorder.js into the active page.
  • Save popup.js.
  • Switch back to about:debugging.
  • Click Reload on the Scroll Atlas extension card.

The extension reloads with the active-tab query and recorder injection in place.

Firefox returns an array of injection results. The popup checks those results before sending the options that start the recorder.

  • Return to popup.js.
  • Insert this block immediately after the executeScript() call:
    for (const result of results) {
      if (result.error) {
        throw new Error(result.error.message || String(result.error));
      }
    }

    const response = await extensionApi.tabs.sendMessage(tab.id, {
      type: "scroll-atlas:start",
      options: {
        dynamicMode: dynamicMode.checked,
        maxPasses: Number(maxPasses.value),
      },
    });

    if (!response?.started) {
      throw new Error(response?.reason || "Capture did not start.");
    }

    status.textContent = "Capture started. Follow progress on the page.";
    setTimeout(() => window.close(), 500);

What completes the start sequence?

  • The result loop surfaces an injection failure through the popup status.
  • The scroll-atlas:start message carries the checkbox state.
  • The Number() conversion turns the selected pass value into a number.
  • The popup closes after the recorder confirms that the run started.
  • Save popup.js.
  • Switch back to about:debugging.
  • Click Reload on the Scroll Atlas extension card.

The temporary extension reloads without a popup-script error.

Does the popup report that capture could not start?

Confirm that the new block remains inside the existing try section. Check that it appears before the catch section.

Still stuck? Help me debug the popup start sequence.

✔️ Awesome, I've got everything!

Your popup now queries the active tab, injects the recorder, and sends the selected options.

ⓧ I'd like to double check the full code

The complete popup.js file should match this reference.

const extensionApi = globalThis.browser ?? globalThis.chrome;

const dynamicMode = document.querySelector("#dynamic-mode");
const maxPasses = document.querySelector("#max-passes");
const startButton = document.querySelector("#start");
const status = document.querySelector("#status");

dynamicMode.addEventListener("change", () => {
  maxPasses.disabled = !dynamicMode.checked;
  status.textContent = dynamicMode.checked
    ? "Dynamic mode will perform bounded loading passes."
    : "Finite-page mode is ready.";
});

startButton.addEventListener("click", async () => {
  startButton.disabled = true;
  status.textContent = "Starting capture...";

  try {
    const [tab] = await extensionApi.tabs.query({
      active: true,
      currentWindow: true,
    });

    if (!tab || tab.id === undefined) {
      throw new Error("No active tab is available.");
    }

    const results = await extensionApi.scripting.executeScript({
      target: { tabId: tab.id },
      files: ["recorder.js"],
    });

    for (const result of results) {
      if (result.error) {
        throw new Error(result.error.message || String(result.error));
      }
    }

    const response = await extensionApi.tabs.sendMessage(tab.id, {
      type: "scroll-atlas:start",
      options: {
        dynamicMode: dynamicMode.checked,
        maxPasses: Number(maxPasses.value),
      },
    });

    if (!response?.started) {
      throw new Error(response?.reason || "Capture did not start.");
    }

    status.textContent = "Capture started. Follow progress on the page.";
    setTimeout(() => window.close(), 500);
  } catch (error) {
    status.textContent = `Could not start: ${error.message}`;
    startButton.disabled = false;
  }
});
Run the visible capture

The complete path is ready for its first real test. Use a normal webpage with enough content to scroll beyond the current viewport.

  • Open a new Firefox tab.
  • Navigate to a normal article or documentation page that extends below the viewport.
  • Scroll to an identifiable section in the middle of the page.
  • Keep that webpage selected as the active tab.

Before you start the capture, how much of the page do you think one screenshot request can preserve?

  • Click the Scroll Atlas action in the Firefox toolbar.
  • Leave This page loads more while scrolling unchecked.
  • Click Start capture.

The popup reports that capture started before closing. Firefox downloads one PNG through its download manager.

  • Click Finder in the macOS Dock.
  • Select Downloads in the Finder sidebar.
  • Open the Scroll Atlas folder.
  • Open visible-capture.png.

The downloaded PNG matches only the viewport that was visible when capture ran. Content outside that viewport is absent.

Why is content missing?

This first recorder asks Firefox for one visible capture. It never moves the document to reveal the remaining content.

An independent split pane would keep its hidden rows outside this image too. The shortfall proves that document scrolling and element scrolling require separate recording strategies.

Did the PNG fail to download?

  • Confirm that the target tab stayed active while the capture ran.
  • Use an ordinary webpage instead of reader view, a PDF viewer page, or a built-in Firefox page.
  • Return to about:debugging and click Reload after saving all three JavaScript files.

Still stuck? Help me trace the missing screenshot download.

That is the full message chain working from toolbar click to downloaded PNG. Next, you will replace this single viewport image with numbered rectangles that cover the finite document.

Build the Document Tile Atlas

Your first Firefox capture proved that the extension can save the visible tab. A finite page often extends far beyond that viewport.

This step turns the document into bounded rectangles. Each rectangle becomes a numbered PNG with matching coordinates in a JSON manifest.

In This Step, Get Ready To:
  • Inspect the split-view test fixture.
  • Divide the finite document into bounded screenshot rectangles.
  • Download numbered document tiles with a matching manifest.
Inspect the Test Fixture

A controlled fixture makes the capture boundary visible. The document has its own scroll range while each pane holds additional internal content.

  • Switch back to the Visual Studio Code window from earlier.
  • Locate test-page.html in the scroll-atlas file list.
  • Drag test-page.html into the Firefox window.
  • Scroll down the document to confirm that four feed articles extend below the split view.
  • Move the left pane horizontally to reveal later board cells.
  • Move the left pane vertically to reveal lower board cells.
  • Move the right pane vertically to reveal later messages.

You can now observe three separate moving surfaces. The page moves as a document while each pane keeps its own position.

Why Use a Controlled Fixture?

The fixture separates document scrolling from element scrolling. That makes missing content easy to identify in the downloaded images.

The growing feed remains disabled during this step. This keeps the document finite while you prove the rectangle logic.

Add the Atlas Utilities

The atlas needs a maximum rectangle size plus stable file names. It also needs short rendering pauses before capture.

  • Switch to recorder.js from earlier.
  • Find the declarations for extensionApi and activeRun near the top of the file.
  • Use these lines as the current reference:
const extensionApi = globalThis.browser ?? globalThis.chrome;
let activeRun = null;
  • Replace those declarations with the atlas limits shown below:
const extensionApi = globalThis.browser ?? globalThis.chrome;
const TILE_LIMIT = 1600;
const SETTLE_DELAY_MS = 180;
let activeRun = null;

What Do These Limits Control?

  • TILE_LIMIT caps each screenshot rectangle at 1600 CSS pixels per axis.
  • SETTLE_DELAY_MS gives the page a short pause after scrolling.
  • Move to the bottom of recorder.js.
  • Paste these naming helpers immediately above the final })(); line:
function makeSessionName() {
  const page = (document.title || location.hostname || "page")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "")
    .slice(0, 48) || "page";
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
  return `${page}-${timestamp}`;
}

function pad(value) {
  return String(Math.round(value)).padStart(6, "0");
}

How Are Sessions Named?

  • makeSessionName() converts the page title into a folder-safe label.
  • The timestamp keeps separate capture sessions from overwriting each other.
  • pad() gives every tile coordinate six digits for predictable filename ordering.
  • Save recorder.js.
  • Return to Firefox.
  • Visit about:debugging.
  • Select This Firefox.
  • Click Reload for Scroll Atlas.
  • Return to the test page.
  • Click the Scroll Atlas toolbar action.
  • Click Start capture.

You should receive one new viewport PNG. This confirms that the existing proof of concept still works after the utility additions.

Did the Existing Capture Stop Working?

  • Confirm that both constants remain inside the outer function in recorder.js.
  • Confirm that both naming helpers appear above the final })(); line.
  • Reload the temporary extension after saving the file.

Still stuck? Help me check where I added the Scroll Atlas constants and helper functions.

Capture the Document Rectangles

The recorder now needs one path for downloading PNG data. It also needs a separate path for saving the manifest after all rectangles finish.

  • Return to the bottom of recorder.js.
  • Paste these manifest and rendering helpers above makeSessionName():
async function saveJson(value, filename) {
  const dataUrl = `data:application/json;charset=utf-8,${encodeURIComponent(
    JSON.stringify(value, null, 2),
  )}`;

  await extensionApi.runtime.sendMessage({
    type: "scroll-atlas:download",
    dataUrl,
    filename,
  });
}

function pause(milliseconds) {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

function nextPaint() {
  return new Promise((resolve) => {
    requestAnimationFrame(() => requestAnimationFrame(resolve));
  });
}

async function settle() {
  await nextPaint();
  await pause(SETTLE_DELAY_MS);
}

What Do These Helpers Do?

  • saveJson() converts the manifest into a downloadable data URL.
  • nextPaint() waits for two rendering frames.
  • settle() adds the short delay used after moving the document.
  • Paste the PNG download helper immediately above saveJson():
async function captureAndSave(rect, filename) {
  const dataUrl = await extensionApi.runtime.sendMessage({
    type: "scroll-atlas:capture",
    rect,
  });

  await extensionApi.runtime.sendMessage({
    type: "scroll-atlas:download",
    dataUrl,
    filename,
  });
}

How Does a Rectangle Become a File?

captureAndSave() sends the page-relative rectangle to the background script. The returned PNG data URL is sent back with its destination filename.

  • Save recorder.js.
  • Return to about:debugging in Firefox.
  • Click Reload for Scroll Atlas.
  • Return to the test page.
  • Click the Scroll Atlas toolbar action.
  • Click Start capture.

You should receive another viewport PNG. The original message path still works with the new download helpers in place.

The next function measures the document through scrollWidth and scrollHeight. Nested loops turn those dimensions into page-relative rectangles.

  • Paste the document capture function immediately above captureAndSave():
async function captureDocument(scrollingElement, folder, captureAndSave) {
  const width = scrollingElement.scrollWidth;
  const height = scrollingElement.scrollHeight;
  const tiles = [];

  for (let y = 0; y < height; y += TILE_LIMIT) {
    for (let x = 0; x < width; x += TILE_LIMIT) {
      const rect = {
        x,
        y,
        width: Math.min(TILE_LIMIT, width - x),
        height: Math.min(TILE_LIMIT, height - y),
      };
      const filename = `${folder}/document/tile-x${pad(x)}-y${pad(y)}.png`;

      await captureAndSave(rect, filename);
      tiles.push({ filename, contentX: x, contentY: y, rect });
    }
  }

  return {
    id: "document", kind: "document", label: "document.scrollingElement",
    scrollWidth: width, scrollHeight: height,
    clientWidth: scrollingElement.clientWidth,
    clientHeight: scrollingElement.clientHeight, tiles,
  };
}

How Does the Tile Grid Work?

  • The outer loop advances through vertical content coordinates.
  • The inner loop advances through horizontal content coordinates.
  • Math.min() trims tiles along the right edge or bottom edge.
  • Each tile record keeps its filename plus its content coordinates plus its screenshot rectangle.
  • Find the existing runCapture(options) function that creates one visible PNG.
  • Select that complete function without selecting the surrounding message listener.
  • Replace the selected function with this atlas workflow:
async function runCapture(options) {
  const scrollingElement = document.scrollingElement || document.documentElement;
  const sessionName = makeSessionName();
  const folder = `Scroll Atlas/${sessionName}`;
  const manifest = {
    formatVersion: 1,
    sourceUrl: location.href,
    pageTitle: document.title,
    startedAt: new Date().toISOString(),
    finishedAt: null,
    status: "running",
    mode: "finite",
    requestedLoadingPasses: 0,
    tileLimitCssPixels: TILE_LIMIT,
    targets: [],
  };

  scrollingElement.scrollTo({ left: 0, top: 0, behavior: "instant" });
  await settle();

  manifest.targets.push(
    await captureDocument(scrollingElement, folder, captureAndSave),
  );

  manifest.status = "complete";
  manifest.finishedAt = new Date().toISOString();
  await saveJson(manifest, `${folder}/manifest.json`);
}

What Does the Atlas Workflow Record?

  • document.scrollingElement identifies the element that owns the page scroll range.
  • Scroll Atlas/<session>/ gives every run a separate download folder.
  • The manifest starts with a running status.
  • The status changes to complete after every document rectangle has been saved.

✔️ Awesome, I've got everything!

Your recorder now has the measurement loop plus the download helpers plus the finite-mode manifest workflow.

ⓧ I'd like to double check the full code

  • Compare your entire recorder.js file with this cumulative version.
(() => {
  if (globalThis.__scrollAtlasInstalled) {
    return;
  }

  globalThis.__scrollAtlasInstalled = true;
  const extensionApi = globalThis.browser ?? globalThis.chrome;
  const TILE_LIMIT = 1600;
  const SETTLE_DELAY_MS = 180;
  let activeRun = null;

  extensionApi.runtime.onMessage.addListener((message) => {
    if (message.type !== "scroll-atlas:start") {
      return false;
    }

    if (activeRun) {
      return Promise.resolve({
        started: false,
        reason: "A Scroll Atlas capture is already running in this tab.",
      });
    }

    activeRun = runCapture(message.options).finally(() => {
      activeRun = null;
    });

    return Promise.resolve({ started: true });
  });

  async function runCapture(options) {
    const scrollingElement = document.scrollingElement || document.documentElement;
    const sessionName = makeSessionName();
    const folder = `Scroll Atlas/${sessionName}`;
    const manifest = {
      formatVersion: 1,
      sourceUrl: location.href,
      pageTitle: document.title,
      startedAt: new Date().toISOString(),
      finishedAt: null,
      status: "running",
      mode: "finite",
      requestedLoadingPasses: 0,
      tileLimitCssPixels: TILE_LIMIT,
      targets: [],
    };

    scrollingElement.scrollTo({ left: 0, top: 0, behavior: "instant" });
    await settle();

    manifest.targets.push(
      await captureDocument(scrollingElement, folder, captureAndSave),
    );

    manifest.status = "complete";
    manifest.finishedAt = new Date().toISOString();
    await saveJson(manifest, `${folder}/manifest.json`);
  }

  async function captureAndSave(rect, filename) {
    const dataUrl = await extensionApi.runtime.sendMessage({
      type: "scroll-atlas:capture",
      rect,
    });

    await extensionApi.runtime.sendMessage({
      type: "scroll-atlas:download",
      dataUrl,
      filename,
    });
  }

  async function captureDocument(scrollingElement, folder, captureAndSave) {
    const width = scrollingElement.scrollWidth;
    const height = scrollingElement.scrollHeight;
    const tiles = [];

    for (let y = 0; y < height; y += TILE_LIMIT) {
      for (let x = 0; x < width; x += TILE_LIMIT) {
        const rect = {
          x,
          y,
          width: Math.min(TILE_LIMIT, width - x),
          height: Math.min(TILE_LIMIT, height - y),
        };
        const filename = `${folder}/document/tile-x${pad(x)}-y${pad(y)}.png`;

        await captureAndSave(rect, filename);
        tiles.push({ filename, contentX: x, contentY: y, rect });
      }
    }

    return {
      id: "document", kind: "document", label: "document.scrollingElement",
      scrollWidth: width, scrollHeight: height,
      clientWidth: scrollingElement.clientWidth,
      clientHeight: scrollingElement.clientHeight, tiles,
    };
  }

  function makeSessionName() {
    const page = (document.title || location.hostname || "page")
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-|-$/g, "")
      .slice(0, 48) || "page";
    const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
    return `${page}-${timestamp}`;
  }

  function pad(value) {
    return String(Math.round(value)).padStart(6, "0");
  }

  async function saveJson(value, filename) {
    const dataUrl = `data:application/json;charset=utf-8,${encodeURIComponent(
      JSON.stringify(value, null, 2),
    )}`;

    await extensionApi.runtime.sendMessage({
      type: "scroll-atlas:download",
      dataUrl,
      filename,
    });
  }

  function pause(milliseconds) {
    return new Promise((resolve) => setTimeout(resolve, milliseconds));
  }

  function nextPaint() {
    return new Promise((resolve) => {
      requestAnimationFrame(() => requestAnimationFrame(resolve));
    });
  }

  async function settle() {
    await nextPaint();
    await pause(SETTLE_DELAY_MS);
  }
})();
  • Save recorder.js.
  • Return to about:debugging in Firefox.
  • Click Reload for Scroll Atlas.
  • Return to test-page.html.
  • Move the left pane to its top-left position.
  • Move the right pane to its top position.

Before you start the final capture, do you expect the test page to produce one viewport image or a numbered document tile set?

  • Click the Scroll Atlas toolbar action.
  • Leave This page loads more while scrolling unchecked.
  • Click Start capture.
  • Keep the test page active until the downloads finish.

You should receive a timestamped session folder inside Scroll Atlas. It contains manifest.json plus a document folder with one or more numbered PNG tiles.

  • Open the newest Scroll Atlas session folder in your Downloads folder.
  • Open the document folder.
  • Open tile-x000000-y000000.png.
  • Drag manifest.json into the Visual Studio Code window.

The PNG set now includes document content that sat below the original viewport. The manifest records the document dimensions plus each tile's contentX value plus its contentY value plus its rect object.

The split panes still expose only their current internal views inside the document tiles. Their far-right cells plus lower rows remain clipped because each pane owns a separate scroll position.

This Shortfall Is Intentional

The document atlas successfully covers the page's finite outer surface. It cannot reveal content hidden behind an element's independent scrollport.

That result proves the page document plus each scrolling pane are separate recording targets.

Missing Tiles or the Manifest?

  • Confirm that you reloaded Scroll Atlas after saving recorder.js.
  • Keep test-page.html active throughout the capture.
  • Check that captureDocument() calls captureAndSave() before adding each tile record.

Still stuck? Help me debug why Scroll Atlas is not downloading document tiles or manifest.json.

You now have the first real Scroll Atlas. It reaches offscreen document content through bounded PNG rectangles. Next, you'll record each independent pane as its own two-axis target.

Record Independent Scroll Regions

Your document atlas now reaches content beyond the browser viewport. However, its tiles preserve only the visible slice of each pane.

This step teaches Scroll Atlas to treat every independent scroll container as its own recording surface. Each detected region gets separate coordinates, PNG tiles, and manifest metadata.

In this step, get ready to:
  • Detect elements with eligible horizontal or vertical overflow.
  • Scroll each detected region across both axes and capture its visible scrollport.
  • Verify that the wide board and message history appear as separate manifest targets.
Discover overflowing elements

A document and its nested panes keep separate scroll positions. Scroll Atlas needs to measure each element before deciding whether that element contains recordable offscreen content.

  • In the Explorer sidebar in Visual Studio Code, return to recorder.js.
  • Find the SETTLE_DELAY_MS constant near the top of recorder.js.
  • Add this overflow set directly below that constant:
  const SCROLLABLE_OVERFLOW = new Set(["auto", "scroll", "hidden"]);

What does this set represent?

The set identifies computed overflow values that can support a scroll container. The discovery function uses one membership check for both axes.

  • Find the captureDocument() function in recorder.js.
  • Add this discovery function immediately above captureDocument():
  function discoverScrollRegions() {
    return Array.from(document.querySelectorAll("*")).filter((element) => {
      if (element.closest("[data-scroll-atlas-ui]")) {
        return false;
      }

      const style = getComputedStyle(element);
      const horizontal =
        SCROLLABLE_OVERFLOW.has(style.overflowX) &&
        element.scrollWidth > element.clientWidth + 1;
      const vertical =
        SCROLLABLE_OVERFLOW.has(style.overflowY) &&
        element.scrollHeight > element.clientHeight + 1;

      return (
        (horizontal || vertical) &&
        element.clientWidth > 0 &&
        element.clientHeight > 0
      );
    });
  }

How does region discovery work?

  • The universal selector scans every element in the top document.
  • The closest() check keeps Scroll Atlas interface elements out of the results.
  • The horizontal test compares scrollWidth with clientWidth.
  • The vertical test compares scrollHeight with clientHeight.
  • The final size checks reject elements without a visible scrollport.

Why does hidden overflow count?

An element with overflow: hidden can still move through code with scrollTo(). The set excludes overflow: clip because clipped overflow does not support programmatic scrolling.

Capture each region as tiles

Each region needs a tile grid based on its own scrollable dimensions. The recorder moves the region first and converts the visible piece back into page coordinates for Firefox.

This function is the fiddliest edit in the step because its loops track content coordinates, scroll offsets, and screenshot coordinates at the same time. You will assemble it in three adjacent parts.

  • Scroll to the end of the captureDocument() function.
  • Start captureRegion() directly below it with this first part:
  async function captureRegion(
    element,
    regionNumber,
    label,
    folder,
    captureAndSave,
  ) {
    const width = element.scrollWidth;
    const height = element.scrollHeight;
    const clientWidth = element.clientWidth;
    const clientHeight = element.clientHeight;
    const stepX = Math.max(1, Math.min(TILE_LIMIT, clientWidth));
    const stepY = Math.max(1, Math.min(TILE_LIMIT, clientHeight));
    const maxLeft = Math.max(0, width - clientWidth);
    const maxTop = Math.max(0, height - clientHeight);
    const regionId = `region-${String(regionNumber).padStart(2, "0")}`;
    const tiles = [];

    for (let y = 0; y < height; y += stepY) {
      for (let x = 0; x < width; x += stepX) {
        const scrollLeft = Math.min(x, maxLeft);
        const scrollTop = Math.min(y, maxTop);

How is the region grid sized?

The step size cannot exceed the region's visible width or height. The maximum offsets stop the final tile from scrolling beyond the available content.

The padded region number creates predictable folders such as region-01 and region-02.

  • Continue inside the inner tile loop with this coordinate logic:
        element.scrollTo({
          left: scrollLeft,
          top: scrollTop,
          behavior: "instant",
        });
        await settle();

        const physicalX = x - scrollLeft;
        const physicalY = y - scrollTop;
        const tileWidth = Math.min(stepX, width - x, clientWidth - physicalX);
        const tileHeight = Math.min(stepY, height - y, clientHeight - physicalY);
        const bounds = element.getBoundingClientRect();
        const pageX = bounds.left + window.scrollX + element.clientLeft + physicalX;
        const pageY = bounds.top + window.scrollY + element.clientTop + physicalY;
        const rect = {
          x: Math.max(0, Math.floor(pageX)),
          y: Math.max(0, Math.floor(pageY)),
          width: Math.max(1, Math.floor(tileWidth)),
          height: Math.max(1, Math.floor(tileHeight)),
        };

How do content coordinates become page coordinates?

  • The region moves to the requested scrollLeft and scrollTop offsets before the recorder measures it.
  • The physical offsets describe where the requested content begins inside the visible scrollport.
  • The getBoundingClientRect() result locates the scrollport relative to the viewport.
  • The window scroll offsets convert that position into page coordinates for the screenshot rectangle.
  • Finish the tile loop and close captureRegion() with this final part:
        const filename = `${folder}/${regionId}/tile-x${pad(x)}-y${pad(y)}.png`;

        await captureAndSave(rect, filename);
        tiles.push({
          filename,
          contentX: x,
          contentY: y,
          scrollLeft,
          scrollTop,
          rect,
        });
      }
    }

    return {
      id: regionId,
      kind: "scroll-region",
      label,
      overflowX: getComputedStyle(element).overflowX,
      overflowY: getComputedStyle(element).overflowY,
      scrollWidth: width,
      scrollHeight: height,
      clientWidth,
      clientHeight,
      tiles,
    };
  }

What does each region target record?

Every PNG filename carries its content origin. Its manifest entry also preserves the element offsets used for that capture.

The target-level dimensions describe the whole scrollable surface. This metadata lets you audit coverage without guessing from the images.

Connect regions to the capture run

The recorder can now discover and capture regions. The final connection gives each element a readable label and runs region capture after the document atlas.

  • Find the makeSessionName() function near the bottom of recorder.js.
  • Add this label helper immediately above makeSessionName():
  function describeElement(element, index) {
    if (element.id) {
      return `${element.tagName.toLowerCase()}#${element.id}`;
    }

    const name = element.getAttribute("aria-label") || element.getAttribute("role");
    return name
      ? `${element.tagName.toLowerCase()}[${name}]`
      : `${element.tagName.toLowerCase()} region ${index}`;
  }

How are region labels chosen?

An element ID produces the clearest label. An accessible name or role becomes the fallback before the recorder uses a numbered generic label.

  • Inside the try block in runCapture(), find the current manifest update that invokes captureDocument().
  • Replace that document-only update with this document and region sequence:
      const regions = discoverScrollRegions();

      manifest.targets.push(
        await captureDocument(scrollingElement, folder, captureAndSave),
      );

      for (let index = 0; index < regions.length; index += 1) {
        const element = regions[index];
        const label = describeElement(element, index + 1);

        element.scrollIntoView({
          behavior: "instant",
          block: "nearest",
          inline: "nearest",
        });
        await settle();

        element.scrollTo({ left: 0, top: 0, behavior: "instant" });
        await settle();

        manifest.targets.push(
          await captureRegion(
            element,
            index + 1,
            label,
            folder,
            captureAndSave,
          ),
        );
      }

What changes in the capture run?

The document remains the first manifest target. The loop then reveals each detected region and resets its internal position before capture begins.

Each completed call appends another target to the same JSON manifest. The result describes one page as a collection of independently moving surfaces.

  • Save recorder.js.

✔️ Awesome, I've got everything!

Your recorder now discovers overflowing elements and sends each one through its own two-axis tile loop. Keep recorder.js saved before reloading the extension.

ⓧ I'd like to double check the full code

Compare your cumulative recorder.js with this finite-region version. The discovery helper appears before document capture, while the label helper remains near the bottom.

(() => {
  if (globalThis.__scrollAtlasInstalled) {
    return;
  }

  globalThis.__scrollAtlasInstalled = true;
  const extensionApi = globalThis.browser ?? globalThis.chrome;
  const TILE_LIMIT = 1600;
  const SETTLE_DELAY_MS = 180;
  const SCROLLABLE_OVERFLOW = new Set(["auto", "scroll", "hidden"]);
  let activeRun = null;

  extensionApi.runtime.onMessage.addListener((message) => {
    if (message.type !== "scroll-atlas:start") {
      return false;
    }

    if (activeRun) {
      return Promise.resolve({
        started: false,
        reason: "A Scroll Atlas capture is already running in this tab.",
      });
    }

    activeRun = runCapture(message.options).finally(() => {
      activeRun = null;
    });

    return Promise.resolve({ started: true });
  });

  async function runCapture(options) {
    const scrollingElement = document.scrollingElement || document.documentElement;
    const sessionName = makeSessionName();
    const folder = `Scroll Atlas/${sessionName}`;
    let tileCount = 0;

    const manifest = {
      formatVersion: 1,
      sourceUrl: location.href,
      pageTitle: document.title,
      startedAt: new Date().toISOString(),
      finishedAt: null,
      status: "running",
      mode: options.dynamicMode ? "dynamic" : "finite",
      tileLimitCssPixels: TILE_LIMIT,
      targets: [],
      warnings: [
        "Keep the target tab active while captureVisibleTab is running.",
        "Cross-origin frames, privileged browser pages, closed panels, clicked pagination, and overflow: clip content are not automatically traversed.",
      ],
    };

    async function captureAndSave(rect, filename) {
      const dataUrl = await extensionApi.runtime.sendMessage({
        type: "scroll-atlas:capture",
        rect,
      });

      await extensionApi.runtime.sendMessage({
        type: "scroll-atlas:download",
        dataUrl,
        filename,
      });

      tileCount += 1;
      await pause(80);
    }

    try {
      scrollingElement.scrollTo({ left: 0, top: 0, behavior: "instant" });
      await settle();

      const regions = discoverScrollRegions();

      manifest.targets.push(
        await captureDocument(scrollingElement, folder, captureAndSave),
      );

      for (let index = 0; index < regions.length; index += 1) {
        const element = regions[index];
        const label = describeElement(element, index + 1);

        element.scrollIntoView({
          behavior: "instant",
          block: "nearest",
          inline: "nearest",
        });
        await settle();

        element.scrollTo({ left: 0, top: 0, behavior: "instant" });
        await settle();

        manifest.targets.push(
          await captureRegion(
            element,
            index + 1,
            label,
            folder,
            captureAndSave,
          ),
        );
      }

      manifest.status = "complete";
    } catch (error) {
      manifest.status = "failed";
      manifest.error = error.message;
    } finally {
      manifest.finishedAt = new Date().toISOString();
      manifest.savedTileCount = tileCount;

      await saveJson(manifest, `${folder}/manifest.json`);
    }
  }

  function discoverScrollRegions() {
    return Array.from(document.querySelectorAll("*")).filter((element) => {
      if (element.closest("[data-scroll-atlas-ui]")) {
        return false;
      }

      const style = getComputedStyle(element);
      const horizontal =
        SCROLLABLE_OVERFLOW.has(style.overflowX) &&
        element.scrollWidth > element.clientWidth + 1;
      const vertical =
        SCROLLABLE_OVERFLOW.has(style.overflowY) &&
        element.scrollHeight > element.clientHeight + 1;

      return (
        (horizontal || vertical) &&
        element.clientWidth > 0 &&
        element.clientHeight > 0
      );
    });
  }

  async function captureDocument(
    scrollingElement,
    folder,
    captureAndSave,
  ) {
    const width = scrollingElement.scrollWidth;
    const height = scrollingElement.scrollHeight;
    const tiles = [];

    for (let y = 0; y < height; y += TILE_LIMIT) {
      for (let x = 0; x < width; x += TILE_LIMIT) {
        const rect = {
          x,
          y,
          width: Math.min(TILE_LIMIT, width - x),
          height: Math.min(TILE_LIMIT, height - y),
        };
        const filename = `${folder}/document/tile-x${pad(x)}-y${pad(y)}.png`;

        await captureAndSave(rect, filename);
        tiles.push({ filename, contentX: x, contentY: y, rect });
      }
    }

    return {
      id: "document",
      kind: "document",
      label: "document.scrollingElement",
      scrollWidth: width,
      scrollHeight: height,
      clientWidth: scrollingElement.clientWidth,
      clientHeight: scrollingElement.clientHeight,
      tiles,
    };
  }

  async function captureRegion(
    element,
    regionNumber,
    label,
    folder,
    captureAndSave,
  ) {
    const width = element.scrollWidth;
    const height = element.scrollHeight;
    const clientWidth = element.clientWidth;
    const clientHeight = element.clientHeight;
    const stepX = Math.max(1, Math.min(TILE_LIMIT, clientWidth));
    const stepY = Math.max(1, Math.min(TILE_LIMIT, clientHeight));
    const maxLeft = Math.max(0, width - clientWidth);
    const maxTop = Math.max(0, height - clientHeight);
    const regionId = `region-${String(regionNumber).padStart(2, "0")}`;
    const tiles = [];

    for (let y = 0; y < height; y += stepY) {
      for (let x = 0; x < width; x += stepX) {
        const scrollLeft = Math.min(x, maxLeft);
        const scrollTop = Math.min(y, maxTop);

        element.scrollTo({
          left: scrollLeft,
          top: scrollTop,
          behavior: "instant",
        });
        await settle();

        const physicalX = x - scrollLeft;
        const physicalY = y - scrollTop;
        const tileWidth = Math.min(stepX, width - x, clientWidth - physicalX);
        const tileHeight = Math.min(stepY, height - y, clientHeight - physicalY);
        const bounds = element.getBoundingClientRect();
        const pageX = bounds.left + window.scrollX + element.clientLeft + physicalX;
        const pageY = bounds.top + window.scrollY + element.clientTop + physicalY;
        const rect = {
          x: Math.max(0, Math.floor(pageX)),
          y: Math.max(0, Math.floor(pageY)),
          width: Math.max(1, Math.floor(tileWidth)),
          height: Math.max(1, Math.floor(tileHeight)),
        };
        const filename = `${folder}/${regionId}/tile-x${pad(x)}-y${pad(y)}.png`;

        await captureAndSave(rect, filename);
        tiles.push({
          filename,
          contentX: x,
          contentY: y,
          scrollLeft,
          scrollTop,
          rect,
        });
      }
    }

    return {
      id: regionId,
      kind: "scroll-region",
      label,
      overflowX: getComputedStyle(element).overflowX,
      overflowY: getComputedStyle(element).overflowY,
      scrollWidth: width,
      scrollHeight: height,
      clientWidth,
      clientHeight,
      tiles,
    };
  }

  function describeElement(element, index) {
    if (element.id) {
      return `${element.tagName.toLowerCase()}#${element.id}`;
    }

    const name = element.getAttribute("aria-label") || element.getAttribute("role");
    return name
      ? `${element.tagName.toLowerCase()}[${name}]`
      : `${element.tagName.toLowerCase()} region ${index}`;
  }

  function makeSessionName() {
    const page = (document.title || location.hostname || "page")
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-|-$/g, "")
      .slice(0, 48) || "page";
    const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
    return `${page}-${timestamp}`;
  }

  function pad(value) {
    return String(Math.round(value)).padStart(6, "0");
  }

  async function saveJson(value, filename) {
    const dataUrl = `data:application/json;charset=utf-8,${encodeURIComponent(
      JSON.stringify(value, null, 2),
    )}`;

    await extensionApi.runtime.sendMessage({
      type: "scroll-atlas:download",
      dataUrl,
      filename,
    });
  }

  function pause(milliseconds) {
    return new Promise((resolve) => setTimeout(resolve, milliseconds));
  }

  function nextPaint() {
    return new Promise((resolve) => {
      requestAnimationFrame(() => requestAnimationFrame(resolve));
    });
  }

  async function settle() {
    await nextPaint();
    await pause(SETTLE_DELAY_MS);
  }
})();

What should the full file contain?

The cumulative file keeps the existing document capture path. It adds one overflow set, one discovery helper, one region capture function, one label helper, and one loop inside runCapture().

The new path should turn the two clipped panes into separate recordings. This is the moment to test whether their far edges finally become visible.

  • Switch back to the existing about:debugging page in Firefox.
  • Select This Firefox if the temporary extension list is not visible.
  • Click Reload for Scroll Atlas.
  • Return to the existing test-page.html tab.

Before you start the capture, how many manifest targets do you expect from the document and its two overflowing panes?

  • Click the Scroll Atlas toolbar action.
  • Leave This page loads more while scrolling unchecked.
  • Click Start capture.
  • Keep the test-page.html tab active until the downloads finish.

You should receive one document folder and at least two numbered region folders. The extra PNGs prove that Scroll Atlas moved through each pane independently.

  • Use Finder to open the Scroll Atlas folder inside your Downloads folder.
  • Open the newest timestamped session folder.
  • Open manifest.json from that session in Visual Studio Code.
  • Confirm that the targets array contains document plus at least two region- entries.
  • Open the wide-board region folder.
  • Inspect its final horizontal PNG tile.
  • Open the message-history region folder.
  • Inspect its final vertical PNG tile.

You should see far-right board cells in the wide-board tiles. You should also see bottom messages that were absent from the document overview.

Only seeing the document target?

  • Confirm that you saved recorder.js before reloading the temporary extension.
  • Check that discoverScrollRegions() runs before the region loop inside runCapture().
  • Confirm that the active tab is the local test-page.html fixture during the whole capture.

Still stuck? Help me debug why Scroll Atlas finds only the document target.

That closes the gap from the previous atlas. Next, you will let users declare a growing page and bound how much expansion Scroll Atlas performs.

Add Bounded Dynamic Capture

Your Firefox extension can now record the document plus each independent pane. Every finite scrolling surface gets its own tile atlas.

Pages that grow near their edges have no trustworthy final size. This step adds a user-selected mode that performs bounded loading passes. It also protects the page with progress reporting, cancellation, partial results, and scroll-position restoration.

In this step, get ready to:
  • Add bounded expansion for the document and every detected scroll region.
  • Add a progress overlay with cancellation and partial-manifest support.
  • Verify growth reporting and scroll-position restoration on the test fixture.
Add bounded loading and cancellation

Dynamic mode treats growth as an explicit user decision. The recorder visits each surface's bottom-right edge until two measurements stay unchanged or the selected pass limit is reached.

A Shadow DOM overlay keeps progress controls separate from the target page. The recorder hides that overlay before each screenshot so it never appears in a saved tile.

How the safety boundary works

Each pass waits for the page to react before measuring its scroll dimensions again. Two unchanged measurements produce a stable result.

Continued growth through the final requested pass produces a safety-limit result. The manifest reports the outcome without claiming that an unbounded source is complete.

  • Switch back to Visual Studio Code from earlier.
  • Select recorder.js in the Explorer sidebar.
  • Use the complete reference below to synchronize recorder.js with the bounded dynamic recorder.

✔️ Awesome, I've got everything!

Great. Confirm that your recorder contains bounded expansion, progress reporting, cancellation, manifest status tracking, and position restoration.

ⓧ I'd like to double check the full code

  • Compare your entire recorder.js file with this reference.
  • Replace any differing section with the matching reference code.
(() => {
  if (globalThis.__scrollAtlasInstalled) {
    return;
  }

  globalThis.__scrollAtlasInstalled = true;
  const extensionApi = globalThis.browser ?? globalThis.chrome;
  const TILE_LIMIT = 1600;
  const SETTLE_DELAY_MS = 180;
  const DYNAMIC_WAIT_MS = 1100;
  const SCROLLABLE_OVERFLOW = new Set(["auto", "scroll", "hidden"]);
  let activeRun = null;

  extensionApi.runtime.onMessage.addListener((message) => {
    if (message.type !== "scroll-atlas:start") {
      return false;
    }

    if (activeRun) {
      return Promise.resolve({
        started: false,
        reason: "A Scroll Atlas capture is already running in this tab.",
      });
    }

    activeRun = runCapture(message.options).finally(() => {
      activeRun = null;
    });

    return Promise.resolve({ started: true });
  });

  async function runCapture(options) {
    const scrollingElement = document.scrollingElement || document.documentElement;
    const sessionName = makeSessionName();
    const folder = `Scroll Atlas/${sessionName}`;
    const overlay = createOverlay();
    const initialRegions = discoverScrollRegions();
    const originalPositions = new Map();
    let cancelled = false;
    let tileCount = 0;

    rememberPosition(scrollingElement);
    initialRegions.forEach(rememberPosition);

    const manifest = {
      formatVersion: 1,
      sourceUrl: location.href,
      pageTitle: document.title,
      startedAt: new Date().toISOString(),
      finishedAt: null,
      status: "running",
      mode: options.dynamicMode ? "dynamic" : "finite",
      requestedLoadingPasses: options.dynamicMode ? options.maxPasses : 0,
      tileLimitCssPixels: TILE_LIMIT,
      targets: [],
      warnings: [
        "Keep the target tab active while captureVisibleTab is running.",
        "Cross-origin frames, privileged browser pages, closed panels, clicked pagination, and overflow: clip content are not automatically traversed.",
      ],
    };

    overlay.cancelButton.addEventListener("click", () => {
      cancelled = true;
      overlay.setStatus("Cancelling after the current operation...");
    });

    function rememberPosition(element) {
      if (!originalPositions.has(element)) {
        originalPositions.set(element, {
          left: element.scrollLeft,
          top: element.scrollTop,
        });
      }
    }

    function ensureRunning() {
      if (cancelled) {
        throw new Error("Capture cancelled by user.");
      }
    }

    async function captureAndSave(rect, filename) {
      ensureRunning();
      overlay.host.style.display = "none";
      await nextPaint();

      try {
        const dataUrl = await extensionApi.runtime.sendMessage({
          type: "scroll-atlas:capture",
          rect,
        });

        await extensionApi.runtime.sendMessage({
          type: "scroll-atlas:download",
          dataUrl,
          filename,
        });
      } finally {
        overlay.host.style.display = "block";
      }

      tileCount += 1;
      overlay.setStatus(`Saved ${tileCount} tile${tileCount === 1 ? "" : "s"}...`);
      await pause(80);
    }

    try {
      if (options.dynamicMode) {
        overlay.setStatus("Expanding the document for dynamic content...");
        manifest.documentGrowth = await expandSurface(
          scrollingElement,
          options.maxPasses,
          ensureRunning,
        );
      }

      scrollingElement.scrollTo({ left: 0, top: 0, behavior: "instant" });
      await settle();

      const regions = discoverScrollRegions();
      regions.forEach(rememberPosition);

      overlay.setStatus("Capturing the document atlas...");
      manifest.targets.push(
        await captureDocument(scrollingElement, folder, captureAndSave, ensureRunning),
      );

      for (let index = 0; index < regions.length; index += 1) {
        ensureRunning();
        const element = regions[index];
        const label = describeElement(element, index + 1);
        let growth = null;

        element.scrollIntoView({
          behavior: "instant",
          block: "nearest",
          inline: "nearest",
        });
        await settle();

        if (options.dynamicMode) {
          overlay.setStatus(`Expanding ${label} for dynamic content...`);
          growth = await expandSurface(element, options.maxPasses, ensureRunning);
        }

        element.scrollTo({ left: 0, top: 0, behavior: "instant" });
        await settle();
        overlay.setStatus(`Capturing ${label}...`);

        manifest.targets.push(
          await captureRegion(
            element,
            index + 1,
            label,
            growth,
            folder,
            captureAndSave,
            ensureRunning,
          ),
        );
      }

      manifest.status = "complete";
      overlay.setStatus(`Complete. Saved ${tileCount} tiles and a manifest.`);
    } catch (error) {
      if (error.message === "Capture cancelled by user.") {
        manifest.status = "cancelled";
        manifest.warnings.push("The user cancelled this run. The manifest describes only completed tiles.");
        overlay.setStatus(`Cancelled. Saving a partial manifest for ${tileCount} tiles.`);
      } else {
        manifest.status = "failed";
        manifest.error = error.message;
        overlay.setStatus(`Capture failed: ${error.message}`);
      }
    } finally {
      for (const [element, position] of originalPositions) {
        if (element.isConnected || element === scrollingElement) {
          element.scrollTo({
            left: position.left,
            top: position.top,
            behavior: "instant",
          });
        }
      }

      await settle();
      manifest.finishedAt = new Date().toISOString();
      manifest.savedTileCount = tileCount;

      try {
        await saveJson(manifest, `${folder}/manifest.json`);
      } catch (error) {
        overlay.setStatus(`Tiles were saved, but manifest download failed: ${error.message}`);
      }

      overlay.cancelButton.textContent = "Close";
      overlay.cancelButton.addEventListener("click", () => overlay.host.remove(), {
        once: true,
      });
    }
  }

  function discoverScrollRegions() {
    return Array.from(document.querySelectorAll("*")).filter((element) => {
      if (element.closest("[data-scroll-atlas-ui]")) {
        return false;
      }

      const style = getComputedStyle(element);
      const horizontal =
        SCROLLABLE_OVERFLOW.has(style.overflowX) &&
        element.scrollWidth > element.clientWidth + 1;
      const vertical =
        SCROLLABLE_OVERFLOW.has(style.overflowY) &&
        element.scrollHeight > element.clientHeight + 1;

      return (
        (horizontal || vertical) &&
        element.clientWidth > 0 &&
        element.clientHeight > 0
      );
    });
  }

  async function captureDocument(
    scrollingElement,
    folder,
    captureAndSave,
    ensureRunning,
  ) {
    const width = scrollingElement.scrollWidth;
    const height = scrollingElement.scrollHeight;
    const tiles = [];

    for (let y = 0; y < height; y += TILE_LIMIT) {
      for (let x = 0; x < width; x += TILE_LIMIT) {
        ensureRunning();
        const rect = {
          x,
          y,
          width: Math.min(TILE_LIMIT, width - x),
          height: Math.min(TILE_LIMIT, height - y),
        };
        const filename = `${folder}/document/tile-x${pad(x)}-y${pad(y)}.png`;

        await captureAndSave(rect, filename);
        tiles.push({ filename, contentX: x, contentY: y, rect });
      }
    }

    return {
      id: "document",
      kind: "document",
      label: "document.scrollingElement",
      scrollWidth: width,
      scrollHeight: height,
      clientWidth: scrollingElement.clientWidth,
      clientHeight: scrollingElement.clientHeight,
      tiles,
    };
  }

  async function captureRegion(
    element,
    regionNumber,
    label,
    growth,
    folder,
    captureAndSave,
    ensureRunning,
  ) {
    const width = element.scrollWidth;
    const height = element.scrollHeight;
    const clientWidth = element.clientWidth;
    const clientHeight = element.clientHeight;
    const stepX = Math.max(1, Math.min(TILE_LIMIT, clientWidth));
    const stepY = Math.max(1, Math.min(TILE_LIMIT, clientHeight));
    const maxLeft = Math.max(0, width - clientWidth);
    const maxTop = Math.max(0, height - clientHeight);
    const regionId = `region-${String(regionNumber).padStart(2, "0")}`;
    const tiles = [];

    for (let y = 0; y < height; y += stepY) {
      for (let x = 0; x < width; x += stepX) {
        ensureRunning();
        const scrollLeft = Math.min(x, maxLeft);
        const scrollTop = Math.min(y, maxTop);

        element.scrollTo({
          left: scrollLeft,
          top: scrollTop,
          behavior: "instant",
        });
        await settle();

        const physicalX = x - scrollLeft;
        const physicalY = y - scrollTop;
        const tileWidth = Math.min(stepX, width - x, clientWidth - physicalX);
        const tileHeight = Math.min(stepY, height - y, clientHeight - physicalY);
        const bounds = element.getBoundingClientRect();
        const pageX = bounds.left + window.scrollX + element.clientLeft + physicalX;
        const pageY = bounds.top + window.scrollY + element.clientTop + physicalY;
        const rect = {
          x: Math.max(0, Math.floor(pageX)),
          y: Math.max(0, Math.floor(pageY)),
          width: Math.max(1, Math.floor(tileWidth)),
          height: Math.max(1, Math.floor(tileHeight)),
        };
        const filename = `${folder}/${regionId}/tile-x${pad(x)}-y${pad(y)}.png`;

        await captureAndSave(rect, filename);
        tiles.push({
          filename,
          contentX: x,
          contentY: y,
          scrollLeft,
          scrollTop,
          rect,
        });
      }
    }

    return {
      id: regionId,
      kind: "scroll-region",
      label,
      overflowX: getComputedStyle(element).overflowX,
      overflowY: getComputedStyle(element).overflowY,
      scrollWidth: width,
      scrollHeight: height,
      clientWidth,
      clientHeight,
      growth,
      tiles,
    };
  }

  async function expandSurface(element, maxPasses, ensureRunning) {
    let previousWidth = element.scrollWidth;
    let previousHeight = element.scrollHeight;
    let stableMeasurements = 0;
    let performedPasses = 0;

    for (let pass = 1; pass <= maxPasses; pass += 1) {
      ensureRunning();
      performedPasses = pass;
      element.scrollTo({
        left: Math.max(0, element.scrollWidth - element.clientWidth),
        top: Math.max(0, element.scrollHeight - element.clientHeight),
        behavior: "instant",
      });
      await pause(DYNAMIC_WAIT_MS);
      await nextPaint();

      const currentWidth = element.scrollWidth;
      const currentHeight = element.scrollHeight;
      const unchanged =
        currentWidth <= previousWidth + 1 && currentHeight <= previousHeight + 1;

      stableMeasurements = unchanged ? stableMeasurements + 1 : 0;
      previousWidth = currentWidth;
      previousHeight = currentHeight;

      if (stableMeasurements >= 2) {
        return {
          result: "stable",
          performedPasses,
          finalScrollWidth: currentWidth,
          finalScrollHeight: currentHeight,
        };
      }
    }

    return {
      result: "safety-limit",
      performedPasses,
      finalScrollWidth: element.scrollWidth,
      finalScrollHeight: element.scrollHeight,
    };
  }

  function createOverlay() {
    const host = document.createElement("div");
    host.dataset.scrollAtlasUi = "true";
    host.style.cssText = [
      "position:fixed",
      "right:16px",
      "bottom:16px",
      "z-index:2147483647",
      "display:block",
    ].join(";");

    const shadow = host.attachShadow({ mode: "open" });
    const panel = document.createElement("section");
    panel.style.cssText = [
      "width:290px",
      "padding:14px",
      "border-radius:12px",
      "background:#111827",
      "color:#ffffff",
      "font:14px/1.4 system-ui,sans-serif",
      "box-shadow:0 10px 30px rgba(0,0,0,.35)",
    ].join(";");

    const title = document.createElement("strong");
    title.textContent = "Scroll Atlas";
    const status = document.createElement("p");
    status.textContent = "Preparing capture...";
    status.style.margin = "8px 0 12px";
    const cancelButton = document.createElement("button");
    cancelButton.type = "button";
    cancelButton.textContent = "Cancel";
    cancelButton.style.cssText = [
      "width:100%",
      "padding:8px",
      "border:0",
      "border-radius:8px",
      "background:#ef4444",
      "color:white",
      "font:inherit",
      "font-weight:700",
      "cursor:pointer",
    ].join(";");

    panel.append(title, status, cancelButton);
    shadow.append(panel);
    document.documentElement.append(host);

    return {
      host,
      cancelButton,
      setStatus(message) {
        status.textContent = message;
      },
    };
  }

  function describeElement(element, index) {
    if (element.id) {
      return `${element.tagName.toLowerCase()}#${element.id}`;
    }

    const name = element.getAttribute("aria-label") || element.getAttribute("role");
    return name
      ? `${element.tagName.toLowerCase()}[${name}]`
      : `${element.tagName.toLowerCase()} region ${index}`;
  }

  function makeSessionName() {
    const page = (document.title || location.hostname || "page")
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-|-$/g, "")
      .slice(0, 48) || "page";
    const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
    return `${page}-${timestamp}`;
  }

  function pad(value) {
    return String(Math.round(value)).padStart(6, "0");
  }

  async function saveJson(value, filename) {
    const dataUrl = `data:application/json;charset=utf-8,${encodeURIComponent(
      JSON.stringify(value, null, 2),
    )}`;

    await extensionApi.runtime.sendMessage({
      type: "scroll-atlas:download",
      dataUrl,
      filename,
    });
  }

  function pause(milliseconds) {
    return new Promise((resolve) => setTimeout(resolve, milliseconds));
  }

  function nextPaint() {
    return new Promise((resolve) => {
      requestAnimationFrame(() => requestAnimationFrame(resolve));
    });
  }

  async function settle() {
    await nextPaint();
    await pause(SETTLE_DELAY_MS);
  }
})();

What does the completed recorder do?

  • The expandSurface() function applies a finite pass limit to every surface.
  • The createOverlay() function creates isolated progress and cancellation controls.
  • The originalPositions map stores each surface's starting coordinates for restoration.
  • The finally block restores positions and saves the final manifest after every outcome.
  • Save recorder.js.
  • Return to the existing about:debugging tab in Firefox.
  • Click Reload on the Scroll Atlas temporary extension card.

That is the main coordination complete. Scroll Atlas can now bound page growth while protecting work that has already finished.

Does the extension fail to reload?

Check that recorder.js begins with the wrapper function and ends with its matching closing call. A missing brace or parenthesis prevents the script from loading.

Still stuck? Help me compare my recorder with the final Scroll Atlas code.

Verify bounded dynamic growth

The fixture adds a finite batch whenever document scrolling reaches the bottom. A three-pass run should report exactly how far the recorder got without claiming more than it measured.

  • Return to the active test-page.html tab.
  • Click Enable finite growing feed.
  • Click the Scroll Atlas toolbar action.
  • Select This page loads more while scrolling.
  • Select 3 passes under Maximum loading passes.

Before you start, do you expect three passes to produce stable or safety-limit for the growing document?

The recorder pauses for 1100 milliseconds during each loading pass. Expect at least several seconds of page movement before tile downloads finish.

  • Click Start capture.
  • Keep the target tab active until the overlay reports completion.

You will see the overlay report expansion and tile progress. The button changes to Close after the manifest has been saved.

  • Open the Downloads folder in Finder.
  • Open the newest session folder inside Scroll Atlas.
  • Open manifest.json.
  • Find the mode field.
  • Find the requestedLoadingPasses field.
  • Inspect documentGrowth for its result and final measured dimensions.
  • Inspect each region target's growth value.

The JSON manifest shows dynamic mode with three requested passes. Its growth entries report performed passes plus a stable or safety-limit result.

Is dynamic growth missing from the manifest?

Confirm that the growing-feed button was enabled before capture. Confirm that the popup checkbox was selected before you clicked Start capture.

Still stuck? Help me trace why my Scroll Atlas manifest is missing dynamic growth data.

Cancel a run and confirm restoration

Cancellation must leave the page usable. The recorder keeps completed tiles, writes a partial manifest, and restores every remembered document or region position from the finally block.

  • Return to the active test-page.html tab.
  • Scroll the document to a recognizable middle position.
  • Move the wide board pane close to its far-right edge.
  • Move the message-history pane close to its middle rows.
  • Click the Scroll Atlas toolbar action.
  • Select This page loads more while scrolling.
  • Select 10 passes under Maximum loading passes.

Before you start, where should the document and both panes return after you cancel?

  • Click Start capture.
  • Click Cancel in the page overlay while capture is running.
  • Wait until the overlay button changes to Close.
  • Open the newest manifest.json file in your Scroll Atlas downloads.

You will see cancelled in the manifest's status field. Its saved tile count covers only completed downloads.

The document and both panes return to their starting positions. That restoration confirms the page remains usable after an interrupted run.

Did a surface stay at the capture edge?

Confirm that the final finally block loops through originalPositions. Confirm that each connected element receives its stored left and top values.

Still stuck? Help me debug Scroll Atlas position restoration after cancellation.

You have completed the recorder's hardest control loop. Scroll Atlas can now bound declared page growth, preserve partial work, and return every captured surface to its original position.

Secret mission

Audit a Hostile Split-View Page

Take Scroll Atlas beyond its controlled fixture on an unfamiliar finite split-view page. Audit every captured surface. Cancel a second run to preserve partial evidence. Confirm the page returns to its original scroll positions.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your local files, pause your work, or remove everything. This project has no ongoing costs.

Resources you used:

  • A temporary Scroll Atlas extension loaded in Firefox through about:debugging.
  • The local scroll-atlas source folder edited in Visual Studio Code.
  • The downloaded Scroll Atlas folder containing every capture session.

Keep everything running

No action is needed. Choose this option if you plan to keep testing Scroll Atlas.

  • Keep the scroll-atlas source folder on your Mac.
  • Keep the downloaded Scroll Atlas folder as evidence from your capture sessions.
  • Use Reload in about:debugging after editing the extension files.

Your extension remains ready for more local testing during the current Firefox session. No resource creates an ongoing charge.

Pause - I'll come back to this later

End the temporary browser session while preserving your project. You can load the extension again when you return.

  • Close Firefox to remove the temporary extension from the browser.
  • Leave the scroll-atlas source folder in place.
  • Leave the downloaded Scroll Atlas folder in place.

Your code remains available. Your capture evidence also remains available.

Delete - I don't want to use this again

Deleting these folders becomes permanent once the Trash is emptied. Your saved manifests disappear with the folders.

Remove the temporary extension:

  • Enter about:debugging in the Firefox address bar.
  • Select This Firefox in the sidebar.
  • Find Scroll Atlas in the temporary extensions list.
  • Click Remove on the Scroll Atlas entry.

The Scroll Atlas toolbar action disappears from Firefox.

Remove the local source files:

  • Locate the scroll-atlas source folder in Finder.
  • Move the scroll-atlas folder to the Trash.

Remove the downloaded capture sessions:

  • Open your Downloads folder in Finder.
  • Locate the Scroll Atlas folder.
  • Move the Scroll Atlas folder to the Trash.
  • Permanently delete the folders from the Trash.

Finder no longer lists the Scroll Atlas source or capture folders. Your Mac now has no remaining project resources.

Nice Work!

Nice Work!

You did it! Scroll Atlas now records finite pages in Firefox as local PNG tile atlases.

Its bounded dynamic mode identifies stable surfaces or records that the safety limit was reached. A cancelled run preserves completed tiles before restoring the page's original scroll positions.

You've learned how to:

  • Built a Manifest V3 extension with a toolbar popup for user-triggered capture. Connected the popup to the background capture code through extension messaging.
  • Turned finite documents into bounded two-axis PNG atlases. Each detected independent scroll region receives its own tile folder.
  • Added bounded dynamic capture for user-declared growing pages. The page overlay reports progress during long runs. Cancellation saves a partial manifest before scroll restoration returns the page to its starting state.
  • Completed the Secret Mission by auditing a second hostile split-view page. The cancelled manifest.json records only completed tiles. Every captured or intentionally excluded surface has a written justification.

Ready to quiz yourself?