C.W.K.
Stream
Lesson 04 of 08 · published

Anchor 4 — Popup as a Doorway

~8 min · popup, side-panel, case-study, single-responsibility

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"ChromeEmbed's popup.js is six lines. Lesson 4 is why that's the right number — and what happens when you reach for 'just one more popup feature.'"
Placeholder mockup of the ChromeEmbed popup showing a single 'Open Panel' button
Placeholder for the v0.1 popup — one button, one job. Real screenshot pending.

The Six Lines

document.getElementById('open-panel')?.addEventListener('click', async () => {
  const windowInfo = await chrome.windows.getCurrent().catch(() => null);
  if (windowInfo?.id !== undefined) {
    await chrome.sidePanel.open({ windowId: windowInfo.id }).catch(() => {});
  }
  window.close();
});

One handler. Click 'Open Panel' button → fetch current window → open side panel in that window → close the popup. The popup HTML is comparably tiny — a styled button labeled 'Open Panel.'

Why This Is Right

  • Single responsibility — the popup is a launcher. Anything more would compete with the panel as the actual experience surface.
  • Predictable lifecycle — popup opens, popup closes. No state to manage, no rerender to handle, no race conditions to debug.
  • Fast paint — the popup HTML loads, JS runs, click handler attaches. Probably under 50 ms from open to ready.
  • No competing UI — Pippa lives in the panel iframe; the popup pointing the user toward it (rather than hosting parallel chat UI) keeps the mental model singular.

The Alternative That Doesn't Work

What if you wanted the popup to be useful? You'd add quick-chat input, a most-recent-message preview, a brain selector. Each of those:

  • Duplicates UI that already exists in the panel.
  • Needs to message the cwkPippa backend, which the panel iframe already does — so you'd either re-implement that messaging in the popup or route through the SW → iframe (which doesn't have a clean API today).
  • Has lifecycle problems: the popup dies the moment the user does anything outside it, so any operation in flight when they click elsewhere is lost.

The 'useful popup' design always ends up paying duplication costs that the doorway-popup avoids entirely. ChromeEmbed's choice: don't pay the cost.

The Window vs Tab Choice

The popup passes { windowId } to chrome.sidePanel.open. Two consequences:

  • The panel opens for the entire window. Tab switching inside the window keeps the panel open with content updating per the active tab (via the background bus + Lesson 5's bridge).
  • The user can still close the panel via Chrome's standard side-panel chrome.

Passing { tabId } instead would scope the panel to that one tab; the moment the user switches tabs, the panel closes. That's the wrong UX for a household extension that wants to be ambient across the entire window's browsing session.

The Optional Chaining

document.getElementById('open-panel')?.addEventListener(...) — the optional chaining is paranoia. If the popup HTML is changed and the button id mis-spelled, the popup degrades gracefully (no JS error, just a non-functional button). For a 6-line script that's overkill; for an extension you might update from a hundred git revisions later, it's a courtesy. Keep it.

Popup as doorway, panel as destination. Six lines is enough; more would compete with the panel for the same workflow space. The cost of NOT adding popup features is exactly zero; the cost of adding them is duplication forever.
If you ever needed popup status info... The right move is to render a tiny preview in the popup (one line: 'Currently capturing context from this tab') without inviting interaction. Read from chrome.storage or message the SW; render once on open; let the user click 'Open Panel' to actually do anything. Don't add input fields, don't add chat — keep the popup as a status-and-launcher only.

Code

popup.html — entire file: one button, scoped styles·html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Pippa</title>
    <style>
      body { width: 240px; padding: 12px; margin: 0; font-family: system-ui, sans-serif; }
      button { width: 100%; padding: 10px 14px; font: inherit; cursor: pointer; }
    </style>
  </head>
  <body>
    <button id="open-panel">Open Panel</button>
    <script src="popup.js"></script>
  </body>
</html>
popup.js — entire file: six lines, one job·javascript
document.getElementById('open-panel')?.addEventListener('click', async () => {
  const windowInfo = await chrome.windows.getCurrent().catch(() => null);
  if (windowInfo?.id !== undefined) {
    await chrome.sidePanel.open({ windowId: windowInfo.id }).catch(() => {});
  }
  window.close();
});

External links

Exercise

Read the actual embeds/chrome/popup.html and popup.js. Notice every line is doing necessary work — there's nothing to remove. Now sketch a 'useful popup' variant: a 320px popup with a text input, a Send button, last 3 messages displayed. Estimate how much code that adds (~100 lines minimum) and how many new failure modes you'd be on the hook for (popup-close races during send, message-state drift between popup and panel, dual chat UIs, etc). Decide whether that complexity earns its keep. For ChromeEmbed v0.1, the answer was firmly 'no' — but the muscle of asking the question is what produces a doorway popup instead of a parallel-app popup by accident.
Hint
If you find yourself wanting to add a quick-chat to the popup, ask whether the user actually loses anything by opening the panel for that chat. If the panel takes 200ms to open and the chat takes 5 seconds anyway, the popup-bypass saves nothing. The exceptions are extensions where the entire workflow fits in 3 seconds — those genuinely benefit from popup-as-app. Pippa isn't that; Pippa is conversation, conversation is persistent, persistent surfaces are panels.

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.