# TODO ## Fewer well-known probes, without losing detection The extension probes `/.well-known/site.standard.publication` on every origin the user visits. Two known costs, both measured rather than assumed: - **Gating the probe on a `` hint is not safe today.** Checked live on 2026-08-12: `standard.site` itself serves the well-known (200) and emits *no* link tag at all, so hint-gating would stop detecting the flagship publication. `atproto.com/blog` is the mirror case — it emits a link tag and 404s the origin well-known, answering only on the path-suffixed probe. `permadeath.com` has both. Any narrowing has to survive all three shapes. Revisit if the standard.site spec ever makes the link tag mandatory. - **Path probes multiply on multi-tenant hosts.** On an origin with no publication, each distinct *first path segment* earns its own probe (see the test in `src/lib/detection.test.ts`), so browsing a large host produces one probe per tenant visited. Capping probes per origin per session would fix it at the cost of false negatives for publications under a rarely-visited path. Not attempted here: skipping loopback and private hosts. Local dev of a publication depends on those probes working. ## What the blocked-publisher warning cannot see The warning reads `app.bsky.graph.block` from the signed-in user's own repo (`getBlocks` in `src/background.ts`), which is public data and needs no extra OAuth scope. Three gaps, all of them false negatives: - **Mutes.** Not records at all; they live on the appview behind `app.bsky.graph.getMutes`, which needs an authenticated appview call this extension's token cannot make (its scope covers repo writes, not service proxying). Nothing to do here without asking for a wider scope. - **Moderation lists.** Blocking via a list is an `app.bsky.graph.listblock` record pointing at a list; deciding whether a publisher is in one means fetching every subscribed list's members, which is an appview read too. - **Very large block lists.** `listRecords` stops at 2000 records, so an account with more blocks than that can visit a blocked publisher and see no warning. Fixable with a paged scan that stops at the first match, at the cost of scanning on every miss. Also unbuilt on purpose: a warning for the reverse direction (the publisher blocked *you*). Their block records are public, but reading them means listing a stranger's whole block collection on every publication visited. ## Nudge users to pin the toolbar icon The badge is invisible until the user pins the extension, and Chrome offers no manifest field or API to request pinning — only detection, via `chrome.action.getUserSettings()` (`isOnToolbar`, no extra permission). Unpinned, everything still works from the puzzle menu; what's lost is the passive badge, which is the product's pitch. The onboarding page is built: a bundled `welcome.html` — not a trip to substandard.blog, so it works before the first page load and can read the pin state — opened by the worker on `chrome.runtime.onInstalled` with `reason === "install"`. It draws the extensions menu, and polls `getUserSettings` so the line under the picture answers the moment the icon is pinned. The dev build's popup footer carries a debug menu that reopens it, since Chrome otherwise shows it exactly once per install. Its words have since been written by a human. `scripts/deploy-ext.sh` still refuses a build carrying the `SNICKERSNEE` placeholder token, now a guard against a later rewrite rather than a gate on unwritten copy. Two things are still open: - **Its picture is a drawing, not a capture.** Chrome's toolbar and its extensions menu are native UI and never appear in a page screenshot — the same reason `scripts/capture-status-docs.mjs` draws its badge tiles rather than shooting a toolbar — so the SVG in `welcome.html` is a hand-drawn stand-in. Replacing it means a capture taken by hand. - **The popup pill is unbuilt**: an informational pill in the existing `src/lib/status.ts` stack while unpinned ("pin to see the badge as you browse" — wording is a human's call). Zero new UI machinery, but it only reaches users who already found the popup. ## Recapture now that substandard.blog is a publication The listing is live and the publication record is written, so both halves of what this was waiting on are done: - Recapture the popup screenshots, so the store screenshots show our own record. Every capture of a real publication uses a real one (`REAL` in `scripts/capture-status-docs.mjs`); the one deliberate fixture is the moderated card (`BAD_BLOG`), which stays invented — nobody's real publication is going to model "blocked and labeled". The homepage gallery shows the same captures (`scripts/shots.mjs`), so `npm run recapture` is the whole pass — shots, store tiles and frontpage copies together. - Maybe: wire the CWS Upload API (`chrome-webstore-upload-cli`) into the release flow — the upload endpoint is the only authoritative validator (it rejects things no local tool checks, like a manifest `key`). ## Sessions granted before the current scope list The hosted metadata already carries `include:app.userinput.authBasic` and `rpc:app.bsky.actor.getPreferences?aud=*`, so the deploy-before-release ordering this entry used to describe is done and `check-oauth-metadata.mjs --hosted` passes. What is left is the other half: - Sessions granted under the old list cannot post; the panel says so when the PDS refuses the write. Nothing prompts for re-consent on its own — decide whether that is worth a status pill once there are users with old sessions. The labeler list fails more quietly on the same session: it falls back to the local list and logs, because a label is advisory and a wrong-looking set of labels is better than none. If the status pill happens, this is a second reason for it. Deliberately not built: image attachments. The lexicon takes up to four (`app.userinput.discussion` `#image`, 1 MB each), and a screenshot of the badge would be the obvious one, but it needs `blob:image/png` on top of the permission set, a capture path, and a preview in a 400px popup. ## Show a pub's post count in the popup When the popup shows a detected publication, also show how many documents it has. ATProto has no count endpoint (`listRecords` has no total field), so use Constellation, microcosm's free public backlink index, instead of paginating the whole collection: ``` GET https://constellation.microcosm.blue/links/count ?target= &collection=site.standard.document &path=.site ``` The subscriber row (`src/lib/subscribers.ts`) already talks to Constellation and already handles the two at-uri forms; the count should reuse it rather than open a second client. Details that matter: - Documents in the wild write the `site` field in both handle form (`at://example.com/...`) and DID form (`at://did:plc:.../...`), and Constellation matches target strings exactly. Query both forms and sum — `subscriptionTargets` in `src/lib/subscribers.ts` already builds the pair for the same reason. Spot-checked on `permadeath.com`'s publication (2026-08-12): 10 in DID form, 0 in handle form, which matches its exact `listRecords` count. A publication whose well-known answers in handle form splits the other way, which is what the sum is for. - Responses are instant and unauthenticated. Cache per pub with a TTL like the existing DID cache in `src/lib/atproto.ts`; counts change slowly. - Semantics are network-wide: a document in someone else's repo pointing at the pub counts too. For standard.site pubs today all documents live in the owner's repo, so the numbers agree. - Constellation is a third-party dependency. If it is down, hide the count rather than falling back to pagination. ## Check the store listing's Privacy practices tab against reality The listing is live, and CWS enforces the data-use declarations on it. Confirm the dashboard says what the extension actually does: broad host permissions with a justification, "Website content" handling, and no collection (there is no backend). A mismatch between the declared practices and observable behaviour is a takedown risk, not a paperwork one. The privacy policy itself now describes the per-site check and the ecosystem lookups; this is the other copy of those claims, and the two should not drift apart. There is now a third copy: `/architecture` names each caller, each host and each stored item, and is the version a reviewer can check against the source. It is the most specific of the three, so it is the one that goes stale first — when a network call is added or removed, `web/src/diagrams/architecture.mmd` and the tables in `web/src/pages/architecture.astro` need the same edit. ## Decide what to do about the 843 kB popup chunk `dist/chunks/oauth-*.js` is 843 kB (169 kB gzipped) against a 34 kB popup, and `src/popup/popup.ts` imports it statically, so it is parsed before first paint even for signed-out users who will never authorize anything. Two independent levers, either worth taking alone: - Import it dynamically on the signed-in and subscribe paths, so the signed-out popup never pays for it. - `core-js` ships inside that chunk — it is a dependency of `@atproto/oauth-client-browser` (still there in 0.5.3). It is dead weight in an MV3 extension, which only ever runs on a modern Chrome. Check whether a vite alias to a stub is safe. Measure before and after; the numbers above are from `npx vite build` after the 0.20 / 0.5 upgrade, which took the chunk down from 1,131 kB on its own. ## No CI Nothing runs `npm run check`, `npm test`, `verify:dist` or `check-oauth-metadata` except a human at a terminal. The prek hooks cover a committer who has them installed and nothing else. Needs a decision about where CI would run for a Tangled-hosted repo before it is worth wiring. ## Subscriber row: two caps that can hide a face `src/lib/subscribers.ts` answers "who that you follow subscribes here" by intersecting two lists, and both are capped: - Constellation is paged to `SCAN_CAP` (500) subscriber DIDs. The count stays exact past that — it comes from the index's own total — but a followed subscriber past the cap is not drawn. `truncated` records this and the row says so in its tooltip. - The follow set stops at `FOLLOWS_CAP` (10,000 records, so a hundred requests). `truncated` records this too, and the row's tooltip names whichever cap could be hiding a face. The first cap binds on a publication and has not bound on any measured so far (the largest had 53 subscribers). The second binds on the *reader*, and costs more than a face: the owner card's "Following" line reads the same set, so a reader past the cap can be told they do not follow an account they followed early. Both now say when they bit, which is the part that used to be invisible; neither is fixed. 10,000 is bounded by the MV3 worker, not by the PDS. The walk is once a day and off every render path (`graph` in `src/lib/cache.ts`), so the cost is fine, but a service worker only stays up while its event is being handled and a walk killed mid-flight caches nothing to show for it. Covering the tail above 10,000 therefore wants a walk that can resume from a stored cursor across worker lifetimes, not a bigger constant. The other direction is to stop holding the whole set and ask about the one account in question — Constellation, or the reader's own repo by rkey. That answers the owner card directly and makes its cap irrelevant; the subscriber row would need the reverse question, which of a publication's subscribers the reader follows. ## Let the user manage their labelers `src/lib/labels.ts` reads the labeler list from `chrome.storage.local` (`labelers`, an array of DIDs) and falls back to Bluesky's moderation service. Nothing in the UI writes that key, so today the list is the default or whatever was set from the console. What is missing, cheapest first: - A labeler section in the popup: the current list, a handle box to add one (`resolveHandleToDid` then `resolveLabelerEndpoint`, which fails for an account that is not a labeler), and a remove control. The popup is 400px wide and already carries an account menu, a reader menu and a feedback panel, so this probably belongs behind its own disclosure rather than in the card. - Per-label settings. Bluesky lets a user set each label value to ignore/warn/hide; substandard applies the labeler's default. Honouring an override means storing a per-labeler, per-value map and threading it through `viewLabel`, which is where `hides` is decided. - A way to add a labeler for substandard alone. The subscription list is now read from the account's Bluesky preferences, which is the right default — but a labeler you want here and not in Bluesky has nowhere to live except the `labelers` storage key, which nothing writes. ## Share to somewhere other than Bluesky The share button (`src/lib/share.ts`) hands the article to `bsky.app/intent/compose`, which is the only compose intent in the Atmosphere that is documented and stable. Two directions if that stops being enough: - Other clients. Aturi's `@aturi.to/waypoints` catalogs 28 Atmosphere clients with per-client URL builders, but every one of them is a *view* builder — there is no compose or intent field in the catalog — so "post from the client I actually use" cannot be answered from it today. Worth revisiting when the announced `to.aturi.*` preference lexicon lands. - Posting the record instead of the link. `app.bsky.embed.record` takes a strongRef to any record, so a post could point at the `site.standard.document` itself and be countable by Constellation. The Bluesky appview hydrates only record types it knows, so it would render as a not-found quote there; this only becomes attractive if a reader app that renders standard.site quotes gets popular enough to post from. ## Waypoints: what to send upstream, and what to watch `src/lib/readers.ts` takes its reader list from `@aturi.to/waypoints` and overrides the URL shapes that turned out to be wrong. Three follow-ups, none of them ours alone: - **Report the publication-vs-document gap.** The catalog has one URL builder per client keyed on the collection prefix `site.standard.`, so a `site.standard.publication` gets the article route: Standard Reader renders a blank "Article" page and Leaflet renders the account's profile. Both have a real publication route (`/p/{did}/{rkey}`, `/lish/{did}/{rkey}`), which is what the overrides here use. Worth a report at github.com/aturi-to/aturi/issues, after which the overrides can go. - **Report offprint.app and pckt.blog.** Both are catalogued under `publications` with `redirectCompat: ['standard-site']`, and both 404 on the URLs the catalog builds, in DID and handle form, for publications and documents (checked 2026-08-12). Either the shape changed or those clients do not render external standard.site records. - **Watch for the preference lexicon.** Aturi has announced a `to.aturi.*` record for a user's client preferences, readable by any app. Nothing resolves yet (no `_lexicon.aturi.to` TXT record as of 2026-08-12). If it lands, the popup's stored `defaultReader` becomes a local mirror of something portable, and "open in the reader I use everywhere else" stops being a per-app setting. The package is pinned exactly (`0.1.2`, no caret) because it is a `0.x` beta whose author says minors may break, and because it is a new dependency inside the popup. A bump is a review, not a lockfile refresh: `catalogGaps()` and its test exist to make that review land on a person. ## Finish the popup's card split `src/popup/cards/` holds the three cards that fetch — labels, owner, subscribers — behind the contract in `card.ts`, and `popup.ts` is the registry plus everything that has not moved yet: the account menu, the status pills, the subscribe button, the reader split, the share button and the feedback panel. Those are the parts that never conflicted, so they were left alone; moving them is worth doing when one of them next grows, not before. Two smaller things the split leaves open: - Each card still reaches into `popup.html` by id. A card that owned its own markup (a `