diff --git a/README.md b/README.md
index c5ecb14..4e6c8ff 100644
--- a/README.md
+++ b/README.md
@@ -12,405 +12,49 @@ substandard shows a badge when the site you're visiting has a standard.site publ
- This extension is open source and has no backend, no analytics, and no tracking.
-## What it does
+## Installing
-- Detects publications via the authoritative `/.well-known/site.standard.publication`
- endpoint, with `` and
- `` head tags as hints. Verification is
- bidirectional: the well-known endpoint at the publication record's own `url`
- must return the record's at-uri.
-- The toolbar icon is always the substandard logo; per-tab status is shown
- with Chrome's native action badge. A badge appears only when there is
- something worth noticing: green means a verified publication (`+` can
- subscribe, `?` subscription state unknown, `✓` subscribed), the same glyph
- in amber with a trailing `*` means the same relationship on a page that is
- not the publication's verified home, amber `!` means detection could not
- run at all, red `X` means you have blocked the account behind the
- publication, grey `•` means a check is
- running. Pages with no publication, non-http pages, and offline tabs
- carry no badge. Each state also sets the icon's tooltip title, so hover
- and screen readers get the state in words rather than color alone. See
- `docs/status-states.md` for screenshots of every state.
-- The popup shows the publication's name, icon, and description, plus the
- current article when the page has a document record.
-- Above it sits the account the publication lives in: avatar, display name,
- handle and bio, read from that account's own DID document and its
- `app.bsky.actor.profile` record on its own PDS, and linked to its profile.
- The handle is only shown once it has been proven — a DID document's
- `alsoKnownAs` is a claim by whoever controls the DID, so it counts only if
- resolving the handle returns that same DID, the same bidirectional check
- detection makes for publications. An unproven handle is replaced by the
- DID rather than shown. This card replaced the older "by @handle" byline,
- which showed the claim. See `src/lib/profile.ts`.
-- That card says "Following" when the account behind the publication is one
- you follow, from the same follow list the subscriber row uses: your own
- `app.bsky.graph.follow` records, read from your own repo, so it needs no
- extra OAuth scope and no appview sees the question. It is a status and not
- a control — this extension can write only its own subscription records, so
- following and unfollowing stay on the profile the card links to. An account
- you have blocked never gets the line: blocking does not delete the follow
- record it supersedes, and a card that is already refusing to show someone
- must not announce that you follow them. No line is never "you do not follow
- them": it is also what a signed-out popup, a failed read, and a follow list
- past its 10,000 cap all look like.
-- The popup shows how many accounts subscribe to the publication, and the
- faces of the ones you follow. Subscriptions are public records in other
- people's repos, so the count comes from Constellation, microcosm's free
- backlink index; the follow set is read from your own repo and each face
- from its owner's PDS, so no appview sees which publication you are on.
- Constellation is a third party: if it cannot answer, the row is hidden
- rather than guessed at. See `src/lib/subscribers.ts`.
-- Moderation labels from labelers the user listens to appear on the
- publication card, and a label that Bluesky's own interpretation puts
- behind a click puts the card behind one too, with a "Show anyway" button.
- A labeler is any account with an `#atproto_labeler` service, so the
- extension holds no opinion of its own: it asks the labelers *you*
- subscribe to about the publication's account and records, and shows what
- comes back. The list is your own Bluesky labeler subscriptions, read from
- `app.bsky.actor.preferences` — subscribe to a labeler in any client and
- its labels appear here, with no substandard-specific list to maintain.
- Signed out, or with a session that predates the scope, it falls back to
- the `labelers` key in `chrome.storage.local` and then to Bluesky's own
- moderation service. The
- severity, blur and wording all come from Bluesky — `LABELS` and
- `interpretLabelValueDefinition` in `@atproto/api`, plus the global label
- strings — so a label you recognize from Bluesky reads the same here. See
- `src/lib/labels.ts`.
-- The popup also carries status pills (`src/lib/status.ts`): every
- applicable message shows at once, most severe first — errors, then alerts,
- then warnings, then informational lines — so being offline, viewing an
- unverified publisher, and an expired sign-in can all be reported
- together instead of first-match-wins. Errors and alerts are both red and
- keep different glyphs: an error is the extension failing at its job, an
- alert is a serious answer it arrived at, like a block you made.
-- Subscribe/unsubscribe writes `site.standard.graph.subscription` records to
- your own PDS. Nothing is stored server-side by this extension.
-- A publication owned by an account you have blocked is called out before you
- subscribe: red `X` badge, a red alert pill naming the account, and a
- Subscribe button that takes a second, reworded click. The check reads your own
- `app.bsky.graph.block` records, which are public records in your repo, so it
- needs no extra OAuth scope and nothing about your blocks leaves the browser.
- Mutes are not readable this way and are not covered.
-- The account card for a blocked account does not show them: no avatar, no
- display name, no bio — just "Blocked user" and the handle, which is which
- account rather than how it presents itself. A block is a decision not to be
- shown someone, and this page is one the reader landed on rather than asked
- for. The publication itself is unaffected: its name, icon and description are
- the publication's, and the block is on the account.
-- "Open in " split button with a dropdown to pick another reader or
- set a default. Readers: Standard Reader (standard-reader.app, the default
- until you pick one), Leaflet, Anisota Reader, PDSls.
-- The reader list comes from Aturi's Waypoints catalog
- (`@aturi.to/waypoints`), a maintained inventory of Atmosphere clients, so
- a new reader arrives on a dependency bump rather than on somebody here
- noticing it. Two things the catalog does not supply stay ours: the icons
- (it ships none) and the proof. Its builders are one URL shape per client
- and a publication is not a document, so `src/lib/readers.ts` records what
- was actually opened against each live service, overrides the shapes that
- are wrong, and refuses to offer a catalogued reader nobody has checked —
- a test fails on one, rather than shipping a link into the popup.
-- "Share on Bluesky" next to the Open button hands the article's title and
- URL to Bluesky's compose intent (`https://bsky.app/intent/compose`) in a
- new tab. Nothing is posted from the extension: no post scope, no session
- needed, and the reader edits and sends it themselves. Bluesky builds the
- link card from the page's own Open Graph tags, which is why the text is a
- title and a URL rather than a record embed — see `src/lib/share.ts`.
-- How long a read keeps depends on whose data it is (`src/lib/cache.ts`).
- Your own repo, where this extension also writes — subscriptions, blocks,
- labeler subscriptions — keeps for an hour and is dropped the moment it
- writes, so subscribing never leaves the popup insisting you have not.
- Anything about somebody else's publication, profile or labels keeps for a
- day, since nothing you do changes it. Both live in session storage, which
- the browser clears when it closes: nothing about which publications you
- visited is written to disk.
-- Your follow list is the exception, and keeps for a day on disk. Nothing
- here creates or deletes a follow, so a stale set cannot hide something you
- did; and reading it is one request per hundred follows against your own
- PDS, up to a cap of 10,000 — a hundred requests. That is not a walk to
- repeat every time the browser restarts. It is the one value allowed on
- disk because of what it is not: a
- follow list is your own public social graph, and unlike the rest it records
- nothing about where you have been. The walk runs in the background worker,
- starting when you sign in, so no popup ever waits on it — the subscriber
- count draws first and the faces appear when it lands.
-- The refresh icon next to the Open button re-fetches everything, since
- records can change remotely. Refresh bypasses every cache in the path,
- including the well-known probes — a publisher correcting their site sees
- it immediately rather than at the next TTL expiry.
-- Well-known probe results are cached by outcome: five minutes for a hit
- (this origin *is* a publication, so freshness is what matters) and an hour
- for a miss (the answer nearly every site gives, where re-asking costs a
- request to a stranger and changes almost nothing).
-- All outbound requests go through `src/lib/http.ts`: a per-attempt timeout,
- `credentials: 'omit'`, a body-size cap, and retries narrow enough that a
- 404 — the usual well-known answer — is never retried. Only timeouts,
- transport errors and 408/425/429/5xx get a second attempt, with
- full-jitter exponential backoff and `Retry-After` honoured up to a minute.
-- "Send feedback" in the popup footer opens a report form: a title, optional
- details, and the categories the feedback board itself lists. Sending writes
- an `app.userinput.discussion` record to your own PDS, pointing at
- substandard's space on [userinput.app](https://userinput.app/s/did:plc:jlle5fhgsrzlqybnpfysavg4/3mst6ruen3l2n)
- — public, yours, and deletable from there. Still no backend: nothing of
- ours receives the text.
-- Next to it, "Updates" is a plain link to
- [substandard.blog/posts](https://substandard.blog/posts), opened in a new
- tab. No JavaScript behind it and no state in the popup.
-- The first install opens a welcome page (`welcome.html`, dressed as
- substandard.blog), and its whole job is the toolbar pin. Chrome keeps a new
- extension's icon behind the puzzle piece, and the badge — the passive half
- of what this extension does — is invisible until the icon is out. No
- manifest field and no API can ask for a pin; the only thing Chrome offers is
- `chrome.action.getUserSettings()` to read whether it happened (no extra
- permission). So the page asks in words, draws where the pin is, and polls
- that read, which is what makes the line under the picture answer by itself
- the moment somebody pins the icon. It opens on `install` only — an update
- that reopened it would be a tab spawner, not a nudge — so the dev build's
- debug menu is the only way to see it again (see Install).
-- That page's picture is a drawing rather than a capture: Chrome's toolbar and
- its extensions menu are native UI and do not render into a page screenshot,
- the same reason `scripts/capture-status-docs.mjs` draws its badge tiles. Its
- words are a human's. `scripts/deploy-ext.sh` still refuses to package a
- build carrying the `SNICKERSNEE` placeholder token, so a later rewrite
- cannot ship one.
+### Chrome Web Store
-## Install
+You can install substandard from the [Chrome Web Store](https://chromewebstore.google.com/detail/substandard/mecbfognmmefgjekidnddjjlddnfnlki?authuser=0&hl=en). This is the best option for most users.
+
+### Local
+
+You can also build the extension locally:
```sh
npm install
npm run package # build + verify dist/ is loadable
```
-Then in Chrome: `chrome://extensions` → enable Developer mode → Load unpacked →
-select the `dist/` directory.
-
-The manifest declares `minimum_chrome_version: "110"`, so the store does not
-offer the extension to browsers where it would half-work. The floor is
-`chrome.action.setBadgeTextColor` (Chrome 110), which `setBadge` in
-`src/background.ts` calls for every visible badge state inside a `catch`-all,
-so on an older Chrome the badge fails silently. Next below it is
-`chrome.offscreen` (Chrome 109), without which sign-in cannot run at all.
-Everything else the extension calls is older. Raise the floor when a newer API
-is adopted.
-
-One API resisted being pinned to a version, and sign-in no longer depends on
-the answer. `chrome.offscreen.hasDocument()` is annotated *Chrome 150+* in the
-published reference, far above this floor, but that is when it was documented
-rather than when it became callable: it shipped with the offscreen API in 109
-and stayed `[nodoc]` in the Chromium IDL because a per-extension existence
-check does not survive multiple offscreen documents
-([crbug.com/1339382](https://crbug.com/1339382)), which has also put it up for
-removal. Verified callable and correct on Chrome 149. `src/lib/offscreen.ts`
-therefore prefers the documented `chrome.runtime.getContexts()` (Chrome 116),
-falls back to `hasDocument()` for the 110-115 window, and finally lets
-`createDocument`'s own "only a single offscreen document" failure answer the
-question — so no Chrome version can break sign-in over it.
-
-The manifest pins the *unpacked* extension ID to
-`degljbilkggdpbobomfbgnellecgbkjj` via the `key` field, so the dev OAuth
-redirect URI stays stable across machines. The matching private key
-(`key.pem`) is not needed for unpacked loading and stays out of git. The
-Chrome Web Store rejects a manifest with a `key`, so the packaged release
-drops it and the published extension has a store-assigned ID; both IDs'
-`chromiumapp.org` redirect URIs must be listed in
-`oauth/client-metadata.json`.
-
-Both IDs are declared in `oauth/extension-ids.json`, and
-`scripts/check-oauth-metadata.mjs` enforces the invariant that broke sign-in
-in v1.2.1: the extension *bundles* the client metadata and validates its own
-runtime redirect URI against it, so metadata that lists only the unpacked ID
-makes every store install throw `Invalid redirect_uri` before the
-authorization request is pushed — while the hosted metadata, the OAuth
-server, and every other check still look correct. The script derives the
-unpacked ID from the manifest `key` rather than trusting the declaration, so
-rotating the key without updating the metadata fails too. It runs in three
-places: as a `prek` hook on any change to the metadata, the manifest or
-`authflow.ts`; against the hosted copy at `client_id` in `deploy-ext.sh`'s
-preflight; and against the built `dist/` before packaging, which is the only
-check that looks at the artifact actually being uploaded.
-
-Because the two IDs differ, an unpacked build and the store build can be
-installed in the same browser at once. To tell them apart, build the dev
-channel:
+Or if you want developer mode turned on (inverted color icon):
```sh
-npm run package:dev # build:dev + verify dist/ is loadable
+npm install
+npm run package:dev
```
-`npm run build:dev` is the build on its own, and `npm run dev` is the dev
-channel in watch mode.
-
-It inverts the icon PNGs in `dist/` and appends ` (dev)` to the extension
-name, so the toolbar tile, the popup header, and the `chrome://extensions`
-row all say which install you are looking at. It also stamps the commit into
-`version_name` — `1.4.0+2e9cb0e`, or `1.4.0+2e9cb0e-dirty` when the tree has
-uncommitted changes — which is what the popup footer and
-`chrome://extensions` then show, so a reloaded install says which build it is
-running. `version` itself stays a plain dotted number, the only thing Chrome
-accepts there.
-
-Nothing else changes — same ID, same key, same OAuth redirect URI. `npm run
-build` and the release packaging are unaffected: they emit no `version_name`,
-so the store popup shows the bare version, and the inverted tiles live in
-`brand/icons-dev/` and never reach `public/`. See `scripts/dev-channel.mjs`.
-
-That name suffix is also what the running extension reads to know which build
-it is (`isDevChannel` in `src/lib/channel.ts`, asserted against the build
-script's own constant), and a dev build shows one thing a release build does
-not: a **Debug** menu at the right of the popup footer. It holds the dev-only
-tools, which today means opening the welcome page — Chrome shows that page
-once per install and there is otherwise no way back to it short of
-reinstalling the extension.
-
-## Sign in
-
-atproto OAuth requires the client to be identified by a publicly hosted
-metadata file. Ours lives at
-`https://substandard.blog/client-metadata.json` (see `oauth/` for the
-file and `infra/` for the hosting). The extension asks for
-`repo:site.standard.graph.subscription` — its own subscription records — plus
-`include:app.userinput.authBasic`, userinput.app's published permission set
-covering the feedback posts described above (discussions, replies, votes, and
-edits of your own posts; not the moderation collections in its `authFull`).
-Nothing else, and never `transition:generic`.
-
-Changing that list changes what the consent screen asks for, so the site has to
-be deployed before a build that asks for it — the guard above compares the
-hosted file to the bundled one field by field, scope included, and
-`deploy-ext.sh` will not package until they agree. Everyone who signed in under
-the old list keeps a session that cannot post feedback until they sign in
-again. Whether a session covers the write cannot be checked here: the
-authorization server expands `include:` into the set's own permissions
-before minting the token, so the granted scope never contains the string that
-was requested. The write is attempted, and the PDS is what says no
-(`src/lib/feedback.ts`).
-
-The popup header also shows the running version next to the wordmark, read
-from the manifest at runtime (`chrome.runtime.getManifest()`) rather than
-baked into the bundle, so it is what Chrome installed and not what some build
-step believed. The capture script asserts it matches `dist/manifest.json`,
-since the header is in every screenshot in `docs/img/`.
-
-Account controls live in the popup header: a Sign in button when signed out,
-and your avatar and handle when signed in, opening a menu with Switch account
-and Sign out. The Sign in button expands into the handle form and then
-submits it — it is the form's only button. Switch account opens that same
-form without signing you out: the current login stays valid until the
-replacement sign-in completes, and only then is it revoked. Abandoning the
-switch leaves you signed in as before. A session that ends on its own
-(expired or remotely revoked refresh token) is flagged once in that panel,
-so being signed out always has an explanation.
-
-Sign-in starts from that form: enter your handle and a small consent window
-for your PDS opens — no other UI. The popup cannot host the flow itself (it
-closes when the consent window takes focus) and neither can the worker (the
-OAuth client needs DOM storage), so the worker runs the flow through an
-invisible offscreen document: it builds the authorization URL there, opens
-the consent window, watches its tab for the redirect back to the
-`chromiumapp.org` URL (the host never resolves; only the navigation attempt
-matters), then hands the redirect back to the offscreen document for the
-token exchange and closes both.
-
-Both halves name the redirect URI of the ID the build is actually running
-under (`chrome.runtime.id`), because the OAuth server checks the exchange
-against what the authorization request registered. The client's own default
-for each half is `redirect_uris[0]` in the metadata — the store ID — so
-leaving either to the default breaks sign-in for every install that is not
-the store one, and only at the exchange (`src/lib/oauth.ts`,
-`src/lib/oauth.test.ts`).
-
-Sessions persist in IndexedDB with a non-extractable DPoP key — the same model
-`@atproto/oauth-client-browser` uses for web apps — so you stay signed in
-across browser restarts without re-consenting.
-
-## Hosting the site and client metadata
-
-`infra/` contains OpenTofu for a fresh AWS account: a Route53 zone for
-`substandard.blog`, an ACM certificate, and a private S3 bucket fronted by
-CloudFront. The bucket holds immutable release trees under `releases//`
-— the Astro-built frontpage from `web/dist/` plus the metadata JSON served at
-`/client-metadata.json` — and CloudFront's `origin_path` points at exactly
-one of them, so a deploy is an atomic pointer flip and a rollback is pointing
-back at an earlier tree. The frontpage copy is a human's now; the
-`PLACEHOLDER-*` strings it once carried are gone. The site also serves
-`/privacy`, the privacy policy the Chrome Web Store listing points at;
-`web/scripts/verify-dist.mjs` fails a build that drops the page.
-
-Because the bucket is private behind an origin access control, S3 answers an
-address that is not in the release tree with `403 AccessDenied` rather than
-404, so an unmapped miss reaches the reader as raw XML. `custom_error_response`
-blocks in `infra/main.tf` map both 403 and 404 to the built `404.html` —
-`web/src/pages/404.astro`, which Astro emits at the top level even though every
-other page builds as a directory — and answer 404. Only a key that is not there
-takes that path, so real objects, `/client-metadata.json` included, are
-untouched. `verify-dist.mjs` reads `response_page_path` back out of the
-terraform and fails a build that does not ship it. That page's copy is still
-`PLACEHOLDER-*`, waiting on a human — it is the one page on the site that is.
-
-The frontpage (and only the frontpage — not the extension) reports analytics
-to an EU-hosted PostHog project via `web/src/components/PostHog.astro`.
-Tracking is cookieless (`persistence: "memory"`), and the snippet is only
-included in production builds, so `astro dev` traffic never reaches PostHog
-(`astro preview` serves the production build, so it does).
-
-Deploy with `scripts/deploy-site.sh` (extra arguments are passed through to
-`tofu apply`). It checks AWS credentials, installs `web/`'s dependencies with
-`npm ci` from `web/package-lock.json` (same rule as `deploy-ext.sh`), re-syncs
-the homepage screenshots from `docs/img/` (`npm run shots`, so a deploy can
-never ship a screenshot of a popup the extension no longer has), builds
-the frontpage, uploads the build plus `oauth/client-metadata.json` to
-`s3://substandard.blog/releases//` — the short sha of `HEAD`, with a
-`-dirty` suffix if the tree has uncommitted changes — and patches the sha
-into `infra/terraform.tfvars.json` as `release_sha`
-(`scripts/patch-tfvars.mjs`). The `tofu apply` then only changes which tree
-CloudFront serves; afterwards the script waits for the distribution to
-finish deploying and invalidates the cache, so the flip is what makes a
-release live. Hashed assets under `_astro/` are uploaded with immutable
-long-cache headers. PDSes re-fetch the metadata when clients authenticate.
-
-CloudFront attaches a response headers policy to every response: HSTS (two
-years, subdomains included, not preloaded), `X-Content-Type-Options: nosniff`,
-`X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, a
-`Permissions-Policy` turning off features the site does not use, and a
-Content-Security-Policy. The CSP allows the inline PostHog snippet by SHA-256
-hash rather than `unsafe-inline`, so `web/scripts/verify-dist.mjs` recomputes
-that hash at build time and fails the build when it no longer matches
-`posthog_script_hash` in `infra/main.tf` — otherwise a changed snippet would
-be silently blocked in production and analytics would just stop. The policy
-was validated by serving `web/dist` with the exact header and loading `/`,
-`/privacy` and `/posts` in headless Chrome.
-
-On a fresh account the first run stops before uploading because the bucket
-does not exist yet: run `tofu -chdir=infra init` and `tofu -chdir=infra
-apply` to create the infra (`terraform.tfvars.json` is already written),
-then re-run the script. The first apply blocks on certificate validation
-until you copy the `zone_name_servers` output into the NS records at the
-registrar; once delegation propagates, the apply finishes on its own (or
-re-run it).
-
-To roll back, set `release_sha` in `infra/terraform.tfvars.json` to a
-previously uploaded sha and run `tofu -chdir=infra apply`, then invalidate
-the CloudFront cache. Release trees are never deleted by a deploy; prune old
-`releases/` prefixes manually when they are no longer rollback candidates.
+Then in Chrome: `chrome://extensions` → enable Developer mode → Load unpacked →
+select the `dist/` directory.
## Development
+Other local dev commands include:
+
```sh
npm run check # typecheck
-npm run build # popup/offscreen/background build + content-script IIFE build
-npm run build:dev # same build, dev channel (inverted icons, " (dev)" name, commit stamp)
-npm run verify:dist # check dist/manifest.json and that every referenced file exists
+npm run build # build the extension
+npm run build:dev # same build, dev channel
+npm run verify:dist # verify against manifest
npm run package # build + verify:dist in one step
npm run package:dev # build:dev + verify:dist in one step
-npm run dev # both builds in watch mode, dev channel; keeps dist/ loadable
-npm test # vitest unit tests (src/lib/*.test.ts, scripts/*.test.mjs; no network)
+npm run dev # dev channel in watch mode
+npm test # run unit tests
```
-`npm run dev` watches both build environments (`scripts/dev.mjs`). After an
-edit, reload the extension from `chrome://extensions`; the popup and content
-script pick up the new build on next open / page load. It builds the dev
-channel (see Install); `SUBSTANDARD_CHANNEL=release npm run dev` opts out.
+## Extension releases
-Cutting a release, in order. Each step is described in full below.
+Instructions for the maintainer.
1. `scripts/deploy-site.sh` — step 4 validates against the *hosted* client
metadata, so the site has to be current before it runs.
@@ -418,177 +62,13 @@ Cutting a release, in order. Each step is described in full below.
as `chore(release): v`.
3. `git tag -a v` and `git push --follow-tags` — `deploy-ext.sh`
requires the tag at HEAD and creates none itself.
-4. `scripts/deploy-ext.sh` — preflight, clean build, and the zip in `release/`.
-5. `npm run smoke`, then `npm run rc` — detection over the real network, then
- sign-in under the real store id.
+4. `scripts/deploy-ext.sh` to build the deployable package under `release`
+5. `npm run smoke`, then `npm run rc` to verify the package
6. Upload the zip by hand at the developer console URL step 4 prints.
-`scripts/deploy-ext.sh` packages a Chrome Web Store release: it refuses a dirty
-tree, installs dependencies with `npm ci` (the artifact is built from
-`package-lock.json`, not from whatever is in `node_modules`; the dirty-tree
-check runs first because `npm ci` wipes and reinstalls), runs the typecheck and
-tests, checks the OAuth client metadata against
-the hosted copy and against every declared extension ID, asserts `package.json` and
-`public/manifest.json` agree on the version, requires the release tag
-`v` to already exist and point at HEAD (the script makes no version
-decisions and creates no tags), does a clean build plus `verify:dist`, and
-zips the contents of `dist/` (manifest at the zip root, its `key` field
-stripped — the store rejects uploads that carry one; the release channel is
-forced and the archive is checked for a `(dev)` name) into
-`release/substandard-v.zip`. Tagging, pushing the tag, and uploading
-the zip are manual steps; the script ends by printing the developer console
-URL as a clickable terminal link (a bare URL when its output is piped).
-
-The release ships sourcemaps, and `deploy-ext.sh` fails if any of the five
-entry points' maps are missing. The store reviews minified code more slowly
-than readable code, and this extension's all-hosts grant already earns an
-in-depth review, so the maps are worth their size: a reviewer reads the
-TypeScript rather than the minified output.
-
-Only the source *text* of our own code ships. `sourcesContent` inlines every
-file in a chunk, and the OAuth chunk is mostly `@atproto` — its map alone is
-3.7 MB, which would bury ~2 kLOC of ours in megabytes of third-party source
-that is public on npm anyway. The `substandard:own-sourcemaps` plugin in
-`vite.config.ts` nulls the text of `node_modules` sources while leaving every
-mapping intact, so stack traces still resolve. Net effect on the upload:
-290 kB → 599 kB, against 974 kB for untrimmed maps.
-
-Two checks run against a built extension rather than the source, because the
-bug that shipped in v1.2.1 was in the wiring between modules that all had
-passing unit tests.
-
-`npm run smoke` (`scripts/smoke-test.mjs`) asks whether detection still works
-at all: it loads `dist/` into a headless Chrome and visits real publications
-over the real network, one per detection route — `standard.site` (no ``
-hint, found by the origin well-known), `atproto.com/blog` (a hint, plus a
-path-scoped well-known because the origin 404s), `permadeath.com` (both) — and
-`example.com` as the control that catches a build which "detects" everything.
-For each it asserts the publication, its verification, its DID and the icon
-state a real toolbar would show.
-
-Because the routes are separated, a failure says which one broke. Pointing the
-well-known probe at a path that does not exist fails the well-known-only site
-with "no publication detected" while the hint-carrying sites degrade to
-`signedout-unverified` — found, but unverified.
-
-It talks to the live internet, so it is not a `prek` hook and not part of
-`npm test`. Run it on the tree about to be packaged; `--headful` watches it
-happen.
-
-`npm run rc` (`scripts/rc-load.mjs`) is the other half, and asks whether
-sign-in works under the identity the build will actually run as. It takes the
-newest zip in `release/`, or one named on the command line.
-
-Loading `dist/` unpacked cannot catch a v1.2.1-class bug. Unpacked runs under
-the *dev* id pinned by the manifest `key`, and that id was in the bundled
-OAuth metadata all along — only the store id was missing, which is why
-sign-in was broken for every store user while every local check passed. The
-store id is a hash of the publisher's public key, and the store publishes
-that key inside every CRX it serves; `oauth/store-key.txt` holds it, checked
-against `oauth/extension-ids.json` before use. Stamping it into the zip's
-manifest makes Chrome compute the store id for a locally loaded build.
-
-The script then asks the build's own service worker to start an
-authorization with a deliberately unresolvable handle. The `redirect_uri`
-check in `@atproto/oauth-client` runs before any identity lookup, so
-`Invalid redirect_uri` means the artifact does not cover its own id (fail),
-while `Failed to resolve identity` means it got past that check (pass). Run
-against the real v1.2.1 artifact it reproduces the outage; against a current
-build it passes.
-
-`--interactive` opens a headful Chrome with the candidate installed and
-leaves it running, for a real sign-in against a real PDS. Both modes use a
-throwaway profile — the store copy of the extension cannot be installed
-alongside it, since they share an id.
-
-Lint hooks are configured in `prek.toml` ([prek](https://prek.j178.dev)):
-whitespace/EOF/JSON hygiene, the typecheck above, and `tofu fmt -check` for
-`infra/`. Run them all with `prek run --all-files`. Committing a change to a
-brand SVG or a render script also re-renders the derived assets (icon PNGs,
-Chrome Web Store tiles, the web favicon) in place; if that rewrites anything,
-the commit fails — stage the regenerated files and commit again.
-
-A `commit-msg` hook (`scripts/lint-commit-msg.mjs`) enforces Conventional
-Commit headers and changes nothing. `prek install` installs both stages.
-
-Version decisions happen at release time, not per commit: `npm run bump`
-(`scripts/version-bump.mjs`) scans the commits since the last version change
-and stages the highest bump they call for — a breaking change (`!` or a
-`BREAKING CHANGE:` footer) bumps major, `feat` minor, `fix`/`perf` patch —
-into `package.json`, `package-lock.json`, and `public/manifest.json`. Commit
-the staged bump (e.g. `chore(release): v`) and tag it `v`
-for `deploy-ext.sh`. Versioning used to run as a commit-msg hook, but bumping
-on every `feat`/`fix` rewrote the same three files in every branch and
-parallel worktrees kept conflicting on them.
-
-Layout:
+## Privacy policy
-- `src/background.ts` — service worker: per-tab detection state, toolbar
- badge states, public reads (records, subscription list)
-- `src/signin.ts` — worker half of sign-in: consent window, redirect
- interception, offscreen document lifecycle
-- `src/content.ts` — reads head link tags, reports to the worker
-- `src/lib/detection.ts` — well-known probing and verification
-- `src/lib/icon.ts` — state and badge mapping
-- `src/lib/http.ts` — the one place outbound requests are made: timeouts,
- narrow retries with backoff, body-size caps
-- `src/lib/atproto.ts` — DID/handle resolution, public record fetches
-- `src/lib/feedback.ts` — the userinput.app board (its at-uri, its links, the
- discussion record and the lexicon's limits on it)
-- `src/lib/session-store.ts` — the session mirror in chrome.storage.local;
- the worker writes it at sign-in (the offscreen document has no
- chrome.storage)
-- `src/lib/oauth.ts` — OAuth client; DOM contexts only, never the worker
- (the browser OAuth client needs `window`/`localStorage`)
-- `src/lib/authflow.ts` — pure sign-in helpers (redirect URI, redirect
- matching)
-- `src/popup/` — popup UI; PDS writes happen here via the restored session
-- `offscreen.html`, `src/offscreen/` — invisible OAuth host during sign-in
-- `oauth/client-metadata.json` — the hosted OAuth client identity
-- `web/` — Astro project for the `substandard.blog` frontpage;
- `npm --prefix web run dev` to serve it, `npm --prefix web run build`
- to emit `web/dist/`. The homepage shows the same popup captures the store
- listing screenshot is composed from, framed by the site's own CSS (`.shot`
- in `web/src/styles/global.css`) rather than by anything baked into the
- images. It is also a standard.site publication: updates are
- markdown files in `web/src/content/posts/`, previewed on the homepage,
- listed at `/posts`, served in full at `/posts/`, and syndicated at
- `/atom.xml`. After a deploy, `web/scripts/publish.sh` writes one
- `site.standard.document` record per post (config in `web/sequoia.json`)
- and writes each record's at:// URI back into the post's frontmatter,
- which renders as a `rel="site.standard.document"` link tag. `/architecture`
- is the audit page: the extension's contexts, the calls each one makes, and
- what is stored where, around a diagram whose source is
- `web/src/diagrams/architecture.mmd`. The site's CSP has no `unsafe-inline`,
- so Mermaid cannot run in the browser; `npm --prefix web run diagram` renders
- it to `architecture.svg` in a local Chrome and strips the CSS the CSP would
- drop, leaving the page's own `.diagram` rules in `global.css` to supply the
- colors and the light/dark theme. Editing either the `.mmd` or the render
- script re-renders the SVG in place, like the brand assets below. Tests:
- `npm --prefix web test`.
-- `infra/` — OpenTofu for the frontpage and metadata hosting
-- `brand/` — canonical logo SVGs (`mark.svg`, `sticker.svg`, plus
- `sticker-round.svg` for avatar slots that crop uploads to a circle); the
- icon PNGs in `public/icons/`, `web/public/icons/`, and `brand/` are
- rendered from the stickers with `npm run icons` (needs a local Chrome;
- set `CHROME` to pick one), the
- snapped wordmark (`wordmark.svg` plus PNGs at a few widths under
- `brand/wordmark/`) with `npm run wordmark`, the Chrome Web Store
- listing tiles in `brand/cws/` with `npm run cws` (the promo tiles, plus a
- 1280×800 listing screenshot per `store` capture in `scripts/shots.mjs`,
- composed from the committed popup captures in `docs/img/`), and the
- frontpage's
- social card `web/public/og.png` with `npm run og` (the last two nest the
- generated wordmark, so run `wordmark` first when brand sources change);
- `npm run shots` then copies the popup captures the store listing and the
- homepage gallery share (`scripts/shots.mjs`) into `web/public/shots/` and
- writes their sizes to `web/src/shots.json`;
- `npm run assets` runs all five in that order. The popup captures those two
- read from are not rendered from art: they are screenshots of the built
- extension, re-shot by `npm run capture` (build included, needs a local Chrome
- and the network), and `npm run recapture` is that followed by `npm run assets`
- — one command for new screenshots everywhere they appear. See
- [docs/status-states.md](docs/status-states.md).
+See [substandard.blog/privacy](https://substandard.blog/privacy).
## License