A dame.is & pds.dad production bsky38.com

Series of bug fixes #64

Merged pds.dad
targeting main
Merged opened by and assigned to pds.dad
targeting main

The list used to jump 7d -> 14d -> 30d as each window landed. Selecting the shallowest finished window (7d, almost always) instead keeps the chip stable while deeper windows keep loading in the background; chosen still overrides once the reader picks a chip. deepest is now unused and removed.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

Redirect /profile/{handle} to the canonical did URL

/profile/{handle}, optionally @-prefixed, now resolves through Slingshot and 302s to /profile/did:... so there is one canonical URL per account; anything that isn't a did and doesn't resolve as a handle is still a 404.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

Close typeahead on exact handle match, cap results at five

HandleInput now auto-selects when the fetched suggestions contain an exact handle match (case-insensitive, @-stripped), calling the existing select() so both callers' onselect fires - on /vote that's what makes the nominee card appear without a click. A lastSelectedDid guard stops the same account re-selecting on every keystroke-driven fetch, and resets once the typed value stops matching so retyping the same handle still selects. A derived showList (open && suggestions.length > 0 && exact === undefined) now gates the listbox render, aria-expanded, aria-activedescendant, and keyboard handling, so focus/arrow keys can't act on a hidden list. Typeahead limit dropped from 8 to 5.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

Reset lastSelectedDid on every edit, not just non-matching fetches

Fixes a review finding on the previous commit: lastSelectedDid only reset when a fetch resolved with no exact match, never when onInput's clear branch (trimmed.length < 2) fired, so it could survive a fast clear that skipped straight past a fetch. Retyping the same handle right after would then find exact.did === lastSelectedDid and silently skip select(), so onselect never refired - on /vote the nominee card would not reappear.

onInput now resets lastSelectedDid on every real edit (including the clear branch), and select() itself sets lastSelectedDid, so manual suggestion clicks/Enter keep the guard consistent too - it no longer needs its own reset in fetchSuggestions' non-matching branch.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

Fall back to the Bluesky bio when there is no campaign blurb

A nominee who never wrote a campaign profile had nothing under their name — on the page, in the social card, and in getProfile. The bio they already wrote on Bluesky is the obvious thing to put there.

actors gains a description column, filled by the Slingshot refresher from the app.bsky.actor.profile record it already fetches for the display name and avatar. getProfile gains a top-level description that resolves the precedence once, server-side: the campaign blurb when one is set and visible, else the bio. That mirrors top-level avatar, and it keeps profile.description meaning what it has always meant — "a campaign profile is indexed" — so the editor still round-trips only the record. The three display readers (profile page, profileSeo, the OG route) now take the resolved field and stop choosing for themselves.

The migration blanks fetched_at so every cached actor is refetched by the refresher (100 a minute) and bios appear within the hour rather than trickling in over the 24h staleness window.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

Give the account square a face

The header showed the signed-in account as a single letter, which is the one place in the app that never showed who you are. It now shows the campaign avatar when the repo has one and the Bluesky avatar otherwise, falling back to today's initial only when there is neither — or when the image breaks, which Avatar has always handled.

Both sources are the browser's own, so nothing here waits on the indexer: myCampaign reads the user's repo (and the layout now refreshes it on every signed-in load, not only when the editor opens), while auth.bskyAvatar asks Slingshot for app.bsky.actor.profile — the same getRecord the server's profile cache makes, asked again here because the signed-in account need not be in the pool and so need not have a cached row. Because save() sets mine, the square changes the instant a save lands, with no reload.

actors.ts's private avatarCidOf moves to blob-url.ts as blobCidOf, which is where the other blob helpers live and the only place both halves can import from. It grows a third accepted shape while it is there: a parsed ref, where @atproto/lex has already turned ref into a Cid object rather than { $link }. That one is recognised structurally — an object whose string form could be a path segment — so a module the server bundle imports for URL building does not take on @atproto/lex to name a class.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

Ignore the Playwright MCP screenshot directory

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

Say "log in again" before a stale grant refuses the write

The campaign editor stays open after Save when the session's OAuth grant no longer covers what this build asks for. buildScope is derived from the lexicon $types, so commit 30c0a02 ("moved to the new domain", which renamed dev.wafflehouse.influential.* to com.bsky38.influential.*) invalidated every grant made before it: the PDS refuses putRecord, save() returns 'reauth-needed', and the form stays on screen behind a banner that blames a feature that has been there all along.

Compare the grant to the request at session restore instead. #resolvePds already reads the stored token set with getTokenInfo(false) and no request, so it now reads the scope out of the same answer and becomes #readTokenInfo; missingScopes does the comparison as the space-separated set that scope is. A session short of anything sets auth.scopeStale, which opens the editor with the re-login prompt and Save disabled, so nobody fills in a form only to lose it. The refused write stays as the backstop for a grant we could not read.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

Derive the re-login banner instead of snapshotting it

start() copied auth.scopeStale into reauthNeeded while Save's disabled read the flag live, and the flag lands late: #adopt sets status = 'authed' synchronously and only then dispatches the un-awaited token-set read, while the #edit deep link opens the editor one microtask after 'authed'. A stale-grant user arriving from /me would therefore get the form open, no banner, and a Save greyed out with nothing on screen to explain it — worse than the amber banner they had before.

One $derived for both controls, so they cannot disagree whenever the read settles. Also swaps the banner's straight apostrophe for the curly one the rest of the file uses.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

Stop gating Save on a heuristic; validate resolved handles are did-shaped

CampaignEditor disabled Save on auth.scopeStale as well as a real write refusal. scopeStale is an inference from the auth server's returned scope string; reauthNeeded is ground truth from the PDS itself. Gating Save on the inference removed the only path that could prove it wrong. Save's disabled now reads reauthNeeded alone; the banner still shows early on scopeStale.

resolveHandle (used by /profile/{handle}'s redirect) now rejects a body whose did isn't did-shaped instead of passing it straight to redirect() - an unvalidated string could loop the redirect (if it came back handle-shaped) or break the response header (a stray \r\n).

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

Document the bio cache, description precedence, and handle-redirect URL contract

README and CLAUDE.md hadn't caught up with the Bluesky-bio cache (actors.description), its campaign-first fallback in getProfile, the blobCidOf helper, src/lib/server/identity.ts, auth.scopeStale, or the /profile/{handle} redirect contract.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

removed did from profile drop down. don't think ppl care

Fill an uncached profile in from Slingshot in the browser

The profile cache only holds accounts the indexer has seen in a vote, as a voter or a subject. Every other did came back described by nothing at all, so /profile/<did> rendered the bare DID as the name, cut its initial from did:, offered no handle link and left the description slot empty.

src/lib/atproto/bsky-profile.ts asks Slingshot the same two questions the server's refresher asks — resolveMiniDoc for the bi-directionally verified handle, getRecord for the app.bsky.actor.profile display name, bio and avatar blob — in parallel, each tolerated on its own, never throwing. It is $env-free with an injectable fetch, so the URLs and every tolerance rule are unit tested.

The profile page fetches it on mount when the server gave it no handle (the practical signal that there is no usable actor row) and renders a shown view: the server's answer with the gaps filled in, so a campaign blurb still beats the Bluesky bio and a campaign avatar still beats the account's, exactly as nomineeProfile.ts decided. The fetch is aborted on unmount and a late answer is dropped when the page's did has changed, since the router reuses the component between profiles. Votes, rank, opt-out, the editor and top posts all keep reading the server's profile, and the <title> and OG card stay server-side — they still show the DID until a vote brings the account into the cache.

auth.svelte.ts made both of those requests already, one per resolver; it now makes one call to the shared helper, with the same fail-open behaviour and the same "a slow answer must not label a replaced session" guard.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_0171sjYAVQxreBZYG9xU2rhh

This pull request has no versions submitted yet.