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

Anchor 3 — Content Script as the Page Sensor

~13 min · content-script, dom, viewport, case-study, v0.2.1

Level 0Extension Curious
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"ChromeEmbed's content-script.js walks the DOM, picks the text inside the viewport, layers in the form controls and ARIA labels, and ships it to the SW on every meaningful event. Lesson 3 is reading that 220-line file with fresh eyes after seven tracks of foundation."

The Walker Pattern

The core extraction loop:

const walker = document.createTreeWalker(
  document.body,
  NodeFilter.SHOW_TEXT,
  {
    acceptNode(node) {
      if (!inlineText(node.textContent)) return NodeFilter.FILTER_REJECT;
      if (!isVisibleElement(node.parentElement)) return NodeFilter.FILTER_REJECT;
      return NodeFilter.FILTER_ACCEPT;
    }
  }
);

TreeWalker visits text nodes only. For each, the acceptNode filter drops nodes whose parent is hidden (display:none, visibility:hidden, etc.) and nodes that are blank. The walker is wrapped in a budget: MAX_VISIBLE_TEXT_NODES = 900, MAX_TEXT_NODE_VISITS = 20000. The cap prevents pathological pages (10MB of text) from freezing the content script.

The Visibility-Rect Check

After the walker accepts a node, the script checks whether its bounding rect actually intersects the viewport:

function visibleTextRect(textNode) {
  const range = document.createRange();
  try {
    range.selectNodeContents(textNode);
    for (const rect of range.getClientRects()) {
      if (rectIntersectsViewport(rect)) return rect;
    }
  } finally {
    range.detach?.();
  }
  return null;
}

This is Track 7 Lesson 3's getClientRects() applied as a filter. A text node with a parent that's display:block but scrolled off-screen has no intersecting rect; it gets skipped. The result: only text the user can actually see right now.

The Control Layer

Beyond text content, the script captures form controls and ARIA-labeled elements:

const selector = 'input, textarea, select, img[alt], [role="button"], [aria-label]';
for (const element of document.querySelectorAll(selector)) {
  if (!isVisibleElement(element)) continue;
  const rect = element.getBoundingClientRect();
  if (!rectIntersectsViewport(rect)) continue;
  const text = controlLabel(element);
  if (!text) continue;
  entries.push({ top: rect.top, left: rect.left, text });
}

This is what makes Pippa aware of what the user can do on the page, not just what they can read. An image's alt text, a button's aria-label, an input's placeholder — all become part of the context. The label-extraction logic per element type is in controlLabel().

The Spatial Sort

Once entries are collected, they're sorted top-to-bottom, left-to-right:

entries.sort((a, b) => (a.top - b.top) || (a.left - b.left));

This recovers reading order from a flat collection of text fragments. Modern pages have wildly out-of-source-order layouts (CSS Grid, Flexbox with reverse), but the visual top-to-bottom order is what the user perceives. Sorting by rect coordinates respects the visual order, not the DOM order.

The Throttle

Three event listeners trigger context updates:

  • scroll — throttled via scheduleContext with a 350 ms quiet period. Heavy event; batching matters.
  • selectionchange, mouseup, pointerup, keyup, touchend — selection-related; scheduleSelectionContext fires immediately on a 0-ms setTimeout (microtask) so the selection arrives at the panel as close to real-time as possible.
  • focus — window regained focus; re-snapshot.
  • Initial call — sendContext() at the end of the file. Captures the moment-of-injection state.

The throttle on scroll vs immediate on selection is the right trade. Scroll fires hundreds of times during a single drag; selection fires only when the user actually highlights. Different cadences, different handlers.

The Selection Cache

A subtle UX detail: selections often disappear as the user clicks toward the side panel to open it. The cache:

function currentSelectionText() {
  const current = truncate(window.getSelection?.().toString() || '', MAX_SELECTION_CHARS);
  if (current) {
    lastSelection = current;
    lastSelectionAt = Date.now();
    return current;
  }
  if (lastSelection && Date.now() - lastSelectionAt <= SELECTION_CACHE_MS) {
    return lastSelection;
  }
  return '';
}

If the live selection is empty but one was captured within the last 5 minutes, return the cached version. The user thinks 'I highlighted that and asked Pippa about it' — they don't think 'oh, the click-to-open-panel cleared the selection.' The cache makes the experience match the intent.

What the SW Receives

One payload per sendContext() call, of shape detailed in Lesson 6. The content script is the sensor; the SW is the bus; the panel is the consumer. Each layer does one job; together they keep cwkPippa fed with what the user is reading right now.

Walker + visibility filter + control labels + spatial sort + throttle + selection cache. Six ideas, one 220-line file. The result is 'what the user is actually looking at' as a JSON payload Pippa can read.
Why all_frames in the manifest matters here. The content script runs in every frame; sub-frame instances send their own context with isTopFrame: false. The SW's merge logic (Lesson 2) preserves the top-frame's bulk text while merging sub-frame selections. This way a user highlighting text inside a YouTube embed still reaches Pippa correctly.

Code

content-script.js — event wiring with two throttle strategies·javascript
// embeds/chrome/content-script.js — the throttled context push loop
const QUIET_MS = 350;
let timer = null;

function scheduleContext() {
  if (timer) window.clearTimeout(timer);
  timer = window.setTimeout(() => {
    timer = null;
    sendContext();
  }, QUIET_MS);
}

function scheduleSelectionContext() {
  window.setTimeout(sendContext, 0);  // immediate for selection events
}

window.addEventListener('scroll', scheduleContext, { passive: true });
document.addEventListener('selectionchange', scheduleSelectionContext, true);
document.addEventListener('mouseup', scheduleSelectionContext, true);
document.addEventListener('pointerup', scheduleSelectionContext, true);
document.addEventListener('keyup', scheduleSelectionContext, true);
document.addEventListener('touchend', scheduleSelectionContext, true);
window.addEventListener('focus', scheduleContext);
sendContext();  // initial snapshot
v0.2.1 checkpoint — content-script.js — top-frame viewport plus subframe selection contract·javascript
function extractContext({ freshSelection = false } = {}) {
  if (isPippaEmbedPanelUrl()) return null;
  const topFrame = isTopFrame();
  const selection = currentSelectionText({ allowCache: !freshSelection });
  if (!topFrame) {
    if (!freshSelection || !selection) return null;
    return { type: "pippa:host-context", payload: {
      host_kind: "web-page", selection, selectionFresh: true,
      viewportText: "", readableText: "", wordCount: 0,
      frameUrl: location.href, isTopFrame: false
    }};
  }
  const viewportText = collectViewportText();
  return { type: "pippa:host-context", payload: {
    host_kind: "web-page", source: location.href,
    human_label: document.title || location.hostname,
    selection, selectionFresh: freshSelection,
    viewportText, readableText: viewportText,
    frameUrl: location.href, isTopFrame: true
  }};
}

External links

Exercise

Read the live content-script.js and find five boundaries: isPippaEmbedPanelUrl, the top-frame/subframe split in extractContext, the 250 ms selection debounce, extractDocument, and insertTextIntoLastEditable. Explain what stale or self-referential failure each boundary prevents.
Hint
The current file is much larger than the old prototype because dock, overlay, document capture, and assisted insertion shipped. Judge the boundaries, not the line count.

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.