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/54 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.local:

{ 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.

A daily-ish cleanup pass (or one on chrome.runtime.onStartup) removes stale ids by intersecting against chrome.tabs.query({}). Keeps the array from growing forever.

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).
  • Content script gate. Before the capture flow, content script checks storage; if paused, it responds with a no-op and the SW records nothing.

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.local.get('pausedTabs');
  const next = pausedTabs.includes(tabId)
    ? pausedTabs.filter((t) => t !== tabId)
    : [...pausedTabs, tabId];
  await chrome.storage.local.set({ pausedTabs: next });
  return next.includes(tabId);
}

This is not strictly atomic — between the get and the set, another event could insert — but for human-rate toggles it's effectively fine. If ClipDeck ever grows multi-window sync, swap to a proper locking pattern with a sequence id.

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.
chrome.storage.session as an alternative. Chrome 102+ ships chrome.storage.session, an in-memory storage area that dies when the browser closes. For pause state that should reset across browser restarts (a user expectation in some cases), session is a clean fit. ClipDeck v1 uses local so pause survives restarts, but the choice is yours — neither is wrong, they encode different defaults.

Code

background.js — toggle + badge refresh + tab close cleanup·javascript
// background.js — toggle helper + badge refresh + content-script gate response
async function togglePauseForTab(tabId) {
  const { pausedTabs = [] } = await chrome.storage.local.get("pausedTabs");
  const isPaused = pausedTabs.includes(tabId);
  const next = isPaused ? pausedTabs.filter((t) => t !== tabId) : [...pausedTabs, tabId];
  await chrome.storage.local.set({ pausedTabs: next });
  await refreshBadgeForTab(tabId);
  return !isPaused;
}

async function refreshBadgeForTab(tabId) {
  const { pausedTabs = [], clips = [] } = await chrome.storage.local.get([
    "pausedTabs",
    "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;
  }
  // Active path — show today's clip count (global, not per-tab).
  const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
  const todayCount = clips.filter((c) => c.savedAt >= todayStart.getTime()).length;
  await chrome.action.setBadgeText({
    tabId,
    text: todayCount === 0 ? "" : String(todayCount > 999 ? "999+" : todayCount),
  });
  await chrome.action.setBadgeBackgroundColor({ tabId, color: "#1a6bd6" });
  await chrome.action.setTitle({
    tabId,
    title: todayCount === 0 ? "ClipDeck" : `ClipDeck — ${todayCount} clips today`,
  });
}

chrome.tabs.onActivated.addListener(({ tabId }) => refreshBadgeForTab(tabId));
chrome.tabs.onUpdated.addListener((tabId, info) => {
  if (info.status === "complete") refreshBadgeForTab(tabId);
});
chrome.tabs.onRemoved.addListener(async (tabId) => {
  const { pausedTabs = [] } = await chrome.storage.local.get("pausedTabs");
  if (pausedTabs.includes(tabId)) {
    await chrome.storage.local.set({ pausedTabs: pausedTabs.filter((t) => t !== tabId) });
  }
});

// Wire up the toggle-pause keyboard command from Lesson 4
chrome.commands.onCommand.addListener(async (command) => {
  if (command !== "toggle-pause") return;
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (tab?.id) await togglePauseForTab(tab.id);
});
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.local.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 === "local" && "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 + SW gate — pause respected before any save attempt·javascript
// content.js — respect the pause state before responding to captureSelection
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type !== "captureSelection") return;
  (async () => {
    // Ask the SW which tab we are; content script doesn't know its own tabId directly.
    // The SW knows from chrome.tabs.query({active:true,currentWindow:true}).
    const { pausedTabs = [] } = await chrome.storage.local.get("pausedTabs");
    // Without tabId, the simplest gate: SW already filters paused tabs before
    // calling chrome.tabs.sendMessage. But if a third trigger (the floating
    // button) bypasses the SW, the content script can still check by querying
    // its own tab id through a round-trip — for v1, the SW-side gate is enough.
    const selection = window.getSelection();
    const text = selection?.toString() ?? "";
    if (!text.trim()) {
      sendResponse({ ok: false, reason: "no-selection" });
      return;
    }
    sendResponse({
      ok: true,
      payload: { text, url: location.href, title: document.title },
    });
  })();
  return true;
});

// SW-side gate (background.js — replace the existing save-clip handler):
// 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.local.get('pausedTabs');
//   if (pausedTabs.includes(tab.id)) {
//     console.log('[ClipDeck SW] paused on tab', tab.id, 'ignoring save-clip');
//     return;
//   }
//   // ...existing save flow...
// });

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.