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_workerbecomesbackground.scriptson an event page. The bundle is unchanged: nothing insrc/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, andstorage.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 andoptional_host_permissions, and 133 broughtstorage.local.getBytesInUse, which the popup uses to size the verdict cache. The floor is 140 anyway, because that is wheredata_collection_permissionsstarts 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 declareswebsiteContentas 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 returnstruesynchronously to claim the channel. Firefox answers with the promise the listener returns and ignores a claimed channel.src/background/index.tssettles 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
- Disabled filtering, protected surfaces, bypassed threads, the opened post and its ancestors, allowed authors, and manually revealed posts stay visible.
- Blocked authors (including reposters) are hidden.
- Literal blocked words/phrases are hidden at Unicode word boundaries.
- 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
configuredflag, 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. BumpPROMPT_VERSIONwhen 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,Popularfalls back to A–Z and the sheet says so. - Endpoint speed (
/stats/endpoint?permaslug=…) — the documented endpoints API returnslatency_last_30mandthroughput_last_30masnullfor 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:
- Build and load the repository root; check for manifest and service-worker errors.
- Save a connection and test it, then open an X timeline.
- 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.
- Open a thread: its ancestors/root stay visible; replies may be filtered.
- Toggle filtering and navigate without reloading; old decisions must not leak onto recycled posts.
- Simulate an offline/rejected provider: posts stay visible and the popup/badge reports the error.
- Reload the worker mid-scroll: cached posts should not create new requests.
- 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.
- Scroll fast in both directions and confirm the scroll position never jumps.
- 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.