"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 viascheduleContextwith a 350 ms quiet period. Heavy event; batching matters.selectionchange,mouseup,pointerup,keyup,touchend— selection-related;scheduleSelectionContextfires 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.
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.