Skip to content
C.W.K.
Stream
Lesson 05 of 05 · published

ClipDeck Mode Toggle — Per-Tab Pause and Resume

~14 min · clipdeck, mode-toggle, badge, per-tab, storage

Level 0Extension Curious
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Track 5 ends with a small, honest power: the user decides when ClipDeck is paying attention. Pause it on the page where they're typing their tax return; un-pause it the moment they're back on the article. State lives in storage so every surface stays in sync."

The Why

Users do not always want ClipDeck active. Privacy reasons, surprise-clip avoidance, or just the comfort of knowing the extension is off while they fill out a sensitive form. The pause toggle is the bar of trust:

  • Per-tab scope so they don't have to remember to re-enable.
  • Persistent across SW evictions so the pause does not silently reset.
  • Visible in the badge so they can tell at a glance.
  • Reachable from popup, keyboard shortcut, and (optionally) the side panel.

The State Shape

A simple array in chrome.storage.session:

{ pausedTabs: number[] }

Tab IDs aren't durable across browser restarts (Chrome assigns new ones), but the pause is meaningful only while the tab still exists, so this is fine — when the tab closes, the id is gone and a fresh tab with the same id starts un-paused.

storage.session clears at browser restart. During the session, remove a tab ID from the set in chrome.tabs.onRemoved; no daily alarm or startup sweep is needed.

The Three Touch Points

Wherever the pause state matters, read the same storage value:

  • Popup button. Reads current tab's pause state, toggles, writes back, lets storage.onChanged update the badge and content script.
  • Keyboard shortcut. Same logic, triggered from chrome.commands.onCommand with command toggle-pause (we wired this in Lesson 4).
  • Service-worker gate. Before it requests or saves capture, the worker checks session pause state. Content scripts do not get storage.session access by default, and they should not become competing state writers.

The SW also writes to the badge each time pause state changes for the active tab — setBadgeText({ tabId, text: 'II' }) when paused, empty when un-paused.

The Race-Free Update

Reading then writing the array invites a race when two events fire close together (user hits hotkey while clicking the popup button). The defensive pattern is a tiny mutation helper that always re-reads inside the same transaction:

async function togglePauseForTab(tabId) {
  const { pausedTabs = [] } = await chrome.storage.session.get('pausedTabs');
  const next = pausedTabs.includes(tabId)
    ? pausedTabs.filter((t) => t !== tabId)
    : [...pausedTabs, tabId];
  await chrome.storage.session.set({ pausedTabs: next });
  return next.includes(tabId);
}

The helper below runs behind one worker-local Promise queue, so two popup/shortcut mutations cannot overlap inside one worker lifetime. Persisted state still belongs to the worker; every entry point calls the same queued helper.

Visual Feedback

The badge is your honest signal. Three states:

  • Active + clips today → number ("3") on the brand color.
  • Active + no clips today → empty badge.
  • Paused → "II" or "PAUSE" on a grey background.

Switch between count-mode and pause-mode whenever the storage changes. The popup also flips its button label: "Pause on this site" ↔ "Resume on this site," and the side panel can show a banner if the user is browsing a paused tab ("Capture paused. Resume?").

Wrapping Track 5

With this lesson ClipDeck has the full action surface:

  • Icon with badge that doubles as today's-count + pause indicator.
  • Popup with stats, three primary actions, and a discoverable settings link.
  • Right-click context menu with the actual selected text in the label.
  • Four user-bindable keyboard shortcuts.
  • Omnibox search keyword clip.
  • Per-tab pause/resume reflected everywhere.

Track 6 takes the next sensible step — the permission model. Why ClipDeck asks for what it asks for, how to ask only at the moment you need it, and how to design for the user who says no.

Pause is the user's trust dial. Per-tab scope, persisted in storage, mirrored to the badge and popup. Three surfaces, one state, no surprises.
Why session is the contract here. A tab ID can be reused after restart. Persisting it in local storage can accidentally pause an unrelated future tab. Session storage matches the identity lifetime.

Tab IDs are runtime identifiers and can be reused after a browser restart. Keep pausedTabs in storage.session, clean it on tabs.onRemoved, and let the service worker remain the gate. Persist clips and display preferences in storage.local; persist tab-scoped pause state only for the browser session.

Code

background.js — toggle + badge refresh + tab close cleanup·javascript
// background.js — one queued writer for browser-session pause state
let pauseMutation = Promise.resolve();

function queuePauseMutation(task) {
  const result = pauseMutation.then(task, task);
  pauseMutation = result.then(() => undefined, () => undefined);
  return result;
}

function togglePauseForTab(tabId) {
  return queuePauseMutation(async () => {
    const { pausedTabs = [] } =
      await chrome.storage.session.get("pausedTabs");
    const next = pausedTabs.includes(tabId)
      ? pausedTabs.filter((id) => id !== tabId)
      : [...pausedTabs, tabId];
    await chrome.storage.session.set({ pausedTabs: next });
    await refreshBadgeForTab(tabId);
    return next.includes(tabId);
  });
}

async function refreshBadgeForTab(tabId) {
  const [{ pausedTabs = [] }, { clips = [] }] = await Promise.all([
    chrome.storage.session.get("pausedTabs"),
    chrome.storage.local.get("clips"),
  ]);
  if (pausedTabs.includes(tabId)) {
    await chrome.action.setBadgeText({ tabId, text: "II" });
    await chrome.action.setBadgeBackgroundColor({ tabId, color: "#666" });
    await chrome.action.setTitle({ tabId, title: "ClipDeck — paused on this tab" });
    return;
  }
  const todayStart = new Date();
  todayStart.setHours(0, 0, 0, 0);
  const count = clips.filter((clip) => clip.savedAt >= todayStart.getTime()).length;
  await chrome.action.setBadgeText({
    tabId,
    text: count === 0 ? "" : String(count > 999 ? "999+" : count),
  });
  await chrome.action.setBadgeBackgroundColor({ tabId, color: "#1a6bd6" });
}

chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message?.type !== "togglePause") return;
  togglePauseForTab(message.tabId)
    .then((paused) => sendResponse({ ok: true, paused }))
    .catch((error) => sendResponse({ ok: false, error: error.message }));
  return true;
});

chrome.tabs.onRemoved.addListener((tabId) => {
  void queuePauseMutation(async () => {
    const { pausedTabs = [] } =
      await chrome.storage.session.get("pausedTabs");
    await chrome.storage.session.set({
      pausedTabs: pausedTabs.filter((id) => id !== tabId),
    });
  });
});
popup.js — Pause button + live label update·javascript
// popup.js — wire the Pause button to the SW-side helper
document.getElementById("pauseBtn").addEventListener("click", async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id) return;
  await chrome.runtime.sendMessage({ type: "togglePause", tabId: tab.id });
});

async function refreshPauseLabel() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  const { pausedTabs = [] } = await chrome.storage.session.get("pausedTabs");
  const paused = tab?.id ? pausedTabs.includes(tab.id) : false;
  document.getElementById("pauseBtn").textContent = paused
    ? "Resume on this tab"
    : "Pause on this tab";
}

chrome.storage.onChanged.addListener((c, a) => {
  if (a === "session" && "pausedTabs" in c) refreshPauseLabel();
});

refreshPauseLabel();

// In background.js, route the message:
// chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
//   if (message?.type !== 'togglePause') return;
//   (async () => {
//     await togglePauseForTab(message.tabId);
//     sendResponse({ ok: true });
//   })();
//   return true;
// });
content.js stays stateless; background.js gates capture by tab·javascript
// content.js — capture page facts only; no storage.session access
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message?.type !== "captureSelection") return;
  const text = window.getSelection()?.toString() ?? "";
  sendResponse(text.trim()
    ? { ok: true, payload: { text, url: location.href, title: document.title } }
    : { ok: false, reason: "no-selection" });
});

// background.js — the trusted context knows tabId and owns the pause gate
chrome.commands.onCommand.addListener(async (command) => {
  if (command !== "save-clip") return;
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id) return;
  const { pausedTabs = [] } =
    await chrome.storage.session.get("pausedTabs");
  if (pausedTabs.includes(tab.id)) return;
  // Continue with the serialized save flow from Track 3.
  const captured =
    await chrome.tabs.sendMessage(tab.id, { type: "captureSelection" });
  if (captured?.ok) {
    await saveCapturedClip(captured.payload);
  }
});

External links

Exercise

Add the first code block to clipdeck/background.js — the togglePauseForTab helper, per-tab badge refresh, command wiring, and tab-close cleanup. Add the second code block to clipdeck/popup.js — the Pause button handler and live label. Add the togglePause message router (the comment at the bottom of the second code block) to background.js. Replace your existing save-clip handler with the gated version from the third code block. Reload. Open the popup on any page — Pause on this tab. Click it — badge changes to II on grey, label flips to Resume on this tab. Press Ctrl+Shift+K — nothing saves. Click Resume on this tab — badge restores, save-clip works again. Switch tabs — the badge reflects the per-tab state of each one independently. Close the paused tab and reopen the same URL — state is fresh (un-paused), as designed.
Hint
If the badge does not change to II when you pause, you forgot to await chrome.action.setBadgeText — it's a Promise. If togglePause from the popup throws Receiving end does not exist, your SW listener for togglePause messages isn't registered (the commented router at the bottom of the second code block — add it). If save-clip still saves while paused, the SW-side gate is not in your handler — find the existing chrome.commands.onCommand listener for save-clip and add the pause check at the top of it.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.