Contributing

September 19, 2026 · View on GitHub

Technical notes for working on Sharp. For what it does and how to install it, see the README.

TypeScript · Effect · Preact · esbuild · Manifest V3 (Chrome and Firefox)

Toolchain

Use Bun 1.3.10 or newer (CI uses 1.3.10).

bun install --frozen-lockfile
bun run dev          # rebuild scripts and styles as you edit
bun run dev:firefox  # the same, into firefox/
bun run build:debug  # one unminified build with inline source maps
bun run build:firefox # one production build into firefox/
bun run typecheck    # strict TypeScript, without emitting
bun run test         # focused provider, storage, cache, RPC and menu checks
bun run format       # format source and docs
bun run check        # types + tests + formatting + both production builds
bun run release      # build both targets, then zip each for a release
bun scripts/icon.mjs # regenerate icons/ from geometry

Use bun run test, not bun test: the tests use Vitest, including its jsdom environments and mocks. bunfig.toml runs tool binaries with Bun rather than their Node.js shebangs; a separate Node.js installation is not required.

bun.lock pins dependency versions. --frozen-lockfile refuses to change it; use bun install when intentionally updating dependencies. The explicit empty trustedDependencies list blocks dependency lifecycle scripts, including Bun's default trusted packages. This reduces install-time execution, not the risk of executing a malicious dependency later during builds or tests.

Build output

The Chrome build writes the bundled scripts, styles, and popup HTML beside manifest.json in the repository root, not into a subdirectory. This keeps the original unpacked-extension path and ID. No runtime code is loaded from a CDN.

--target=firefox writes a complete second build into firefox/, generated manifest included. It is compiled, not copied: the target is a build-time constant, so each bundle carries only its own browser's branch. Load firefox/manifest.json from about:debugging → This Firefox → Load Temporary Add-on. firefox/ is generated and ignored by Git.

bun run build minifies. When a stack trace points at content.js:45, use bun run build:debug (or bun run dev): readable names, inline source maps, so Chrome shows the TypeScript line. Do not ship a debug build; release rebuilds.

Reload the extension and X tabs to use rebuilt code. The popup and content script load from disk on every open, but the service worker keeps running until the extension is reloaded. After a rebuild that adds a settings field, a fresh popup would otherwise be talking to a worker that has never heard of it; the RPC layer fills missing settings fields with defaults so that window is survivable, but it is still a mismatch. Reload. Restart bun run dev after changing the manifest, popup HTML, or icons; those static assets are copied at startup.

If Chrome shows an older UI, run bun run build in the repository directory Chrome actually loaded, then reload the extension and the X tab. Builds in another checkout or an agent worktree do not update that output. Disable duplicate older extension instances so they cannot keep modifying the page. The popup's Build timestamp identifies its compiled code; on X, document.documentElement.dataset.aitfBuild should return the same timestamp. Missing or different values identify a missing or stale content script.

Firefox

One source tree, two Manifest V3 browsers. Everything that differs is listed here; there is no polyfill, because the extension only ever calls promise-based chrome.* APIs and Firefox provides those under the same name.

scripts/manifest.mjs derives Firefox's manifest from Chrome's:

  • Background. Firefox has no extension service worker, so background.service_worker becomes background.scripts on an event page. The bundle is unchanged: nothing in src/background/ touches a worker-only global, and an event page is suspended and revived the same way, which the code already assumes.
  • browser_specific_settings.gecko.id. AMO keys the add-on on it, and storage.sync — read once, to migrate API keys off it — has nowhere to write without it. It must never change between releases.
  • strict_min_version. What the code needs is 133: 128 brought MAIN-world content scripts, module event pages and optional_host_permissions, and 133 brought storage.local.getBytesInUse, which the popup uses to size the verdict cache. The floor is 140 anyway, because that is where data_collection_permissions starts being honoured, and 140 is the current ESR, so nothing still supported is excluded.
  • data_collection_permissions. AMO refuses a new add-on without it. Sharp declares websiteContent as required: nothing reaches the developer, there being no server, but post text, handles and reply context do go to the AI provider the reader configured, and that is the extension's whole function rather than something optional. Keep it consistent with privacy-policy.md.

Two things differ at runtime, both behind isFirefox in src/common/build.ts, which is a build-time constant so the other browser's branch is dropped from the bundle:

  • Answering a message. Chrome answers only through sendResponse, and only if the listener returns true synchronously to claim the channel. Firefox answers with the promise the listener returns and ignores a claimed channel. src/background/index.ts settles one promise and hands it over whichever way the browser expects.
  • Naming the extensions page in the "Sharp was updated, reload it" error.

Host permissions are the one real behavioural difference, and it is not papered over. Chrome grants declared host_permissions at install. Firefox treats them as optional even when declared, so until the reader grants them the content scripts never inject and the provider is unreachable — indistinguishable from a broken extension. The popup checks permissions.contains for the filtered sites and the configured provider's origin and, if any are missing, says so and offers a button that asks for exactly those. The request has to be the first thing the click does: Firefox rejects one made after an await, outside the user gesture. Granting does not inject into tabs that are already open, so the banner says to reload them.

bun run release produces sharp-<version>-chrome.zip and sharp-<version>-firefox.zip. 0.3.0 is the exception: its Chrome archive was published as sharp-0.3.0.zip, before there was a second target, and keeps that name and URL — link it as-is rather than as sharp-0.3.0-chrome.zip. Every release from 0.3.1 names both by target.

The Firefox archive goes to AMO, which signs it. Check it first with Mozilla's own validator:

bun run build:firefox && bunx web-ext lint --source-dir=firefox --self-hosted

It should report no errors. Three warnings are expected and not worth chasing: data_collection_permissions postdates the Android floor, and Preact's dangerouslySetInnerHTML path assigns to innerHTML inside its own bundle — no source file in src/ touches innerHTML.

Code map

src/
  index.ts      Content-script dispatcher: exact hostname → site entry
  common/       Settings/Post schemas, typed RPC, errors, shared rule operations
  background/   Provider protocols, the classifier, verdict parsing, durable cache,
                storage, RPC
  x/
    index.ts    Explicit X adapter startup and teardown
    ...         X DOM extraction, local rules, timeline lifecycle, in-page UI,
                the debug inspector
  youtube/      Toggle-driven page rules: three attributes on <html>, one stylesheet
  popup/        Rail, per-site and general sections, model browser, list sheets
tests/          Focused provider/storage, cache, RPC and menu checks
scripts/        Per-target extension build, manifest derivation, release, icons

Site entry points are inert until called. src/index.ts dispatches HTTPS x.com and twitter.com to startX() and www.youtube.com to startYouTube(); importing a site entry alone installs no listeners or observers. The background worker and popup remain separate extension entry points.

YouTube is rules only, on both of its sites. www.youtube.com and m.youtube.com run the same adapter: it sets attributes and the stylesheet decides what they mean, so the mobile site cost selectors rather than code. ytd- elements are the desktop site, ytm- the mobile one, and the newer *-view-model elements and yt*ViewModel classes are shared; a selector that matches nothing on the site in front of it costs nothing, so the pairs sit together per feature. The adapter reads public settings, sets data-aitf-yt-shorts and data-aitf-yt-comments on <html>, plus data-aitf-yt-thumbs="blurred" or "hidden", and follows storage changes. Hidden thumbnails give up their height; the badges that sat on the picture (duration, live) are re-laid as a slim strip so the duration stays bottom-right. Everything visible is src/youtube/style.css, keyed on those attributes. No model, no DOM walking, nothing to schedule. YouTube's markup mixes Polymer ytd-* elements with newer *-view-model ones; selectors cover both, and a Short is recognised by its /shorts/ link rather than its container, so it survives layout renames.

To add a site, create src/<site>/index.ts with an explicit startup function, add its exact hostname to the dispatcher, and add the necessary matches and host permissions to manifest.json. Keep selectors and page UI inside the site folder. Shared schemas still describe this extension's current filtering model; a future adapter that needs different author identities or site-specific settings should extend that model explicitly, not reuse X's DOM assumptions. Settings that read as per-site in the popup — filtering on/off, image analysis, the YouTube toggles — are still stored flat; the popup's rail decides which ones each section shows.

Effect owns external operations: typed provider/storage failures, abortable fetches, deadlines, and semaphore-protected writes. Chrome listeners and Preact handlers are the Promise interop boundaries. Pure parsing, rules, and DOM extraction stay plain TypeScript.

The timeline controller owns one state per post: queued, running, decided, or waiting to retry. A post is identified by its tweet id, not by a DOM node, because X recycles nodes and rewrites text when "Show more" is pressed. Changing AI inputs increments a generation, so stale replies cannot hide posts under a new filter.

Undecided posts keep their layout. A pending post's own children are hidden with visibility, and the skeleton is drawn over it from inside the article. Nothing resizes when a verdict says show, so X's virtualiser never re-measures the column and the scroll position holds. Only a confirmed hide resizes anything. Presentation uses scoped CSS attributes rather than overwriting X's inline styles.

A known verdict is applied before X measures the post. X positions timeline cells from heights it measures on mount and caches, and it restores the scroll position from those heights after a navigation. When a mutation adds a post whose verdict is already known, the controller scans synchronously inside the mutation callback, before the next layout, so the post is already collapsed when X first measures it. Otherwise X caches the full height, restores onto it, and re-lays out the whole list when the post collapses a moment later, which lands the reader somewhere else after backing out of a thread. As a backstop, the post at the top of the viewport is remembered on the click that leaves Home and nudged back once after X's restore, unless the reader scrolls.

The rendered menu is the source of truth. After settings load, an observer watches X's #layers portal and direct ancestor removal/visibility. If the portal has not mounted yet, a temporary body-subtree observer waits for it, then switches to the scoped portal observation. There is no menu polling or click deadline.

A single visible dropdown must contain a native tweetEngagements link identifying the exact author and post. Conflicting author markers and ambiguous menus are rejected. This works for already-open menus, incremental mounts, and virtualized articles without requiring a captured click or an ARIA relationship. A native menu without a recognized identity is left untouched.

Preact owns only the injected rows, appended below X's own actions. Native items are never cloned, replaced, or activated by the extension; successful saves request native dismissal via Escape. Keyboard handling only bridges focus transitions involving the added rows. Typography, colour, padding and icon size are sampled from a native menu item. Elements mounted outside a post — the thread control — sample their typeface from the focal post, because X applies its face to text elements rather than to their containers.

Scheduling and batching

Scanning. A leading-edge throttle at SCAN_INTERVAL (100ms) drives scans from a MutationObserver, backed by a POLL_INTERVAL (200ms) interval that also covers SPA navigation, delayed focal posts, and expired retry deadlines.

Selection. nearViewport limits work to the reader's lookahead setting, a percentage of a viewport either side, so posts far from the fold are neither judged nor paid for. Batches fill to batchSize in DOM order, images or not.

Concurrency. A full batch is dispatched immediately and the next starts at once, up to the reader's concurrency setting (default 3). Together with batchSize this is the popup's Spending control: presets are named pairs in spendPresets, and any other pair reads as Custom. BATCH_WINDOW (200ms) only bounds how long a partial batch waits for stragglers. In the worker, the cache lock covers the read and the merge but never the provider call, so requests overlap; the merge re-reads under the lock rather than writing back its original snapshot, so overlapping batches cannot drop each other's verdicts.

Deadlines nest: provider fetch 25s < evaluate 32s < content request 40s.

Corrections and "Not interested"

A correction is a verdict the reader made. Once a hidden post is revealed with Show, a "Wrong call?" strip takes the banner's place and discloses two checks in flow: Never filter @author (an author rule) and Keep posts like this (CORRECT_VERDICT with keep; unchecking sends forget). "Hide posts like this" in the ⋯ menu sends hide. The worker replaces any correction with the same text in settings.corrections (bounded to MAX_CORRECTIONS) and, for keep/hide, writes the corrected verdict straight into the cache under the current decision scope. A hide from the menu settles the post locally first; a keep from a revealed post changes nothing on screen, it is already showing.

Unsure is a flag, not a score. The prompt's example row carries "unsure":false so models reproduce the key; it is true only for a hide that is a close call. It is carried through parseVerdicts, the verdict, and the cache entry. The banner swaps the gavel for an accent question mark, and hideFully (no banner, article display: none) is skipped for unsure posts so a close call always keeps its banner and its Show button. showAuthor only drops the name from the banner.

Corrections are sent with every batch as JSON examples marked as data, and are deliberately not part of decisionScope: they steer future decisions, and throwing away every cached verdict on each correction would re-buy the visible timeline for a one-post change. Verdicts cached before a correction stand until they expire or Clear cache is used.

"Not interested" is opt-in, only on /home, and never for an unsure verdict. It is sent over the wire first. wire.js runs in the page's world (manifest world: MAIN) and decorates window.fetch: the original is called with the original arguments and its promise returned untouched; for /i/api/ requests it also reads the signing headers X sent (authorization, x-csrf-token, x-client-transaction-id, x-twitter-*) and, for GraphQL JSON responses, clones the response and lifts each entry's feedbackInfo.feedbackMetadata keyed by post id. Both cross to the content script by window.postMessage, same-window and same-origin only. Feedback then POSTs /i/api/2/timeline/feedback.json?feedback_type=DontLike&action_metadata=… with body feedback_type=DontLike&undo=false, exactly what X's menu sends. The banner shows "Telling X" then "Told X"; no menu opens and X draws no card, so nothing in the timeline changes hands. The metadata is Thrift compact: {4: [{1: postId}, {2: authorId}], 5: 30 days}; Sharp echoes X's blob rather than encoding it. The x-client-transaction-id is reused from X's latest call; if X ever rejects that, the request fails soft and the menu path below runs.

The menu path is the fallback, used when the wire has not yet supplied metadata and headers for a post, or X refused the request. With notInterested on, a hidden post's ⋯ caret is clicked, the dropdown that names the same post via its tweetEngagements link is found, and the menu item whose text matches Not interested in this post is activated. While this runs the document carries data-aitf-auto, which hides the dropdown; the menu adapter reads an invisible dropdown as absent, so it never injects rows into a menu the extension opened. Reports are serial because X shows one menu at a time; each takes as long as the menu takes to open and close plus a 200ms gap. Each one makes X mount and unmount a portal, and opening one closes any menu the reader has open, so the runner waits for requestIdleCallback, never starts one within 250ms of a scroll event, and holds while any dropdown, menu or dialog is visible in #layers. The mutation observer re-claims a swapped cell with O(1) work only; the page-wide lookup by handle runs from the throttled scan. At most once per post identity per tab, capped per tab, and abandoned after three menus without a matching item (a non-English interface). Nothing else in a native menu is ever activated.

The sequence is: verdict → hidden → "Not interested" clicked → the spot stays hidden while X's "Thanks, X will use this" card materialises → nothing (hide completely) or a "hidden, X told" banner with Undo. The card names the author and must never be painted, so the controller claims the post's cellInnerDiv immediately before the menu item is clicked (NotInterested's before hook; X can swap the post synchronously inside that click) and dresses it via view.applyFeedback with a "Telling X…" tag (told, keyed by post id). X swaps the cell's contents or the cell itself on its own schedule; the spot is remembered by parent, index and previous sibling, and the mutation observer re-claims a swapped cell synchronously, before the frame paints. As a last resort the card is found by the author's handle, which X prints in "Show fewer posts from …". A cell with no post and a button is the acknowledged card ("Told X", Undo enabled; Undo activates X's own). The cell is handed back when X puts a post in it, but only Undo (the same post, back in its cell) ends the claim; a recycled cell just drops the node, and the claim waits to find its card again. Unanswered clicks expire after FEEDBACK_WAIT. An acknowledged claim is kept for the tab (bounded by MAX_TOLD): X redraws its card from memory whenever it rebuilds the timeline, such as after opening a post and going back, and the handle lookup claims it again each time. Hidden-state CSS is therefore keyed on [data-aitf-hidden] rather than on article.

X's card comes back after every rebuild. A wire report leaves the post in place, but X's server remembers it: backing out of a thread or reloading redraws the "Thanks" card in that spot, with no post id in it. The controller adopts those cards from the wire's entry order: a run of cards between two identified neighbours maps onto the ids the timeline response placed between them. A card it cannot tie to a post is dressed generically and dropped when it leaves the DOM.

Rule precedence

  1. Disabled filtering, protected surfaces, bypassed threads, the opened post and its ancestors, allowed authors, and manually revealed posts stay visible.
  2. Blocked authors (including reposters) are hidden.
  3. Literal blocked words/phrases are hidden at Unicode word boundaries.
  4. Remaining posts are sent for AI classification if a key, model, and criteria are configured.

Bookmarks, notifications, messages, and settings are protected surfaces. Local block rules work without an API key. An empty AI criterion disables only AI classification, not local rules.

Storage, cache and cost

  • API keys and settings are stored in local extension storage, not browser sync. This is not encrypted secret storage.
  • API requests run in the background worker. Content scripts receive settings with a configured flag, never API keys. Runtime messages are schema-validated, and privileged operations are restricted to the extension popup.
  • Stored settings are merged over defaults before decoding, so a build that adds a field cannot fail on older data.
  • Successful verdicts are cached locally for seven days, up to 4,000 entries. Cache identities include PROMPT_VERSION, provider, custom endpoint, model, routing pin, criteria, image configuration, post ID, author, and text. Bump PROMPT_VERSION when the system prompt changes in a way that can change a verdict; that re-judges the visible timeline once. Mounted reply context and thumbnail variants are intentionally excluded to avoid charging again whenever X remounts a neighboring post.
  • Failed or missing verdicts are not cached. Posts stay visible, with at most three attempts per post in a tab (4s, 8s, 16s backoff) before stopping. A malformed paid response may still consume tokens.
  • Counters track hide events (once per retained post identity in each tab), classification attempts, and reported tokens. They are not a billing ledger. The connection probe is not included.

OpenRouter beyond the documented API

Two features read openrouter.ai/api/frontend/v1/..., which is undocumented and may change without notice. Both are fetched without an auth header, on a 10s timeout, and fail soft:

  • Popularity (/rankings/models) — seven days of per-model token totals, summed and ranked once, joined to the catalogue by canonical slug. On failure, Popular falls back to A–Z and the sheet says so.
  • Endpoint speed (/stats/endpoint?permaslug=…) — the documented endpoints API returns latency_last_30m and throughput_last_30m as null for every provider. The real p50 figures live here, keyed by dated permaslug, which is embedded in each endpoint's label. On failure the documented values are used.

Migrating from the original scripts

When loaded as an update with the same extension ID, settings and API keys are read from the old sync storage once, validated, and saved locally. Synced API keys are removed only after the local save succeeds. Other legacy sync settings remain as a backup, but are no longer read after migration.

Loading the build from a different directory can give an unpacked extension a different ID. In that case Chrome does not expose the old extension's storage to the new one; configure the new instance manually.

Validation and remaining limits

The test suite covers provider responses, cache behavior, credential migration, storage writes, RPC access control, formatting helpers, and the already-open menu regression. It does not establish that the UI works on a logged-in X page; check that directly.

Before shipping, manually check with your own browser and provider:

  1. Build and load the repository root; check for manifest and service-worker errors.
  2. Save a connection and test it, then open an X timeline.
  3. Confirm collapse, blur, Show, allowed authors, and blocked words. After Show, open Wrong call? and check both rows; the author rule and the correction must appear in the popup, and a corrected post must stay decided after a reload without a new request. With Hide completely on, only close calls keep a banner with the question mark.
  4. Open a thread: its ancestors/root stay visible; replies may be filtered.
  5. Toggle filtering and navigate without reloading; old decisions must not leak onto recycled posts.
  6. Simulate an offline/rejected provider: posts stay visible and the popup/badge reports the error.
  7. Reload the worker mid-scroll: cached posts should not create new requests.
  8. Open the ⋯ menus of different posts in succession, including reposts, and check that author rules target the displayed original author. Check native actions, keyboard activation/dismissal, and light/dim/dark themes.
  9. Scroll fast in both directions and confirm the scroll position never jumps.
  10. Turn on Mark hidden posts "Not interested" on Home: hidden posts turn into X's "Thanks for your feedback" card one at a time, no menu is visible, and the ⋯ menu still works by hand while it runs.

X's DOM is not a public API. Extraction and menu selectors are isolated in src/x/extract.ts and src/x/post-menu-dom.ts. The current menu adapter targets the desktop dropdown, not the mobile bottom sheet. Automated tests use representative DOM fixtures and mocked provider responses, not a logged-in X account; verify against your current X layout before relying on it.