This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Firefox browser extension (Manifest V3) that injects a scatterplot panel into eBay search pages to visualize listing prices. Works on all eBay search pages — sold listings are plotted by sale date; active listings are plotted in a price-distribution strip chart.
npm install # installs Chart.js + web-ext; postinstall vendors Chart.js into vendor/The extension's own source (src/*.js) is loaded as-is — no bundler or transpiler. Third-party libraries are vendored via scripts/vendor.mjs (runs on npm install postinstall and before npm run build) so the packaged XPI never references node_modules/:
- Chart.js — file-copied directly into
vendor/chart.js/ - easy-currencies — CJS/Node package (uses axios); cannot be loaded as a content script directly, so bundled via esbuild into a browser IIFE at
vendor/easy-currencies/easy-currencies.iife.jsexposingwindow.EasyCurrencies.platform: 'browser'in the esbuild config resolves axios's browser field automatically.
Load the extension in Firefox for development:
about:debugging#/runtime/this-firefox→ Load Temporary Add-on → selectmanifest.json- Navigate to any eBay search page (e.g.
*.ebay.com/sch/*,*.ebay.co.uk/sch/*, etc.) - After editing any
src/*.jsfile, click "Reload" on the extension card
To update Chart.js: npm update chart.js then npm run vendor (re-copies into vendor/).
Source is formatted by Prettier (.prettierrc: singleQuote) — 2-space indent, single quotes, semicolons, braces on all blocks. npm run format writes; npx prettier --check src/ test/*.mjs verifies. web-ext lint does NOT check style, so run Prettier separately after editing. .prettierignore excludes test/fixtures/ — fixtures stay exactly as captured.
npm run lint—web-ext lint, must pass with 0 errors before submitting. Baseline is 0 errors / 1 pre-existing warning (KEY_FIREFOX_ANDROID_UNSUPPORTED_BY_MIN_VERSION, fromstrict_min_versionin the manifest) — not yours, leave it. To prove a change adds no warnings, compare the count againstHEAD.npm run build— vendors Chart.js, thenweb-ext build→web-ext-artifacts/scatterplot_for_ebay-<version>.zipnpm run sign— submits to AMO as a listed add-on (needsWEB_EXT_API_KEY/WEB_EXT_API_SECRET)
Packaging ignores are under webExt.ignoreFiles in package.json — node_modules/, scripts/, test/, and docs, but vendor/ IS shipped. Bump version in both manifest.json and package.json per release.
npm test # node:test runner; cross-env pins TZ=Australia/Sydney (cross-platform)
TZ=Australia/Sydney node --test test/storage.test.mjs # run one file (keep the TZ pin)Requires Node ≥ 21 (declared in package.json engines): the test-file glob is expanded by Node's test runner, not the shell, so the script double-quotes it to work in both POSIX sh and Windows cmd.
The suite locks down the core logic that must stay stable regardless of eBay's markup: extraction (src/extract.js), the storage contract (src/storage.js), dock geometry (nearestEdge), chart data-shaping math (src/chart.js), and the checkbox state reconcilers (src/checkboxes.js). Coverage is deliberately scoped (see TEST_PLAN.md) — every test is labeled with one of three buckets: invariant (synthetic DOM, must hold forever), documents-behavior (a current/debatable choice pinned so a change is noticed), or markup-canary (a real captured eBay card; kept few, fails loudly when the markup shifts). Files live in test/:
test/extract.test.mjs—parseAmount,extractPrice,extractDate,extractItemId,extractTitle,extractItemData, plus the markup-canary fixturestest/storage.test.mjs—loadItems/saveItemsdegradation + theMAX_ITEMScaptest/dock.test.mjs—nearestEdgegeometry (incl. the center tie-break)test/chart.test.mjs—renderChartdata-shaping (centering, range line-only, priceHigh-aware y-max) against a fake Charttest/checkboxes.test.mjs—syncPlotAlltri-state,reconcileCheckboxes,injectCheckboxchange-handler cross-type coexistence, and the sold-only Best Offer warningtest/panel.test.mjs—buildPanelDOM/event wiring, font-size input, and dark/light theme toggletest/init.test.mjs— the init sequence itself:init.jsruns at top level, soloadModulesexecuting it is the test. The sandbox's jsdom starts with an empty<body>, sofindResultsList()finds nothing and the layout-failure path runs by default; the happy path seeds aul.su-gridinsetup(which runs before the source files). Needssb.MutationObserverwired —init.jscalls it as a bare global.test/helpers/load.mjs—loadModules(files, { url, setup })loads anysrc/files into one shared jsdom-backednode:vmcontext (mirroring the content-script scope), so the source stays free of any test-only exports and tests run the exact shipped code;loadExtractis the extract-only shorthandtest/helpers/chart-stub.mjs—makeChartStub(), a fake Chart.js constructor that records the config it was built withtest/fixtures/*.html— real listing cards (sold-search and active-search), trimmed and self-contained (no external assets), with expected values asserted against the real data. International pages (international-pages/LOCALE/TYPE/) are minified single-line HTML — use Perl to extract cards:perl -0777 -ne 'if (/(<li\b[^>]*data-listingid=ID[^>]*>.*?<\/li>)/s) { $c=$1; $c=~s/<img\b[^>]*>/ /g; print $c }' page.htm. CSS-class-only strikethrough spans needstyle="text-decoration: line-through"added inline (jsdom ignores class-based rules).
Key conventions:
- The
testscript pinsTZ=Australia/Sydney(a positive UTC offset) on purpose:extractDatemust store the displayed calendar date, and the original H1 bug (UTC round-trip viatoISOString()) only shows up at a positive offset. Running there means any reintroduction fails the date tests even on US machines. loadModules/loadExtractreturn vm-realm values: objects and arrays they hand back are constructed inside thenode:vmsandbox, soassert/strictdeepEqualrejects them on prototype identity — assert structural values viaJSON.stringify(or spread[...arr]into the test realm), notdeepEqual.- Summed prices are floats (
item + shipping), so assert them with a tolerance, never a hand-rounded literal. extractPricereturns{ price, priceHigh? }ornull—priceHighis set for range-priced listings ($8.99 to $18.99old,$599.99 - $649.99current).- Strikethrough needs an INLINE style in fixtures: jsdom's
getComputedStylereflects inline styles but not class-based stylesheet rules, and the live page strikes prices via.strikethrough{text-decoration:line-through}. Any fixture with a struck price must carry an inlinetext-decoration: line-throughorisStruckwon't see it — this silently changes what the test proves rather than failing it. cardDatais a top-levelconst, so it is NOT on the sandbox (loadModulesonly exposes function declarations). Assert extracted data through observable state instead — storage after achangeevent, or the checkbox'sdata-item-id.- Browser globals are absent from the vm sandbox by default — only
window,document,localStorage, andconsoleare wired. Any code path that calls other globals must add them insetup(e.g.sb.getComputedStyle = sb.window.getComputedStyle.bind(sb.window)). Specifically:renderSoldChart/renderUnsoldChartneedgetComputedStyle(viagetChartColors());injectCheckbox'schangehandler needsclearTimeout,setTimeout, and a stubbedrenderChart; loadingcurrency.jsrequiressb.EasyCurrencies = {}(referenced at module load time). Barelocationis not a global in the vm sandbox — source code must usewindow.location.hostname, notlocation.hostname. Missing globals throwReferenceError— but jsdom catches errors thrown inside event listeners and prints them without failing the test, so the suite can show green with noisy output. Wire the globals to silence it.
Content scripts in src/ are loaded sequentially by the manifest. All files share the same content script sandbox scope — no IIFE, no ES modules. Top-level const/let/function declarations in one file are accessible to all subsequently loaded files. The content scripts are registered for eBay search pages across all supported domains (ebay.com, ebay.co.uk, ebay.de, ebay.ca, ebay.com.au, ebay.fr, ebay.it, ebay.es — 8 patterns in the manifest), and src/init.js runs unconditionally on all matching pages (no URL guard). Chart.js is loaded first via the manifest (vendor/chart.js/chart.umd.min.js) so window.Chart is available synchronously — no CDN injection (eBay's CSP blocks it).
Source files (load order matches manifest):
src/constants.js— nineconstvalues:RESULTS_SEL('ul.srp-results, ul.su-grid'— old + current layout),STORAGE_KEY,MAX_ITEMS,DOCK_KEY,SNAP_THRESHOLD,PANEL_OPEN_KEY,THEME_KEY,FONT_SIZE_KEY,CURRENCY_KEYsrc/storage.js—loadItems,saveItems(localStorage, 200-item cap)src/extract.js— content-based DOM extraction:leafElements,listingRoot,extractItemId,extractTitle,parseAmount,isStruck,extractPrice,extractDate,extractItemData;PRICE_BODY/PRICE_RE/RANGE_RE/BEST_OFFER_REsrc/styles.js—injectStyles(injects a<style>tag with all extension CSS)src/currency.js—SYMBOL_TO_CODE,CODE_TO_SYMBOL,TLD_TO_CODE,ALL_CURRENCY_CODES;getDefaultCurrencyCode(TLD → currency code viawindow.location.hostname),getSelectedCurrencyCode(localStorage + default),fetchRates(1-hour in-memory cache viawindow.EasyCurrencies),convertPricesrc/chart.js—let chartInstance,let chartInstanceUnsold,renderChart,renderChartConverted(async: renders face-value immediately, then re-renders with converted prices; all UI event handlers call this instead ofrenderChart— onlyclearAllcallsrenderChart([])directly),renderSoldChart,renderUnsoldChart,rangeLinesPlugin,updateChartFontSizessrc/dock.js—let dockSide,nearestEdge,setDockSidesrc/checkboxes.js—let debounceTimer,cardDataWeakMap,openPanel,injectCheckbox,clearAll,reconcileCheckboxes,syncPlotAll,buildPlotAllControlsrc/panel.js—buildPanel(DOM construction + all mouse interaction)src/init.js— init sequence,findResultsList, MutationObserver (no URL guard)
eBay's SRP layout changed (2026-07). The extension supports both shapes; international locales may still serve the old one. What moved:
| old | current | |
|---|---|---|
| results list | ul.srp-results |
ul.su-grid inside div.su-grid-container |
data-listingid |
on the <li> |
on an inner div.su-item-card.s-item-card |
| range price | separate sibling spans ($8.99 to $18.99) |
one span ($599.99 - $649.99) |
| strikethrough | best offer accepted → skip the item | a discount "was" price → ignore it |
| best offer accepted | struck price + Best offer accepted label |
no signal at all |
srp-results still appears in eBay's own leftover inline <script>, so grepping the page for it is misleading — it is not in the DOM.
Default state: The panel starts minimized on first visit — only the 📈 toggle tab is visible. The panel opens when the user clicks the toggle or checks a listing to plot. Once opened, the open state is persisted in localStorage under PANEL_OPEN_KEY (ebay_scatterplot_panel_open) and restored on subsequent page loads as long as there is saved data. If the panel is closed with the × button the flag is cleared, and a reload with no saved items also starts minimized regardless of the flag.
Layout-failure message: When findResultsList() comes back empty, src/init.js renders "Could not find listings…" plus a Report it link into #ebay-scatter-placeholder (adding .ebay-scatter-error), not into #ebay-scatter-status. Three reasons, all of which bit the earlier version: the status span is a flex item sharing a 320px row with "Clear All" (a 150px column docked top/bottom), so a bare URL there overflows the panel onto eBay's page; renderChart owns that span for the item count and would overwrite the message on any later re-render; and the placeholder's default "Check items below to plot prices" otherwise sits right next to the error contradicting it. Build the link as an element — a URL in textContent is neither clickable nor wrappable, and \n collapses (nothing sets white-space).
Docking: The panel docks to any of the four viewport edges. Dock side persists in localStorage under ebay_scatterplot_dock. setDockSide(side) in src/dock.js applies the correct CSS class (dock-left/right/top/bottom) to both the panel and the toggle button, and clears any inline styles left over from dragging.
- Left/right docks: 320px wide, full viewport height (portrait layout)
- Top/bottom docks: full viewport width, 280px tall (landscape layout — controls sidebar on the left, chart fills the right)
Drag to redock: The <header> is the drag handle (cursor: grab). On mousedown it pins the panel to its current pixel position (removing the dock class), then mousemove floats it. Within SNAP_THRESHOLD (80px) of any edge, a #ebay-scatter-snap-preview overlay shows where it will snap. On mouseup, if the panel was actually dragged it snaps to the nearest edge (setDockSide(nearestEdge(...))); a plain click with no movement restores the current dock — a no-op (guarded by a panelDragMoved flag, mirroring the toggle's toggleDragMoved). The panel always snaps to a side, never free-floats.
Toggle button: The #ebay-scatter-toggle (📈) is a tab-shaped button that appears on the docked edge when the panel is closed. Its shape and position are set by .dock-* CSS classes (matching the panel's dock side). The toggle is also independently draggable to change the dock side without opening the panel — uses the same snap-preview UX. The drag pin (removing dock classes, setting inline left/top) is deferred until the first mousemove so a plain click never alters the button's appearance.
Resize: #ebay-scatter-resize is an invisible 6px strip on the panel's free edge. Hovering shows a col-resize or row-resize cursor. Dragging resizes width (left/right docks) or height (top/bottom docks) inward. Min/max: width [200px, 70vw], height [120px, 70vh]. Both chartInstance and chartInstanceUnsold are resized during drag. setDockSide clears inline width/height so size resets to CSS defaults when switching dock sides.
z-index: Panel is 2147483647 (max int32), toggle is 2147483646, snap preview is 2147483645 — ensures the panel sits above eBay's sticky navigation.
Theme & font size: A dark/light toggle (#ebay-scatter-theme) and a font-size number input (#ebay-scatter-font-size) live in the header. Theme persists under THEME_KEY (default dark); the toggle flips .theme-light on both panel and toggle button and destroys/recreates both chart instances so they pick up the new CSS-variable colors. All themeable colors are CSS custom properties declared on #ebay-scatter-panel and overridden under .theme-light. Font size persists under FONT_SIZE_KEY (default 14px, range 10–28).
Font sizing: The panel base font-size is set via panel.style.fontSize (inline style) so it survives setDockSide's style-clearing. Child CSS font-sizes use em units so they scale with the base — never add a hardcoded px font-size inside #ebay-scatter-panel. Chart.js axes/tooltips do not inherit CSS font-size; they need explicit font: { size } on tick options and bodyFont: { size } on tooltip options. updateChartFontSizes(size) in src/chart.js patches both live chart instances.
Header drag guard: The header mousedown handler skips drag logic when e.target.closest('button, input, select') is truthy. Any new interactive element added to the header must be included in this selector, or clicks will trigger drag logic and can pin/reset panel dimensions.
Extraction is content-based (resilient to eBay class renames):
- Listing root:
listingRoot(card)resolves the element that actually carries one listing, and everything else inextractItemDatareads from that root, not the<li>. The<li>is only a grid slot now. Rules: id on the card → the card (old layout); exactly one[data-listingid]descendant → that element (current layout); more than one →null, because a related-items carousel<li>wraps many listings andextractItemId's/itm/fallback would otherwise silently return the first nested one and plot a bogus item (this really happened — 2 per active page). Zero → the card, which preserves the observer's lazy-render retry. - Item ID:
data-listingidattribute, fallback to/itm/<id>URL parsing - Date: leaf element (
span/div) matchingSOLD_RE(/^(?:Sold|Verkauft|Vendu(?:\s+le)?|Venduto|Venduti|Vendido|Vendidos)\b/i) — parses the date substring usingMONTH_MAP(EN/FR/IT/ES/DE month names). Handles day-first format ("14 Jun 2026", used by UK/AU/CA/DE/FR/IT/ES) and month-first ("Jun 14, 2026", US/MX EN) via pure string manipulation — nonew Date()conversion (immune to UTC/TZ regression). Returnsnullfor active (unsold) listings. - Price: leaf element whose full text matches
RANGE_RE(a range in one span) orPRICE_RE($X.XX,£X.XX,EUR X,XXprefix, orX,XX EURsuffix) — range is tried first. Both are built from one sharedPRICE_BODYstring, which spells the non-breaking space as\u00a0— keep it escaped, never paste a literal one back in.parseAmount's regexes still hold 4 literal U+00A0 chars (src/extract.js:64-70, plus 2 more in the comments there): the Edit tool cannot match those lines — it silently swaps a\u00a0escape for the real character and vice versa, so neither form matches. Edit them with a script (node -eon the file) instead.isStruck(el)checks the parent chain forline-through; shipping added if a leaf after the price element in DOM order contains a multi-language shipping keyword (delivery/shipping/Versand/Lieferung/livraison/consegna/spedizione/envío/expédition) and a currency amount, or a+<amount>leaf in any supported currency (split-span delivery pattern). Scanning only post-price leaves prevents false positives when product titles contain free-shipping phrases (e.g. Italian "SPEDIZIONE GRATUITA", French "Livraison gratuite") — title spans always precede the price in eBay card DOM.parseAmountauto-detects EUR comma-decimal (X,XXwith exactly 2 digits before space/end) vs. period-decimal; US thousands commas ($1,234.56) always have 3+ digits after the comma and are never misread. Returns{ price, priceHigh? }ornull.priceHighcomes fromRANGE_RE(current layout, one span) or, failing that, from a second non-struck price leaf in the same parent element (old layout,$8.99 to $18.99across spans). The sibling scan must stay leaf-filtered (leafElements(priceEl.parentElement, …)): wrapper divs inherit their descendants'textContent, so an unfilteredquerySelectorAllmatchesdiv.su-item-card__price-accessoryand reads the struck "was" price as a range high —$2,299.99becamepriceHigh: 5499.99. TheisStruckwalk goes up from the candidate, so it never sees a class on a descendant span, which is why the filter (not the walk) is the fix. Mixed currencies (foreign sellers) appear on every eBay page and are plotted at face value — no currency conversion. - Title:
[role="heading"][aria-level="3"]with.clipped/aria-hiddennodes stripped; falls back to cardaria-label
Best offer — bestOffer means ENABLED, never ACCEPTED. BEST_OFFER_RE matches the "or Best Offer" span (EN/ES/FR/CA-FR confirmed against fixtures; DE and IT are guesses until an international current-layout page is captured) and sets item.bestOffer = true; the field is absent otherwise, never false. On a sold card this means the shown price is the listing price and may not be what it sold for. Whether an offer was actually accepted is not derivable from the search page: the old layout said so explicitly (struck price + Best offer accepted label — see test/fixtures/best-offer-strikethrough.html), the current one has no struck price, no label, no ld+json, and no state blob carrying a real sale price. Only the item page reveals it, and that would cost ~60 fetches per search page. Half of all sold listings are affected (30/60 on both example pages), so they are still plotted — injectCheckbox renders a .ebay-scatter-bo-warn under the checkbox on sold BO cards only, pointing the user at the listing. Active BO cards keep the flag but get no warning: their price is the asking price and is accurate.
extractItemData returns null for old-layout best-offer-accepted items (struck price) or items with unparseable prices. Items with a sold-prefix date get type: 'sold'; items without get type: 'unsold' and today's local date as a fallback (so they still appear in the chart). Items in localStorage from before the type field was added are treated as 'sold'. injectCheckbox only injects a checkbox if extractItemData succeeds. Non-listing tiles (ads/promos) are marked data-scatter-injected="skip" and never re-checked; a listing card that fails extraction is left unmarked so a later observer pass retries it (handles eBay's lazy rendering). Successfully extracted data is cached in a WeakMap (cardData) so the checkbox handler and "Plot all" don't re-extract.
- Per-item checkboxes are injected into each valid listing card. Checking one saves the item to
localStorageimmediately and callsopenPanel()to open the panel if it is minimized. #ebay-scatter-plot-allis inserted directly beforeul.srp-results(anchored to the stable list container, not the filter bar which eBay re-renders). It has three states via the nativeindeterminateproperty: unchecked (none), indeterminate (some), checked (all).syncPlotAll()reconciles its state after every change.clearAll()wipes localStorage, unchecks all checkboxes, clears the chart.- Storage dedup key is
id+type— all mutation paths (add, remove, dedup) use composite key"${id}:${type || 'sold'}". If you add a new write path, follow this pattern; id-only dedup silently drops one record when a multi-quantity item appears as both sold and active.
Two separate chart sections are rendered inside the panel, each visible only when items of that type are selected:
Sold listings (#ebay-scatter-sold-wrap, blue dots): Chart.js scatter with x = sold date (millisecond timestamp), y = total price. Custom ticks.callback formats x-axis as YYYY-MM-DD. No date adapter needed.
Active listings (#ebay-scatter-unsold-wrap, blue dots): Items sorted by price; x = sequential index centered around 0 (no date meaning — used purely to spread overlapping prices and reveal price clusters). x-axis tick labels are hidden. Range-priced items show only a vertical line (dot suppressed).
Both charts use the rangeLinesPlugin (defined in src/chart.js) to draw a vertical line through range-priced items (priceHigh set). Both update in-place rather than destroying and recreating. Both chartInstance and chartInstanceUnsold are declared in src/chart.js and referenced directly by src/dock.js and src/panel.js.
In-place update closure gotcha: The if (chartInstance) early-return paths only patch .data and scale options — they do NOT refresh the tick/tooltip callbacks, which close over sym (the currency symbol) at chart-creation time. Any feature that changes what those callbacks display must explicitly reassign the callback functions in the in-place update branch, not only in the initial construction block.
The list is found by findResultsList(): RESULTS_SEL first, then a content-based fallback that picks the <ul> holding the most [data-listingid] elements (verified on the examples: 60 for the real list vs. 10/8/7 for carousel__list runners-up, because carousels nest their own <ul> so closest('ul') attributes their items to the carousel). That fallback exists because a class rename silently switches the whole extension off, which is exactly what happened when srp-results became su-grid.
One observer in src/init.js, on that container (childList). On each mutation it re-scans the direct <li> children that aren't yet marked (:scope > li:not([data-scatter-injected])) and runs injectCheckbox on them. This covers infinite scroll (new cards) and retries listing cards whose price/date hadn't rendered on an earlier pass. Filter, sort, and pagination on eBay search pages trigger full page navigations, so the extension simply re-initializes. Pagination was verified to work this way: the next page is a full reload, and because selections persist in localStorage, the existing plot survives and new-page items can be added to it (plotting accumulates across pages). There is no separate observer for in-place list replacement — eBay was confirmed never to swap ul.srp-results in place.