Skip to content

Pulldasher frontend v2: a React + TypeScript rewrite#477

Draft
jarstelfox wants to merge 296 commits into
masterfrom
frontend-v2
Draft

Pulldasher frontend v2: a React + TypeScript rewrite#477
jarstelfox wants to merge 296 commits into
masterfrom
frontend-v2

Conversation

@jarstelfox

Copy link
Copy Markdown
Member

Note

Draft, for the review trail. This branch is already running on dev
(pulldasher-dev.cominor.com) and prod (pulldasher.cominor.com), deployed
straight from frontend-v2. The PR exists so a change this size has a home
for review and history, not because it's blocked on merge. There are no CI or
branch gates on Pulldasher; prod is whatever's checked out.

Summary

A ground-up rewrite of the Pulldasher board as a React + TypeScript + Vite + Tailwind app under frontend-v2/, replacing the legacy frontend and served at /. The old board answered "here is every open PR"; v2 is reviewer-centric — it answers "what should I review next, and what needs me," and adds the coordination layer (claims, a turn rotation, "Deal me one") that the team was doing by hand in Slack.

Everything below is a summary; the commits tell the full story (202 of them, atomic).

What's in it

  • The board & lenses — Review · My work · Team · People · Classic · Stats. Per-person repo relevance (your primary repos lead; the rest fold away), an aging cross-repo backstop, stacked-PR nesting in the lanes, a universal state popover on every card, and a "what changed since your last look" delta.
  • Coordination — in-memory review claims (never touches the DB) with per-claim expiry and the claimer auto-added as a GitHub reviewer; a "your turn" rotation that points a starved, unclaimed PR at the best-fit reviewer (reciprocity-ranked, deterministic across clients) with a one-click Claim it nudge; and "Deal me one", which hands you the single best pull to review and lets you run a triage streak.
  • Nudges & rewards — a gamified toast system (what-to-review, author-side alerts, and reward moments), a recoverable notification panel, per-kind on/off toggles, toast dwell (ms or sticky) and bell-badge settings, and reward animations (canvas emoji-confetti, sparkle burst, shimmer, odometer) scaled to each moment and gated on prefers-reduced-motion.
  • Code regions — free-text areas you own; matching PRs float up the queue and gather in their own section.
  • Filters & stats — a query box with saved filters, weight + viewer-state filter dropdowns, and a redesigned Stats view (multi-series line, donut, two-series charts) plus trend history.
  • Settings — theme/density, age thresholds, drafts/cryo defaults, open-in-new-tab, claim length/warn, and the full notifications panel.

Server-side (small, and safe)

  • Per-claim expirylib/claims.js stores a per-record expiresAt from a clamped [5m, 24h] client TTL; app.js forwards it from the claimReview socket event. The broadcast wire is unchanged ({ login, at }). Covered by test/claims.test.js.
  • Retire the legacy frontend and serve v2 at / (the old /v2/ path is gone).
  • No DB schema or migration changes — a standing constraint for this project. Claims live in server memory; a restart clears them.

Testing

  • ~296 unit tests (Vitest) across frontend-v2/src/model/ and components
  • tsc strict — clean
  • Biome + production build — clean
  • Server test/claims.test.js
  • Deployed + smoke-tested on dev and prod: 701 open pulls load from the prod metrics DB, / auth-gates (302), config.json serves weightLabels, no errors

QA

  • Board loads and switches across all six lenses; a bare link opens your default lens
  • Claim a review → hand icon appears for everyone, and you're added as a GitHub reviewer; it expires on its own
  • "Deal me one" hands a sensible pull; claiming deals the next; aging and bot PRs are reachable (bots demoted)
  • "Your turn" points at a reasonable person and the Claim it button claims in place
  • Reward toasts fire (inbox zero, streak, top reviewer, PR green, board clear) with their animations; "review landed" stays quiet
  • prefers-reduced-motion degrades every animation to a still
  • Reloading the page does not replay the load-time nudges
  • Weight labels drive CR weight (the size: * chain), i.e. config.json is present in dist/
Deploy / rollback notes

Frontend-only for the latest slice; the only server change in the whole branch is the per-claim expiry above. Prod rollback is a rm-and-run with pulldasher:pre-40ff358-20260720; dev with any prior pulldasher-dev:<sha>. Build bakes frontend-v2/public/config.json (weight labels) into dist/ — load-bearing, gitignored.

🤖 Generated with Claude Code

jarstelfox and others added 30 commits July 17, 2026 14:01
Toolbar ordering: the search box moves to the right end of the filter row, and
the "?" legend lifts up next to the settings cog in the top row, so the two
help/config affordances sit together and the filter row reads tabs → filters
on the left, search on the right.

And the concrete diff size is back: +added −deleted (GitHub's green/red) sits
beside the repo#number on every card. It was cut when the weight meter landed,
but the abstract five-segment gauge loses the exact number a reviewer often
wants; now both are there — the meter for a glance, the count for precision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stamp ledger only revealed who signed on click, so scanning a board
for review history meant a click per row. Make it a hover preview: brush
the pips and the who/when panel appears, move away and it closes. A click
still pins it open and moves focus in for keyboard users, so the drill-down
stays reachable without a mouse.

Hover-open deliberately never steals focus (only click/keyboard does), so
sweeping the cursor across a lane can't yank the page around. Also anchor
the pop animation to the panel's own top-right corner so it grows from
where it's pinned instead of scaling across the row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ClosedRow (the full-width list in Review/My work) and Classic's ClosedCard
(the narrow board column) each re-derived the same merged/closed-time logic
and then disagreed on how to show it: ClosedRow used the shared status-badge
pill, while ClosedCard rendered bare colored text. Same information, two
vocabularies a few files apart.

Extract closedEpoch() (the closed_at-with-updated_at-fallback rule) into
format.ts and a ClosedBadge pill into bits.tsx, and render both closed views
through them. Standardize on the badge, since StatusBadge already establishes
that vocabulary for every open state, and lead the card meta with it the way
open rows lead with their status. The two shells stay separate on purpose:
the horizontal ClosedRow collapses a title in a narrow column, so columns
keep the CardShell layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The four Stats cards each hand-rolled the same panel chrome (rounded surface
+ title/subtitle header), the same proportional bar row (h-2 track + fill,
pct-of-max width), and the leaderboard/starvation cards repeated the same
avatar + login + you-tag cell. Extract StatsCard, BarRow, and PersonCell into
a colocated parts.tsx and render all four cards through them, leaving only
each card's real variance (fill color ramp, leading/trailing content) at the
call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Legend, SigPips, and Filters each hand-rolled the same structure: a trigger
button with aria-haspopup/aria-expanded, a root ref span, and an
absolutely-positioned role=dialog panel carrying the same .popover class stem
and z-50/mt-1 chrome. Extract a Popover component around the existing
usePopover hook that owns the root/panel plumbing and ARIA, taking side,
hover, width, and panel-class props for the parts that actually vary; each
caller passes its own trigger via a render prop and its own panel content.

usePopover itself (click-away, Escape, focus return, hover-pin) is untouched,
so behavior is unchanged. Settings stays out on purpose: it's a portal drawer
with a scrim, not an inline popover, and folding it in would reintroduce the
special-casing the wrapper removes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings owned a Segmented pill control (a radiogroup for a small closed
choice), while Filters' Mine/All drafts toggle hand-rolled the same visual as
two aria-pressed buttons, duplicating the class strings a third time. Move
Segmented into bits.tsx and render the drafts toggle through it. Besides
deleting the duplication, this upgrades the drafts toggle to one radiogroup,
which reads as 'pick exactly one' instead of two independent toggles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
settings.ts and prefs.ts each hand-rolled the same per-browser store: load a
JSON blob merged over defaults, keep a listener Set, and expose a
useSyncExternalStore hook plus a persisting setter. Extract
createPersistentStore(key, defaults) into storage.ts and back both on it. The
factory keeps the one real divergence as a first-class method: prime() updates
the live value and notifies without persisting, which is how primeScope lets a
shared URL seed the board without overwriting the saved one.

store.ts's lastSeen stays as-is on purpose: its write is gated on attended
seconds and it's woven into the larger snapshot machinery, so a generic
factory would either lose the gating or bloat to fit one caller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lane and RestGroup each re-typed the same title/subtitle header row, and
RestGroup re-typed the exact rounded-surface container the Rows component
already exports. Factor a GroupHeader (title, optional sub, optional
right-aligned count) that both use, and have RestGroup render through Rows.
The collapsible BoardColumn header stays separate: its title lives inside a
collapse button, which is a different affordance, not the same header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The open-count next to the logo doubled as a link into the Stats lens
("N open · stats ▸"). Stats is already one of the lens tabs a row below, so
the shortcut was a second door to the same place. Keep the open count as
plain ambient text beside the live-connection dot and remove the link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CR/QA pips sat justify-end in a fixed-width slot, so the empty space
landed between the label and its pips and shrank as the required-stamp count
grew: a 1-stamp card and a 2-stamp card showed a different CR-to-pip gap, so
the spacing looked inconsistent card to card. Switch the slot (and the
not-required dash) to justify-start: pips now hug the label with a constant
gap, and the leftover space falls after them where it reads as ordinary
column padding. The slot keeps its fixed width, so the QA and age columns
still line up down the board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sign-off ledger opened inside a row, and the row list is a rounded,
overflow-hidden surface — so the panel was clipped at the card's bounds and
tangled with the row's stacking context, reading as translucent where row
content showed through. Render the panel through createPortal to document.body
with fixed positioning derived from the trigger's rect, following it on
scroll/resize, so it escapes the clip and sits above everything at full
opacity.

Because a portaled panel is no longer a DOM descendant of the popover root,
teach usePopover's click-away to treat the panel ref as inside too; otherwise
clicking a control in the (portaled) Filters panel would count as an outside
click and close it. Hover handlers ride the panel as well so the mouse can
travel from trigger into the panel without it closing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sign-off popover listed who stamped and when but went nowhere; v1's
SignatureListItem links each one to the comment via Pull.linkToSignature.
The data is already on the wire: the backend's identifySignatures() sets
source_type ('comment' | 'review') per signature, which is the discriminator
that picks the permalink anchor, since comment_id spans two GitHub id spaces
(#issuecomment- for a comment, #pullrequestreview- for a review).

Add source_type to the Signature wire type, a signatureUrl() helper mirroring
v1's anchor logic, and make each row in the stamps popover an anchor opening
that comment in a new tab. source_type is absent on the old dummy fixture, so
a missing value falls back to #issuecomment- (same as v1's non-review branch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The only way to force a re-fetch was the per-row refresh button, one PR at a
time. Add a "Refresh all" button under a new Data group in Settings that
re-fetches every open PR from GitHub at once.

The socket protocol has no bulk-refresh event (the server only accepts
per-pull "refresh"), and this rewrite keeps the protocol unchanged, so
refreshAll() fans out one refresh per open pull and lets the server's serial
refresh queue drain them; the board updates via the usual pullChange stream as
each returns. The button reports how many were queued. Closed pulls are
historical, so they're skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Who you're signed in as is low-value chrome that ate horizontal space next to
the board controls; your own PRs and stamps already read as "you" in context.
Remove it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "changed since your last look" marker was a 7px dot in the card's left
gutter: hard to see, poorly aligned with the avatar, and its meaning (solid =
new PR, ring = updated) was a guess unless you opened the Legend. Replace it
with an inline tag in the meta line — a solid brand "new" chip for a
brand-new PR, a brand-outline "updated" chip for one that merely changed — so
the state is legible and self-describing, and it flows with the row instead of
floating in the padding.

Move the marker into Row's meta and drop CardShell's fresh prop and the
absolutely-positioned dot; the row-flash on first appearance is unchanged.
Update the Legend to show the two tags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was no way to get back to a clean slate: a bad age threshold, a stuck
scope, or a muted repo you forgot about all just persisted. Add a Clear
settings button under the Data group that removes every pd2.* localStorage key
(settings, scope, last-seen marker) via clearStoredPrefs() and reloads so the
in-memory stores re-init from defaults.

It's a two-click confirm (the button arms to "Click again to confirm" for a
few seconds) rather than a native dialog, since a wipe shouldn't be one stray
click but doesn't warrant a modal for browser-local prefs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audit found five independent answers to "what's next" (authorMove,
reviewerMove, ownVerb, waitingOn, and Row's cue) that phrase the same state
differently across lenses and can disagree. Introduce rowNote(pull, me) in the
model as the single source every lens will render: the imperative verb when
it's your move (enriched with the when/which detail a badge can't carry),
otherwise a terse who/when/why for whoever's move it is, never restating the
badge's noun.

Source-agnostic wording throughout — a CR, re-stamp, or dev block reads the
same whether it came from a comment tag or a GitHub review — and "you"
substitution everywhere (fixing the "you's" grammar bug the old waitingOn had).
Not wired up yet; the next commit routes Row and the views through it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One consistent scheme now, in all lenses: the StatusBadge is the canonical
"what state" signal (shown on every row, no more per-lane suppression), and a
single rowNote line carries the imperative when it's your move or a terse
who/when otherwise. This kills the redundancy the audit found:

- Row's cue() and My work's waitingOn() are deleted; both were reimplementing
  the same status→text mapping with divergent wording. Review's "Yours to
  do" / My work's "Your move"+"Waiting" now render plain <Row> and let rowNote
  supply the text, so the imperative ("Re-stamp", "Merge it") and the wait
  note come from one place. AnnotatedRow and Row's note/noteTone props go away.
- The WarnFlags "QAing: X" chip is gone — it always duplicated the note's
  "X is QAing"; the note wins.
- "iterating" is suppressed when the note already says "fix pushed …", so a
  just-repushed re-CR doesn't state the same fact twice.
- The badge option (badge:false) is removed along with the dead cue toggle, so
  the badge is consistently present rather than hidden in three Review lanes.

Also drops the now-unused flag-qaing style and FoldRows' unused extra prop, and
updates the Legend's QAing entry to the plain-text treatment rows now use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The amber sign-off pip's tooltip said 'invalidated by a later push — a
re-stamp is owed', a third restatement of the fact the badge (Needs re-CR) and
the row note (Re-stamp / waiting on X's re-stamp) already carry. Trim it to
'invalidated by a later push' and let the primary row text own the word.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Even after unifying the note, a your-move row still paired the badge and the
verb for the same axis: "Needs re-CR" next to "Re-stamp". They're two names
for one thing. So when it's your move, let the imperative be the signal — it
leads the row (in the badge's slot) and the badge steps aside; when it's not
your move, the badge names the state and the note only adds who/when.

Also de-echo the wait notes that parroted their badge: "waiting on alice's
re-stamp" → "waiting on alice", "dev-blocked by alice" → "feedback from
alice", and similar for QA/CR/deploy, so the trailing text adds information the
badge doesn't instead of restating it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The imperative led the row as bare bold text, which read quieter than the
status-badge pill it replaces on other rows, so a your-move row looked less
substantial than a waiting one. Add a badge-do pill — the badge's shape and
size, a lighter brand-50 tint and slightly bolder weight — so the imperative
carries the same visual weight as a status badge while still reading as an
action, not a state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1 alerted you when one of your PRs became ready to merge or a re-review
(re-CR/re-QA) fell to you, via a navbar bell that also toggled a sound; v2
dropped it (a listed known gap). Add the engine back: useNotifications watches
the whole board (not the current filter) and fires a desktop Notification for
items that newly match since the last update, so a steady board stays quiet.
The first payload after load only primes the baseline — it never alerts for
the backlog already sitting there, which v1's version did.

Two settings back it (notify, notifySound, both default off); the next commit
adds the Settings toggles. The optional sound is a short synthesized chime
rather than a shipped mp3, so v2 stays self-contained and CSP-safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose the ported desktop-notification feature: a Notifications group with an
On/Off toggle (enabling it requests browser permission, and stays off if the
permission is denied, with a hint pointing at the browser settings) and a
separate Sound toggle for the chime. Both are gated behind Notification
support, with a plain note when the browser lacks it. This puts the control in
Settings where v2 keeps personal config, rather than v1's always-on navbar
bell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move it out of the known-gaps list (it's ported now) into the feature list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first pass was a bare port. Audit turned up real gaps; close them:

- Coverage: alert on every transition that actually lands on you, not just
  ready + re-review. New model/actions alertMove returns the nudge-worthy
  action (mergeable / CI failed / feedback / rebase / re-CR / re-QA) and
  excludes standing states (find-a-QA-er, drafting, self-claimed QA). Locked
  with actions.test.ts.
- Clickable: each notification focuses the tab and opens its PR, and carries a
  per-PR tag so a newer state replaces the old alert instead of stacking.
- Not noise: fire only while the tab is unfocused — when you're looking, the
  board already surfaces it. The baseline still updates every tick, so
  returning and leaving never replays what you saw.
- Sound that actually plays: reuse one AudioContext unlocked by the toggle's
  click (a user gesture), so the chime isn't muted by autoplay policy when it
  fires in a backgrounded tab. Two-note chime instead of a single beep.
- Verifiable: a 'send a test notification' button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Sound toggle unlocks the AudioContext on its click, but when notify was
persisted on from a prior session that toggle is never touched, so the first
chime could be autoplay-blocked. Add a one-time pointerdown/keydown backstop
that resumes the context on the session's first interaction when sound is on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Changing ageWarnDays in Settings recolored the age chip (AgeStamp already read
the setting) but left the actual starved/aging logic pinned at the hardcoded
STARVE_DAYS=4: the "Aging without full review" lane membership, the "open Nd"
flag, and the "unreviewed for Nd" note all ignored the user's threshold. So
the setting silently half-worked.

Thread warnDays into derive() (defaulting to STARVE_DAYS for non-UI callers)
so the starved flag uses it. The store passes getSettings().ageWarnDays, keys
the derive cache on it so a change actually re-derives instead of returning the
cached value, and subscribes to settings changes to re-publish immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All three rendered the same amber, so 'Only CI left' (benign, nearly done)
looked exactly as urgent as 'Dev blocked' (you owe feedback) and 'Cant merge'
(conflict) — collapsing the very distinction the status vocabulary was built
to draw. Split them: a new calm slate tone for the CI wait, amber kept for the
action-owed dev block, and gray for the git-conflict rebase. Dots follow the
badges. Adds badge-slate / --slate tokens in both themes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Compact was one CSS rule that shaved 8px of row padding and nothing else, so
it was barely distinguishable from comfortable. Finish the density that the
data-density hook only pretended to implement:

- CardShell gets a real compact layout: the two-line card (title above, meta
  below) collapses to a single line — avatar, a truncating title, then the meta
  chips and the right-anchored rail — with the avatar shrunk 22->16 and tighter
  padding. This is the structural change that actually changes rows-per-screen.
- Row padding drops to 2px, the metric-rail gap and sign-off pips and weight
  meter shrink, and lane headers and section spacing tighten — all scoped under
  data-density=compact so the whole row shrinks together.
- Lanes show ~50% more rows before folding in compact, since the rows are much
  shorter.

Threaded as opts.compact from settings.density through RowOptions so it rides
the existing row plumbing and the memo comparator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lane fold thresholds were hardcoded per view (6-15 rows). Add a Lane length
setting (Board group: 10 / 25 / 50 / No cap, default 10) that overrides them
uniformly, so a reviewer on a wide monitor can see a whole queue at once and
someone who wants the page short can keep it tight. No cap (0) shows every row
with no fold. Threaded as opts.laneCap through RowOptions; Lane falls back to
its own default when the setting is unset, and the compact 1.5x multiplier
still applies on top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jarstelfox and others added 30 commits July 22, 2026 13:13
An audit of the action/state machine against the live board (82 open
PRs) found four places the imperatives lied, one of them on a quarter
of the board:

1. "Answer the review" fired on bot reviews. The wire's
   unstamped_reviewers carries every reviewer, and the CI review bot
   COMMENTs on most PRs — 21 of 45 open non-draft PRs had a bot
   COMMENTED review, and on 11 the bot was the only unstamped reviewer,
   so the author's card read "Answer the review · from claude[bot]",
   grouped under Respond, bucketed "Waiting on me", and the branch's
   early return buried the real queue-position / nudge context. Filter
   bots (same suffix check engagedNoStamp already uses).

2. DISMISSED reviews demanded answers. A dismissed review no longer
   stands, so it owes nothing; live examples on #61656/#63206/#62457
   were all nagging their authors. Drop DISMISSED from the
   answer-the-review set.

3. Re-stamp / Finish QA / Re-QA ignored the masking statuses. The
   personal-obligation early exits ran before the draft / dev_block /
   ci_red branches, contradicting the machine's own precedence: a pull
   converted back to draft told its stale stampers "Re-stamp" (truth:
   not reviewable), a dev-blocked pull asked for re-stamps while the
   author still owed changes, and the push that broke CI demanded a
   re-review of a head the author is about to replace. New authorOwnsIt
   gate covers reviewerMove (desktop nudges), reviewerNote (cards),
   actionState (the State filter), is:restamp (the query token), and
   the Team/People owes-re-stamps maps, so every surface agrees: while
   the author owns the pull, reviewers are asked for nothing, and the
   obligation returns when the mask lifts. QA's parallel-with-CR
   behavior across the OTHER statuses is unchanged.

4. A clean stacked PR could get a "Rebase" nudge. authorMove keyed on
   status === 'unmergeable', which also covers dependent-without-
   conflict — signed off, waiting on its parent, nothing to rebase.
   Key on the conflict flag alone; authorNote already said "lands with
   its parent" correctly.

actionState now derives the restamp bucket from the note's action
instead of raw recrBy/reqaBy, so the filter count can never disagree
with the word on the card (this also fixes the edge where a re-tester
holding the QAing label bucketed "Re-stamp owed" while reading
"Finish QA").

The masking rule is documented in the Legend's Sign-offs group. Six
new tests pin the gate, the bot/DISMISSED filters, and the stacked
no-rebase case; two existing tests that pinned the old unmergeable →
Rebase and ci_red → Re-stamp behavior are updated to assert the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The legend had grown into a 380px encyclopedia: seven groups, twenty-odd
items, four multi-paragraph fold-out essays (weight, regions, claims,
turn rotation), ~2,600px of scroll. Most of it restated things a mark
already explains in place, and its illustration glyphs (✦, ✋, a v1 eye
icon) didn't even match the marks actually on the board.

The redesign follows locality of information — the lane-subtitle popover
pattern: an explanation lives where the thing it explains sits, and the
legend keeps only what no single mark can teach.

Moved to their marks:
- The pip vocabulary keys itself inside the sign-off ledger popover
  (stands / staled by a push / needed), right where the pips are read.
- How weight is decided (label wins, else the 50/150/600/1500 diff
  steps, +1 class past 15 files) lives in the weight letter's popover,
  replacing the "from diff size" stub and the legend essay.
- The claim button's own title now carries the essential fact: claiming
  adds you as a reviewer on the PR itself. (The restart-timestamp
  trivia from the old essay is cut, not moved — server arcana.)
- The re-stamp masking rule glosses the wait-words where a masked card
  actually sits: "CI red" and "blocked" headers now say stale stamps
  wait for the author's fix. "in QA" now says how QA is claimed (the
  QAing label) — the only how-to the old legend held exclusively.
- Age, the hidden ledger, group-header words, and regions already
  explain themselves in place; their legend entries are deleted, not
  moved.

What remains is one screen, five groups, no folds: the invented word
(stamp — now correctly including GitHub approvals, not just comments),
the circle-mark family with the author-moves-first rule, CI's
same-marks convention (the one thing a mark can't teach about itself:
passing is invisible at rest), the color grammar (brand = your move,
amber = act, gray = fact), the three coordination signals shown as the
REAL marks (the lucide hand, the "requested from you" words), the four
query tokens, and the keys. A lead-in line sets the contract: hover
anything, everything explains itself where it sits.

420px wide so definitions wrap at two to three lines; ~980px tall
against the old ~2,600px. The bundle drops ~8kB of essay prose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Coworker report, reproduced on the live board: a PR that was both a
draft and parked in Cryogenic Storage sat at the TOP of "Your move"
under Re-stamp. The draft half was fixed by the authorOwnsIt gate; this
closes the parked half and renames the draft verb.

Parked (the Cryogenic Storage label) now masks the whole action
machine, the author included — stronger than authorOwnsIt, where the
author still has a move: parked means deliberately shelved, so nothing
is owed by anyone until the label comes off. The new `parked` predicate
gates rowNote (the card reads "parked, kept open on purpose" and groups
under a new "parked" wait word with its own header gloss), authorMove
and reviewerMove (no lane slots, no desktop nudges), the turn rotation
(a starved parked pull no longer gets pointed at anyone), Review's
offered-work pools (queue, bot queue, QA), the Team/People
owes-re-stamps maps, and the is:restamp token. actionState follows
rowNote, so the State filter agrees. This only ever mattered when a
parked pull was revealed (the hidden ledger, showCryo, or show-all) —
but revealed is exactly when the coworker hit it.

"Finish the draft" becomes "Undraft": the exit verb, matching the
Unblock naming pattern the owner asked for. The move on your own draft
is marking it ready for review, not "finishing" it — the old word
implied the board knew how done the code was. Still last in the do-word
ranking and still excluded from desktop nudges and Review's home lane
(planning, not minutes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The knocked-out ✗ made CI's failure the loudest glyph on the rail —
louder than any human mark (owner call: drop it entirely). CI is now a
glyphless circle, the machine's mark; check glyphs are reserved for
human stamps, so the rail reads people-with-checks beside
machine-with-a-circle.

Three standings, one 14px geometry (.ci-ring / .ci-disc):

- Failing: a solid red disc. Mass and color carry the alarm, no glyph.
  It lands with a single 320ms scale-settle and then holds still — an
  alarm that keeps moving is a nag, not a mark.
- Running: the slate ring sweeping clockwise, the filled share being
  checks done. It breathes on a 2.4s cycle so a live run reads as
  alive, and --sweep is a registered @Property (top-level, layers
  don't own it) so the share GLIDES between values instead of
  snapping when another check finishes.
- Passed: a green ring that draws itself closed on row hover or
  focus. Invisible at rest (no news is good news), and the reveal IS
  the animation. Replaces the green ✓ disc, which borrowed the human
  mark.

prefers-reduced-motion disables the breathe, the land, and the draw
(the pass ring simply appears closed). The dead .pip-fail, .pip-run,
and .pip-progress classes are removed; the Legend's CI line and
DESIGN.md's rail taxonomy now describe the circle language.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four interlocking changes from one design pass, touching the same rows.

Snooze is Review's own gesture. It used to hide a pull from EVERY lens
via the shared visibility pipeline, with its only ledger a count buried
in Settings. Now the Review view applies it itself: snoozed rows drop
out of Review's lanes only, and collect in a visible "snoozed by you"
fold at the bottom of The rest of the board, with the wake-all beside
them — hiding is trustworthy when you can always see what's hidden.
The Settings block and the hidden-ledger's snoozed row are gone (the
board section replaces both), and the snooze button says what it now
does: off your Review lens.

The age numeral moves to the rail's far right and whispers. It was a
heavy black column in the meta line; now it caps the rail — the row's
true right edge, beside the marks it ranks with — wearing the age
line's own border tint at rest and rising to ink on row hover. The
warn/rot font weight survives for the hover read. A new setting picks
which clock it shows (days since opened, or since last update); the
urgency weight always follows the opened clock, and both clocks stay
in the popover.

The rail reorders: CR · QA · CI · age. CI is the rail's only
optionally-rendered mark (green shows on hover only), so it moves to
the end where its appearance can't shift the human marks — and
Classic's narrow columns stop hiding it outright: the quiet slot
collapses at rest and returns on row hover, so green is one hover away
everywhere. The CR cluster becomes ONE door: the label, weight letter,
and pips are a single trigger, and the weight drill-down (effort word,
exact diff, the sizing rule, the filter action) is now a section of the
CR ledger panel — the standalone weight popover is deleted. QA's label
joins its trigger the same way, and a no-stamps cluster still opens its
panel ("No stamps yet." plus the key) instead of being a dead spot.

Parked/draft audit fixes: `starved` is now false for parked pulls at
the source (status.ts) — they age on purpose, so starve nags, starve
scores, and the turn rotation all stop seeing them as rotting; and the
cheer/nag system sits parked pulls out entirely (author edge toasts,
the board-cleared count, and the review-requested toast all gate on
cryo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pass ring drew itself closed on row hover — a flourish that broke
the mark's own rule: animation should mean something CHANGED (a check
finishing glides the sweep, a failure lands the disc), and hovering
isn't a change. The green ring is now statically closed and simply
appears with the quiet slot's existing reveal; the hover-draw rules and
the reduced-motion special case they needed are deleted. The doctrine
line in DESIGN.md records the cut so the flourish doesn't come back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The solid red disc read as a blob (owner call, from four live-DOM
candidates at real pixels: ring+dot, thick ring, broken ring, disc).
The winner keeps the machine family hollow — every CI standing is now
ring-built — with the center dot as the smallest mass that still says
alarm, and gives failing a FORM apart from the green passed ring
rather than leaning on hue alone. Drawn with the house SVG-mask
technique so the strokes match the pip family's machining. The landing
settle and everything else about the mark's behavior is unchanged;
Legend and DESIGN.md record the new form and why the disc was cut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In Classic's ~380px columns the rail wraps to its own line, and the
hover action cluster (copy / snooze / re-fetch / claim) rendered
inline right beside the marks — four icons crowding the row's most
information-dense line (owner report, with screenshot). Mobile solved
this long ago with the kebab; desktop narrow columns now borrow it: in
≤520px containers the inline cluster collapses outright and the kebab
takes over, revealed on row hover or focus (opacity, so the slot never
jumps), opening the same labeled menu. Wide lanes keep the inline
cluster; below 720px nothing changes (the kebab was already the
always-visible answer there). The rules ride in the existing unlayered
container block so they beat the kebab's own hidden-at-desktop utility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Size unknown" existed because of an ingest gap, not a real state:
GitHub's pulls.list omits additions/deletions/changed_files (only
pulls.get and webhook payloads carry them), and the bulk open-pulls
refresh handed list items straight to the parser — so every REPLACE
INTO overwrote good sizes with NULL, and a pull stayed "unknown" until
some webhook happened to deliver its stats. Worse, changed_files was
never read back from the DB at all, so even a webhook-populated value
vanished on the next server restart (silently dropping the >15-files
weight bump).

Server: parse() now fetches the full pull when the incoming payload
lacks additions (one extra API call on top of the ~8 the per-pull
fan-out already makes, and only for list items), and getFromDB round-
trips changed_files. Together they guarantee diff stats on every open
pull; existing NULL rows backfill on the next startup refresh.

Client: the unknown state is deleted outright. sizeKnown leaves
DerivedPull; the weight letter is always a real class (no more "?");
the Weight filter drops its "Size unknown" bucket; the CR panel drops
the "estimated" hedge; the sorts and quick-win checks stop guessing a
2.5-rank middle for sizeless pulls. Stats keeps its own additions-null
check for HISTORIC closed pulls, which are never re-fetched. The dummy
fixture predates diff stats, so the demo loader synthesizes a
deterministic spread across every weight class (and the >15-files
bump) instead of reading all-XS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four identical icon buttons (copy, snooze, refresh, claim) gave equal
salience to unequal decisions: Claim and Snooze decide something about
the work, copy and re-fetch are plumbing you reach for rarely. The
hover cluster now shows only the verbs, as WORDS — "Snooze" in quiet
ink, "Claim" in brand, because committing to a review is the board's
core gesture and a word invites where an anonymous hand icon didn't.
The cluster floats over the row's tail at zero standing width (the
compact overlay promoted to the only mode), so the words cost nothing
at rest.

The utilities move to the kebab at every width — one overflow pattern
board-wide, hover-revealed on desktop (extending the narrow-column
pattern), always visible on phones. Its menu reorders to match: the
verbs lead (Claim, then Snooze), a divider, then copy / re-fetch, then
repo and person management.

Snooze also becomes context-honest: it renders only on the Review lens
(opts.showSnooze), the one lens a snooze quiets — offering it on
Classic while it silently acted elsewhere was a lie of placement. The
held-claim hand stays always-visible outside the hover cluster, as
before: a commitment, not a hover affordance. DESIGN.md records the
verbs-vs-plumbing rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 8px band only appeared when the cursor found the line's own 10px
hit strip — pixel-hunting for a 1px hairline. Hovering the CARD is the
"tell me about this row" gesture, and the deepened band is part of that
answer, so it now grows with the row hover (the same trigger that
reveals the verbs, the kebab, the green CI ring, and the ink on the
age numeral — one gesture, one coordinated reveal). The band's own
hover still opens its popover, and keyboard focus still grows it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The worded hover overlay lasted one commit: it made the verbs inviting
but left the board's core gesture undiscoverable at rest — first-class
means living in the row's anatomy, not materializing on hover. The rail
now ends in a verb dock: "Snooze" and "Claim" as STANDING words after
the age numeral, whispered at 60% ink-3 (readable cold, so the
affordance needs no hover and works on touch), rising on row hover to
their true colors — Claim to brand, Snooze to ink. The same
rest-then-rise ramp the age numeral wears: one quiet tier, board-wide.

A claim you hold turns the slot into "Release" in standing brand with
the hand mark — the separate always-visible hand button is absorbed;
one home for the whole claim lifecycle. The Claim slot is reserved on
unclaimable rows (your own, already-claimed) so the dock reads as one
column down a lane, and Snooze still renders only on Review, the lens
it acts on. Utilities stay in the kebab; the overlay pill, its
visibility CSS, and the dead narrow-column hider are deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The standing whispered words put quiet texture on every row of a lane —
sixty Snooze/Claims of it. The dock now separates PLACE from PRESENCE:
the slot keeps its reserved width (one column down the lane, nothing
ever shifts), while the words rest at opacity 0 and fade in on row
hover or focus. A fade, not an animate-in: opacity is the board's one
reveal mechanism (the green CI ring, the kebab), and motion stays
reserved for state changes — nothing slides because a cursor passed.
On touch, where hover doesn't exist, the words stay visible at a
whisper.

With the dock quiet at rest, the held claim needed a standing home that
keeps the column's vertical rhythm — and it has a semantic one: a claim
is literally a pending review request, so its mark is now the brand
hand riding with the CR pips. "Release" joins the dock as a normal
revealed verb. Commitments stay always-visible; the dock stays empty
until a row has your attention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reserved dock was a worse design (owner call): it held ~100px of
invisible words at the row's end, which pushed the age numeral — the
board's strongest column, the right-edge anchor — inward and paid dead
space on every row for it. A place is only worth standing if something
visible stands in it.

The verbs keep their fade-on-row-hover reveal but now own no geometry
at all: they float just left of the rail (absolute off the rail's own
box, both densities), on a muted backdrop for legibility over the meta
text beneath, with pointer-events following the fade so an invisible
word can never steal a click from the text under it. The rail reads
CR · QA · CI · age flush to the right edge again — verified one exact
x for every age numeral down the lane — and appears exactly where the
eye already is when a row has your attention. Touch keeps the kebab as
the verbs' labeled home (the float doesn't exist there); the held
claim's brand hand stays standing with the CR pips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The verbs now name the exit, not a glyph: a claimed row's slot reads
"Release", a snoozed row's reads "Unsnooze" (new unsnoozePull in the
store, publishing like its siblings so the row walks straight back into
the lanes) — the same slot, the opposite word. The hand icon is gone
everywhere: the CR-cluster standing mark, the kebab item, the Legend
(whose claim row now shows the word the UI actually renders, in brand).
A claimed row already reads through position — it groups under Finish
CR in Waiting on you — so a second standing mark restated what
structure says. The kebab's snooze item carries the same toggled word,
and per-row wake means the snoozed fold no longer needs the all-or-
nothing Wake all as its only undo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The states you choose deserved a standing record, not just a toggled
hover verb: "claimed" and "snoozed" now render as flags beside
conflicts and stacked — the board's existing channel for standing row
facts — in a new brand flag tone, because brand is the yours color and
a choice you made stays visible with no hover required. The flags ride
the shared flag popover (brand dot, one-line meaning pointing at the
hover verbs for the exit) and appear on every lens the row renders on,
so a snoozed pull seen on Classic now says why it's missing from
Review. The hover verbs (Release / Unsnooze) are unchanged: state
stands in the meta line, the exit reveals on approach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The meta-line flags lasted one commit — restating in the meta what a
standing button can say itself split one fact across two homes (owner
call). The claimed/snoozed record now lives IN the buttons: the float
splits into two independent chips, each with one rule. A chip that
RECORDS a choice you made stands — a claimed row always shows
"Release", a snoozed row always shows "Unsnooze", brand and ink
respectively, no hover needed, and if both states are yours both chips
stand. A chip that merely OFFERS ("Claim", "Snooze") reveals on row
hover/focus, opacity-only with pointer-events following. The state and
its exit are the same pixel.

The brand flag tone, its CSS, the rowFlags claimed/snoozed entries, and
the Legend's brand-flag sentence all revert; touch keeps offers in the
kebab while standing exits stay visible everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The board spoke two subsection languages. Word groups ("RE-STAMP · 1")
were quiet eyebrow bands the owner liked, but they didn't fold; the
rest-of-the-board ledger, "their other PRs", and "you've stamped" were a
different species entirely — 13px sentence-case <details> rows with a
large chevron and a colored status dot. A reader had to learn both, and
the two tiers side by side made the ledger read heavier than the primary
lanes above it.

Merge them: Fold IS the eyebrow now. One collapsible band — 11px caps,
brand for a do-word, muted for everything that waits, "· count", a 12px
chevron as the only disclosure affordance, no status dot (rows inside
already carry their pips; a second color code on the band was redundant
weight). Hovering the label opens its one-sentence gloss; clicking
anywhere on the band toggles — including the label, whose popover
trigger would otherwise swallow the click, so the trigger's onClick
drives the <details> by hand. Word groups now fold too (open by
default, choice remembered per fold id), which the owner asked for
directly: collapse the REVIEW group once and the lane stays personal.

What this unlocks per lens:

- Review: the ledger buckets adopt the wait-word vocabulary the groups
  already speak (Blocked, Deploy hold, Conflicts, CI running, CI red,
  Drafts…), so the rest of the board reads as a closed table of
  contents in the same words as the lanes above it. Hints became
  hover glosses — locality over always-on chrome.
- Team/People: "their other PRs" was one undifferentiated pile; it now
  word-groups under "The rest of their work", so re-stamps, blocks, and
  waits are legible per person — and the hand-made "you've stamped"
  fold dissolves, because a live stamp of yours already emerges from
  rowWord as the "stamped" group.
- Ci: the per-check bands become folds (caps off — check names are
  case-sensitive identifiers), completing the pattern.
- Classic/My work: inherit the collapsible groups through WordGroupRows.

Truncation moves inside each group (cap per group, "+N more" in the
fold) instead of one shared ration across groups — a collapsed group
can't spend the open ones' budget.

DESIGN.md: the four-tier header system is three tiers now, with the
Fold contract written down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
People and Team were the same board with different pickers: both split a
member list through the reviewable / stamped / rest rules (People inlined
them, Team called the unit-tested teamBuckets), both rendered the same
queue and rest-group below. Two nav tabs whose bodies agree is one tab —
and People made a weak top-level lens anyway: the daily loop never starts
at a directory, and its cold-open default (whoever has the most PRs) was
a board nobody asked for.

What People uniquely carried survives, inside Team:

- The owed re-stamps roll-up: every directory chip still wears the amber
  pip and count, the only at-a-glance "who is sitting on stale
  approvals" on the board.
- Person as destination: clicking any avatar anywhere still lands on
  that person's page, now under the Team tab (#lens=team&person=x).
- GitHub-team boards, via the config team chips.

The merged layout answers "what is this tab for" in its own geometry:
people, starting with yours. Row one is your picked team, the "Your
team" aggregate chip (the tab's home board, active by default) leading
its member chips and the Edit picker; behind it the directory: config
teams, then everyone else, starred first, your members deduped out. An
explicit pick earns the summary card; the home board's summary is the
member strip itself. The build-your-team invitation only appears when
you have no team and nothing picked, with the directory still browsable
above it.

Everything routes through teamBuckets now — a person is a one-member
team — so People's duplicate inline bucket logic is gone and the
live-vs-stale stamp doctrine keeps its single tested home. Legacy
#lens=people URLs rewrite to team (person=/team= params carried along,
the board→classic precedent), and old saved filters describe themselves
as Team.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-check ledger (failing/running share on the fixed ruler, average
run time) sat at the bottom of the lens, after every pull list — but it's
the lens's answer to its own headline question, "how is CI doing right
now": one glance says which check is hurting before any individual pull
matters. Dashboards summarize first, then itemize; the pull lanes (your
broken builds, failing by check, running) are the itemization and now
follow it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deciding "can I act on this?" often needs one more layer than the rail
shows: the description — the summary, the QA checklist, the linked
issue. That layer lived a tab away on GitHub. Now the title itself is a
door: hover the visible words and the description opens beside them,
rendered as GitHub would render it (gfm, single-newline breaks, inert
task-list checkboxes) and sanitized (DOMPurify; PR-template HTML
comments drop out, every link opens a new tab).

Embedding the real GitHub page was considered and is impossible:
github.com sends X-Frame-Options: deny, so it refuses to render in any
frame. Rendering the body we already carry (PullData.body has been on
the wire all along) is both the possible and the lighter answer — no
server change, no extra fetch.

Decisions along the way:

- The hover region is the visible title text, not the row. The title
  anchor's stretched hit layer covers the whole card, and a preview
  from anywhere on the row would be a popover storm — so Popover grows
  a hoverTriggerOnly option that scopes its hover handlers to the
  trigger element instead of the root.
- No click-pin. Clicking a title means "open the PR"; pinning a preview
  behind the tab you just opened is noise.
- No door where there's nothing behind it: an empty description gets a
  plain title.
- marked + DOMPurify load as their own chunk on the first hover
  (~24 kB gz); board startup pays nothing.
- .md-prose sets the quoted document a notch under board type;
  blockquotes render as muted insets, not side-stripes.

The dummy board's flat placeholder bodies become three rotating
realistic descriptions so the feature demos honestly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The description preview never opened for a real pointer: the title
anchor's stretched click layer (.pd-link::after, z 0) paints over the
row's inline content, so the cursor hit-tested the layer and the
trigger span inside the anchor never received mouseenter. The dummy
verification passed because it dispatched synthetic mouseover events,
which bypass hit-testing — a trap worth remembering: verify hover
features with a real pointer.

The fix is the house pattern the layer's own comment prescribes:
genuinely interactive children sit above it via .pd-raise. The span
stays inside the anchor, so clicking the words still opens the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Check health and "Failing, by check" were the same list twice — every
check ranked worst-first up top, then the same checks again below with
their pulls. Merged: each check is one band carrying its glance (the
fixed-ruler failing/running share, "5 of 57 runs failing", average run
time), and a failing check opens into the PRs it's failing, yours
included. Healthy checks render the same band without a chevron —
nothing to open, nothing pretending otherwise.

All bands start collapsed and stay that way until the user chooses:
this view's first audience is QA engineering asking "are our tests
failing in certain ways?", and the glance row answers that; the pulls
are drill-down. Fixing that default exposed a Fold bug: browsers fire
`toggle` on a <details> that MOUNTS open, so every defaultOpen fold was
writing itself into the open-state store on first paint — an explicit-
choices-only store that was silently recording choices nobody made.
onToggle now ignores any toggle matching the rendered state.

Fold grows two small props for the band: `detail` (right-aligned
content) and `showCount` (off when the detail says the count in words).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Thirty stranger-chips stood above your own team's board and outweighed
it. The directory (config teams + every author) now sits behind one
quiet "Everyone · N" control at the row's far end — yours on the left,
the door to the rest of the world on the right.

The door opens itself exactly when hiding it would lie: no team
configured yet (the directory is the only content), or the current pick
lives outside your team (the chip explaining whose board you're on must
stand). Your own toggle wins for the rest of the visit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The popover stated facts the wire could substantiate but didn't hand
over: context at a glance means the door to the evidence, not a trip
through GitHub's tabs.

- Conflicts name the branch they fight ("conflicts with master") and
  link straight into GitHub's conflict editor (/conflicts).
- Blocks say which kind (dev blocked / deploy blocked) — the comment
  permalink was already there via signatureUrl.
- Each failing CI check links to its own run log (the status's
  target_url; the PR checks tab as fallback).
- closes/connects render as issue links — the server has parsed these
  body tags all along (models/pull.js), the client types just never
  declared them.
- branch shows its base ("into master"), and milestone, assignees, and
  the comment count/last-comment age surface when present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Live updates lost … retrying in the background" was sticking after
overnight sleeps because the retry could never succeed:

- A failed /token fetch stayed CACHED: the promise rejected once and
  every later caller — including each reconnect — replayed that same
  rejection forever. Failures now evict themselves from the cache.
- The auth handshake had one shot: on reconnect, a token failure set
  the error banner and stopped. The socket sat connected but
  unauthenticated, so the server never re-sent data. Authentication now
  retries with backoff (2s doubling to a 30s cap), reset by any
  connect/disconnect.
- Nothing kicked the socket on wake: Chrome freezes background tabs and
  laptops sleep mid-backoff, and socket.io would then sit out the rest
  of its delay. The tab becoming visible, the window refocusing, or the
  network returning now reconnects immediately.
- The one failure a client can't heal alone — an app session cookie
  that expired overnight — gets a guarded page reload (visible + online
  tabs only, at most once per 5 minutes): the auth gate re-runs the
  OAuth handshake, which is silent with a live GitHub session, and the
  board comes back signed in. /token redirects now read as failures
  instead of JSON parse errors, so that path is detected at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four changes from Danny's first-day feedback thread in #pulldasher,
sharing one theme: the board should never move state the user can't
see or predict.

Drop v1 URL compatibility. legacy.ts translated v1 bookmarks
(?repo=&cr=0&closed=1) into v2 state behind an undismissable chip.
Nobody wants the old URLs; the translation layer, its filter branch,
its Classic-only props (column collapse, the Recently Closed column),
and its tests all go.

"Requested of you" becomes "review requested". The standalone lane
merged into Waiting on you some time ago; what remained was phrasing.
"GitHub asked you directly" read oddly — a PERSON asked, GitHub
carried the message. The row context and Legend now say "review
requested", matching the toast title that already existed.

Your own pulls wear your mark. v1 had a blue star; v2's star means
"starred person", so ownership rides the identity mark itself: your
avatar wears a 1.5px brand ring on every lens. One glyph, one
treatment, no new vocabulary.

"Changed since your last look" becomes "Recently updated (Clear)".
The old model guessed attention: tab visibility plus a glance-guard
timer advanced the baseline, and opening a PR silently removed its row.
Both guesses were wrong in ways users could see but not explain — a
hidden virtual desktop still counts as "visible", and click-vanish read
as the board losing things. Now there is one state and one visible
control: the lane's sub-line names the window out loud ("new or
updated in the last 14d") and its Clear button is the only thing that
moves the baseline. Retired: the visibility/pagehide stamping, the
glance-guard setting, per-row acks, Settings' "mark everything seen"
(the control lives at its point of effect now), and the shipped
toast's dismiss handler that quietly cleared the ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The brand ring around your own avatar didn't survive contact with the
owner: a halo on a photo reads as selection/focus, and lands as
decoration. Five candidates went head-to-head at real pixels — the ring,
a "you" word in the meta line, a corner dot (reads as online presence),
a brand-tinted repo#number (brand text implies a link), and v1's actual
treatment, which turned out to be substitution: authoredByMe() replaced
the avatar with a blue faStar outright.

The pick is a hybrid of the ring and v1's star: the identity slot holds
a minted coin — the brand ring as the coin's edge around a filled brand
star, the same visual mass as a face so row rhythm holds. Glyph
candidates were auditioned in the coin at 12px: a pen reads as an edit
button, a feather reads as another pen, the @ (GitHub's own author:@me)
was the runner-up; the star won on filled mass and a decade of v1
muscle memory. The star vocabulary stays one concept — people who
matter to you: corner star on someone you starred, the full coin for
its limit case, you.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The you-mark's final form, arrived at through a fifty-idea sweep. The
previous iteration — ring around the avatar plus a star badge at the
top-right corner — was close but was still TWO marks: a ring that
faintly pleads "selected" and a badge that faintly pings
"notification", three strokes negotiating one corner at 16px.

The seal fuses them: the brand ring parts at the upper-right (an 80°
gap, round-capped terminals) and the filled brand star sits IN the
opening, on the ring's own circumference. One object. A parted ring
cannot read as a focus ring, because focus rings never break — which
retroactively solves the objection that sank the original plain halo.
Your face stays; the star crowns the coin rather than replacing it.

Both densities get exact geometry (22px avatar: r=13 ring, star at 45°;
16px: r=10, slightly wider gap), colors are --brand/--surface only so
both themes come free, and the mark never animates: identity, not
state. Candidates rejected across the whole arc are recorded in
DESIGN.md so the decision holds its shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full-strength brand on both ring and star made the seal the loudest
thing on a quiet row. The mark needs one voice, not two at equal
volume: the ring drops to 45% opacity — an edge you feel more than
read — while the filled star stays full brand and carries the scan.
Opacity rather than a second token, so both themes keep their own
brand value and the tint tracks it for free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants