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

Votes only #61

Merged pds.dad
targeting main from feature/VotesOnly
Merged opened by and assigned to pds.dad
targeting main from feature/VotesOnly

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

Drop the nomination lexicon and its OAuth scope

Votes-only Task 1: delete dev.wafflehouse.influential.nomination, strip nomination fields/values from defs, getStats, and getVoterStatus, and remove nominationRecord/NominationRecord from the lexicons.ts barrel and buildScope. pnpm check is expected to stay red in server/browser files that still reference the removed fields until later tasks.

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

Drop nominations from schema and squash migrations to init

Remove the nominations table and the recordCols() factory it shared with votes now that only voting remains; inline votes' columns directly and rename its subject/status index to vote_status_subject (status, subject) to match the WHERE status='counted' GROUP BY subject query shape. RECORD_STATUSES drops not_nominated, leaving the four values the vote lexicon now declares.

Since the product is pre-release, squash the six-migration history into a single drizzle/0000_init.sql covering the current ten-table schema.

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

Rank votes per author and drop the pool cascade

Pool membership is now "a did with at least one counted vote", so a vote's status depends only on its own author's rows. put/del each re-rank exactly one author in one synchronous transaction; inPool, votersOf, subjectsOf, cascade, the watch map, placeholderStatus, tableFor and maxFor are gone, along with RecordKind and maxNominations.

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

Rank the pool from votes alone across every read path

The pool CTE now groups votes once instead of unioning two record tables: rank ties on the vote count alone, and position — the gapless paging key — breaks those ties by the subject's earliest counted vote, then by did. That MIN(created_at) is a string compare, correct only because the indexer canonicalizes every stored createdAt, the same way stats.ts documents its MAX.

nominationCount is gone from RankedRow/SELECT_NOMINEE and so from the leaderboard and profile bodies; getStats drops participants and reads one aggregate over counted votes; getVoterStatus answers with votes only and takes { maxVotes }; the actor refresher's backfill union reads the two vote columns.

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

Wire config, XRPC, and Jetstream for votes-only, retire nominate OG/SEO copy

Drops maxNominations from config, .env.example, +layout.server.ts, and the getVoterStatus/getStats XRPC handlers. jetstream/start.ts no longer registers a nominationRecord commit handler; toRecordEvent/toDeleteEvent/onPut/onDel lose their kind parameter now that only votes flow through the indexer. Updates the site and profile OG cards and the site SEO description to votes-only copy.

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

Drop the collection axis from browser stores for votes-only

records.svelte.ts loses RecordKind, nominations, nominationsLeft, hasNominated, nominate(), and Limits.maxNominations; lastWrite is now { subject, handle? }, justFilled() and statusOf(uri) take no kind argument, and DidString derives from VoteRecord instead of NominationRecord. Guard messages become vote-specific ("You can't vote for yourself.", "You already voted for them.", "You have used all N of your votes. Remove one first.", "Couldn't read your existing votes. Reload and try again."). Test file rewritten first (TDD): mock bodies drop the nomination fields, NOMINATION_URI becomes VOTE_URI, and the exhausted-limit case is reached via configure({ maxVotes: 1, closed: false }) plus one existing vote.

engagement.svelte.ts drops the now-unused pool/checkPool/POOL_BATCH (the vote page's own pool lookup, going away in Task 8) and its getProfilesQuery import. optout.svelte.ts's getProfile failure copy becomes "Couldn't check whether you're a nominee ()."

pnpm test is fully green (543 passed, 1 pre-existing skip); pnpm check's 47 remaining errors are confined to six .svelte files for Tasks 7-9 to fix.

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

Fix fabricated RANK_LIMIT rationale in engagement.svelte.ts

The reworded comment from the previous commit claimed the 25-vs-fewer-shown headroom exists so a page has something left after filtering out a self-vote or already-voted-for account. That isn't how EngagementRecs works: it renders every ranked row with a rowAction and no filter/resolving step. The real reason is pagination, already documented in EngagementRecs.svelte:91-92 ("The store keeps more than any page shows, which is what 'Show more' walks") — reworded RANK_LIMIT's comment to match.

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

Retire the filed-nomination slip and cut nav down to three tabs

+layout.svelte drops the slip's state, effect and markup, collapses stackHeight/slipHeight into one stackBase, and publishes only --stack-h; every lastWrite?.kind === 'vote' becomes a plain undefined check now that MyRecords no longer distinguishes record kinds, and the mobile tab bar and desktop cluster carry Leaderboard/Vote/My ballot only. layout.css drops .mo-tuck and its lg override, and every comment that named the slip or a "nomination filing". Toast, StatusBadge, NomineeCard and BallotCard lose their nomination-shaped props, statuses and doc lines; NomineeCard gains an optional note for Task 8's zero-vote empty state. Icon gains a pencil glyph. Login copy now names the four votes-only permissions the site actually asks for.

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

Let NomineeCard's note open the tally row without a vote count

hasTally was gated on voteCount alone, so a note passed for an account outside the pool (voteCount undefined) never rendered — exactly the case Task 8 needs it for. hasTally now also opens on note being defined.

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

Merge the nominate page into the vote page

One page, one card, one Vote button. The handle typeahead and its NomineeCard move here from /nominate, backed by a single abortable getProfile read: a pool member's card arrives with a rank and a tally, and anyone else gets the line saying the first vote is still going. The pool search goes — anyone on Bluesky can be voted for now, so searching the standings was the wrong way in.

EngagementRecs and FollowSampler lose their panel variants and their pick callbacks: both are list-only, both take a required rowAction, and the page hands each the same Vote snippet, so every suggested face votes in place. FollowSampler picks up the engagement list's row anatomy.

/nominate is deleted.

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

Move the campaign editor under the profile header and finish the votes-only copy

The profile page's action row becomes the single entry point into the campaign editor: on your own profile its primary slot is an "Edit campaign profile" button that calls the component's newly exported start() and scrolls the editor block — moved up under the header card, because it edits that card — into view. CampaignEditor loses its own collapsed-state button, and /profile/#edit opens the editor once the session settles, so /me can deep-link to it.

The badge row counts the vote you just cast, so voting for a stranger flips it from "No votes yet" to "1 vote" a firehose hop before the server agrees, and top posts now render for everyone — off the pool they say so rather than claiming a pull that was never queued. VoteState drops its self kind, which the editor branch made unreachable.

/me trades its nominations half for a "Campaign profile" tile pointing at that deep link, and the home page drops the nominate call to action, the participants tile and the per-row nomination column.

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

Let the opt-out badge beat a just-cast vote

The pool query excludes an opted-out account outright, so no vote can re-admit them — showing "1 vote" on their profile the moment you voted was a promise the leaderboard never keeps. The opt-out branch is now tested ahead of inPool || justVoted; the other two branches are unchanged.

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

Update README and CLAUDE.md for the votes-only model

Drop every reference to the removed nomination lexicon/record type: pool membership is now "a did with at least one counted vote," MAX_NOMINATIONS is gone, the status model has four values (no not_nominated), and the XRPC contract is eight queries plus setOptOut. Adds a Railway runbook for the one-time volume wipe this migration requires (migrations were squashed to a single 0000_init.sql) and fixes four leftover source comments that still described the old two-record-type world.

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

Pin the pool's tie key to MIN, and close RANKED over its one caller

The two tie tests seeded every subject's votes at a single instant, so MIN(created_at) and MAX(created_at) ordered them identically and neither test could have caught the key being swapped. Re-seeded so one subject's votes straddle the other's (:01/:09 against :02/:03): under MIN it leads, under MAX it trails. Verified RED by flipping the SQL to MAX — exactly those two tests failed — then restored MIN.

RANKED is imported nowhere outside pool.ts, so the doc paragraph claiming nomineeProfile.ts consumes it was stale twice over; it is now a module-private const with a comment saying every read path goes through getPool.

Also: the generated voter dids in the votesFor helpers are unique by construction rather than by digit length, the MAX_VOTES comment is singular, and the stats seed comment says what the seed now is.

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

Hide top posts for opted-out profiles, and tidy the vote page's edges

The top-posts block promised "will load once someone votes" on a profile that opted out — but the pool query excludes them permanently, so no pull is ever queued and nothing could ever arrive. Gated on !profile.optedOut.

Vote page: the standing fetch swallowed every error into a bare catch, so a real failure was indistinguishable from the abort that supersedes a pick — it now returns on AbortError and console.debug()s the rest; and a vote cast while that fetch is in flight no longer has its card overwritten by the late response. "Surprise me" told a reader with an empty pool they had "already voted for every nominee", since getRandomNominee answers 404 for both; it now says "No one to suggest yet". handleValue carries a note that it exists for bind:value and is never read.

Profile page: openEditor awaited start(), which opens the form synchronously and then reads the record from the PDS — so the scroll waited on the network. It now fires the open and scrolls after a tick.

FollowSampler: FollowActor.handle is required, so the {#if} around OpenIn was always true; and the empty

spacer under the list is a real conditional now, with justify-end holding the button in place.

Also: the leaderboard's empty state uses the ballot icon (plus had no other caller and is gone from Icon), and EngagementRecs imports $lib/ui once.

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

Correct PROFILE_VISIBLE's home, and note the leftover nomination records

PROFILE_VISIBLE moved to pool.ts with RANKED; CLAUDE.md still pointed at leaderboard.ts. And voters who took part before the votes-only change may still hold dev.wafflehouse.influential.nomination records in their own repos — nothing indexes them and the narrowed OAuth scope means this app cannot delete them either, so the README now says so rather than leaving the next reader to wonder.

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

Commits 0