# 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 ``, or DOM it builds) would stop the shared file from
being an edit point at all, which is the last one left.
- `world` entries live in `chrome.storage.session`, so their day-long TTL is
really "up to a day, within this browsing session". Moving them to
`storage.local` would make it literal, at the price of a list of the
publications you visited persisting on disk. One word in
`POLICIES` (`src/lib/cache.ts`), and a decision nobody has made.