Skip to content

Pulldasher frontend v2: a React + TypeScript rewrite#477

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

Pulldasher frontend v2: a React + TypeScript rewrite#477
jarstelfox wants to merge 341 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 19:50
Two conventions were each spelled in multiple places:

- "is CR/QA signed off" exists as crDone/qaDone precisely so every view
  agrees, but derive() recomputed it inline as crMet/qaMet and Review.tsx
  spelled the raw comparison twice more. All now call the helpers, so a
  future change to the done definition lands in one place.

- The lane cap convention (user setting over lane default, 0 = no cap,
  compact packs 50% more) lived in Lane and was half-reimplemented by the
  "Yours to do" lane, which dropped the compact bonus — the top lane on
  the page was the only one not showing more rows in compact density.
  Extract laneShown() and use it from both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three naming fixes from the cleanup audit, no behavior change:

- qaingBy broke the *By convention: every sibling (crBy, qaBy, recrBy,
  devBlockedBy, ...) is a string[] of logins, but qaingBy was a single
  login-or-null, so the natural array reading was wrong at every call
  site. Now qaingLogin, which says both the meaning and the shape.

- app.tsx computed inScope and immediately aliased it (const scoped =
  inScope), forcing readers to hold two names for one value. The memo is
  now just scoped.

- Classic.tsx's devBlock/deployBlock were boolean predicates named as
  nouns; isDevBlocked/isDeployBlocked read as the questions they answer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Date.parse(x) / 1000 was spelled raw at ten call sites with no name for
"ISO string → epoch seconds". Add epoch() to format.ts and swap the
production sites in (closedEpoch now builds on it too). Sites where the
conversion isn't epoch-seconds semantics (millisecond comparisons in
sorts) are deliberately left alone.

The .settle-once and .reveal-stagger stagger rules were the same
animation and near-identical delay schedules, maintained twice. Merge
them into shared selectors with one schedule; the fold reveal gains the
160ms fifth step first paint already had, an invisible difference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The quiet outlined button existed as the same long className pasted four
times in the repo manager (one brand-toned) and three times in Settings
(the h-8 panel variant). Any radius or hover tweak needed seven
synchronized edits.

One QuietButton in bits.tsx now carries the shape (sm row action, md
panel action) and tone. The two intentional outliers stay inline: the
icon-only close button (square, centered) and the destructive
clear-settings button (armed red state) are different designs, not copies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four keyboard/focus gaps from the polish audit:

- Row actions were mouse-only by CSS catch-22: visibility:hidden elements
  can't take focus, so .row-actions:focus-within could never fire and
  per-row refresh had no keyboard path at all. Reveal on the ROW's
  :focus-within instead, so tabbing into the row shows the buttons before
  they're reached.

- The Settings drawer said aria-modal but let Tab walk out into the live
  board behind the scrim, and the board still scrolled under it. Wrap Tab
  at the panel's edges and lock body scroll while open.

- Segmented claimed role=radio, which announces "use arrows" to screen
  readers, but every option was its own Tab stop and arrows did nothing.
  Roving tabindex + Arrow/Home/End now honor the contract.

- The Filters category row claimed role=tab with no tablist, no arrows,
  and no tabpanels — an invalid half-pattern that duplicated Segmented's
  styling anyway. It now IS a Segmented, inheriting the fixed keyboard
  behavior and deleting the copy.

Also: the header wordmark is the page's h1 (there was none, so heading
navigation had no root above the lane h2s).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
State-visibility fixes from the polish audit:

- The "refreshing N PRs…" and "done" confirmations were plain spans that
  appeared and vanished — silent to screen readers. Both are now
  permanently-mounted role=status live regions, matching the connection
  dot's established pattern.

- The sign-off pip trigger, the row-flag cluster, and the primary-repo
  star had hit areas of roughly 14-22px — far under the ~40px touch floor
  for what the code itself calls the board's most decision-critical
  marks. Padding plus negative margin grows each tap target without
  moving anything in the layout.

- Disabled reveal checkboxes in Filters looked identical to enabled ones
  in some browsers; disabled:opacity-40 makes the state visible.

The hollow .pip-off ring also moved from --border (~1.5:1 against the
surface, invisible to low vision) to --ink-3 (≥4.5:1 both themes) in the
previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The polish audit's copy/drift findings, applied:

- The Legend showed "Can't merge" in amber (badge-blocked) while the real
  status renders gray (badge-hold) — the one component whose job is
  teaching the colors taught the wrong one.
- Curly apostrophes everywhere users read them (Can't/QA'd/PR's/won't
  had straight quotes among curly siblings).
- "PRs sign-off, on the board" on the CR leaderboard wasn't a sentence;
  now "PRs CR'd" matching its QA sibling.
- Classic's column set said "CI Blocked / Deploy Blocked / Dev Block" —
  the odd one out is now "Dev Blocked".
- Button/label casing: the Filters repo actions (unmute/show/mute) match
  their Sentence-case twins in Settings; the main search placeholder
  capitalizes "Filter" like every other filter input; Classic's empty
  column text is "None", not "none".
- Same-role elements now share metrics: the status swatch dots (8px),
  the "+N more" and fold-summary row padding (9px), the muted section
  headers (text-xs), and both lenses' shipped fold (label + hint split).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Browser QA showed the flag-cluster popover trigger exposing no reliable
accessible name in the accessibility tree; every other icon/cluster
button on the row carries one. Name it from its own flags ("row flags:
recent changes, deploy block") so a screen reader hears what the cluster
holds before opening it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The QA lane predates the reviewer-centric rework and got none of it: all
repos flattened together, a sort that never looked at effort, and a
vestigial pin-my-own-QA term whose subjects the filter already routes to
"Yours to do". On a self-review team QA is the bottleneck, so the lane
most in need of the primary-repo treatment was the one without it.

Split the pool by primaryRepos like the CR queue (lane leads with your
repos, "to QA in other repos" folds into the rest group), and sort by
claim state (unclaimed QA needs a volunteer, someone-else's claim sinks),
then weight (lighter tests first, unknown ranks M-ish like crScore),
then age. The dead self-claim sort term is dropped, not moved: those PRs
never reach this lane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a PR cleared its new/updated dot only for the session: acked was
an in-memory Set, so every reload resurrected every dot you'd already
dealt with, defeating the point of per-row acknowledgment. Worse, within
a session the Set hid a pull's LATER changes too — once opened, a PR
could never go "updated" again until the whole-board last-seen advanced.

Store ack TIMES (pull key → epoch), persisted next to lastSeen. Fresh is
now "changed since your last look AND since you last opened it": reloads
keep your cleared dots, and a pull that changes after you looked earns
its dot back. Acks older than the last-seen stamp are pruned on write
(they can't affect any dot), so the blob stays bounded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 15-person queue has no way to say "not today" to one PR: you either
review it, mute its whole repo, or scroll past it every session. Snooze
is the missing middle. The row actions gain a clock button that hides
the pull from the whole board for 24 hours; any change to the pull (a
push, a comment) voids the snooze early, so punting a PR can never hide
what happens to it next.

Client-only by design: snoozes persist per-browser through the same
localStorage pattern as acks and last-seen, with expired entries pruned
on write. The master reveal (show-all) surfaces snoozed rows like every
other hidden group, and Settings' Data section shows how many are hidden
with a "Bring back snoozed" undo. QuietButton picks up the disabled
style the audit noted it lacked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The comments table is loaded on every getPull for participants and
signature source-tagging, then thrown away before toObject() — so the
board can't tell a PR that's been discussed to death from one nobody has
touched. Ship the two facts a queue can use: comment_count and
last_comment_at (the max created_at, null when there are none).

Additive only: no schema change, no new query, and clients that don't
read the fields are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The server now ships comment_count and last_comment_at on pull status.
Use them where neglect is the question: the aging flag's hover detail
says whether an old un-CR'd PR has had no discussion at all or went
quiet after a debate ("Last comment 12d ago"), which is the difference
between "nobody ever looked" and "waiting on a second opinion". Typed
optional, so older servers without the fields render the flag unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The discussion-aggregates commit dragged a whole-file quote/indent
reformat into models/pull.js, turning a 9-line additive change into a
474-line diff against master. The v1 board must stay reviewably
unchanged, so restore master's formatting byte-for-byte and reapply only
the comment_count/last_comment_at addition in the file's own style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both boards now point at each other: v2's header has carried a "v1
board" link since it was built, but v1 offered no way to discover v2
short of typing the URL. A small quiet "v2" link joins the right-side
utility cluster (connection dot, open count), styled to recede until
hovered. Ten added lines in the file's own Chakra idiom; nothing else in
the v1 tree changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Compact rows had two silent-loss failure modes. The metric rail (CR/QA
pips, age — the board's most decision-critical pixels) sat inside the
same squeezing flex line as everything else, so a crowded row overflowed
into the list's overflow-hidden and clipped the rail with no scrollbar
or ellipsis. And the title/note truncated with no recovery at all: no
tooltip on the title, mouse-only tooltips on the note.

Structural fix first: CardShell gains a rail slot rendered OUTSIDE the
content column in compact, so the content clips internally (min-w-0 +
overflow-hidden) and the rail is unsqueezable by construction. The
comfortable layout keeps the rail on its wrapping meta line, unchanged.

Recovery second: the flag cluster generalizes into RowDetails — in
compact its panel leads with the full title and the action/context line,
and a flagless row still gets a quiet ellipsis trigger, so every compact
row keeps exactly one hover/tap point for whatever it clipped. The
compact title also gains a native tooltip for mouse users.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The signature-ledger popover had no max width and nowrap content: a long
login grew the panel leftward past the viewport edge with no way to
scroll it back. Cap it at 300px and let logins break; only the timestamp
keeps nowrap.

The Settings and Filters lists truncated repo names and logins with no
recovery at all, and the filter chip's tooltip was a static string that
didn't match its truncated summary. Every truncating list row now
carries the full text as its tooltip, and the chip's tooltip is the
actual summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In compact density the title had an 8ch floor while the meta chips
(badge, notes, repo#, diff) refused to shrink, so at real window widths
the title collapsed to about two words. Worse, the details popover's own
trigger was the last clippable child of the overflow-hidden column: on
exactly the rows crowded enough to clip content, the recovery point
vanished with it, and StatusBadge / RepoRef / DiffSize / FreshTag had no
recovery surface at all.

Flip the priority and make recovery structural:

- The title's floor rises to 16ch and the do-note is allowed to shrink
  (to an 8ch floor) despite .badge-do's flex:none, so the title wins the
  squeeze instead of losing it.
- Compact keeps only the short chips inline (fresh tag, imperative or
  badge, repo#). The wait-note, diff size, and full flag list move into
  the details popover.
- The details trigger renders beside the rail, outside the clipping
  column, so it survives any squeeze — and collapses to the leading flag
  plus a +N count to stay narrow.
- The compact popover header becomes the row's complete recovery
  surface: full title, fresh state, status badge, repo#, diff size, and
  the action/context line, above the flag explanations.

Verified in the dummy board at 860px and 700px compact: every row keeps
its trigger, zero clipped rails, titles floor at ~16ch (the only
shorter ones are titles that fit whole), and at 700px all 41 rows with
clipped chips recover everything through the popover. tsc, build, and
all 54 tests pass; all four views render error-free in both densities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Even after the title-first squeeze, Classic's ~400px columns showed only
eight or nine characters of title. Two standing costs were to blame:

- .row-actions hides the copy/snooze/refresh cluster with visibility,
  which still reserves layout space — every compact row paid ~70px of
  rail for buttons that only appear on hover.
- The inline status badge and repo#number are rigid nowrap chips, so in
  a narrow column they (not the title) kept the leftover width.

In compact the actions now float over the row's tail on hover (absolute,
anchored just left of the rail, zero reserved width — hovering no longer
shifts the layout either), the status collapses to a colored dot with
the label as its tooltip, repo#number moves to the details popover with
the rest of the recovery surface, and the trigger's flag label caps at
9ch. Comfortable density is unchanged.

Verified on Classic at 385px columns in the dummy board: rail width
drops 253px to 173px on every row, zero clipped rails, and visible
title length goes from ~9 characters to 19-23. tsc, build, and all 54
tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two rounds of squeeze-priority tuning still left compact trading text
for density — a titles-first floor and a popover recovery surface are
both just ways of choosing which text to hide. Drop the premise instead:
compact keeps its tight geometry (16px avatar, slim padding, one flowing
line in the common case) but wraps when the column is narrower than the
content. Nothing ellipsizes, in any column, at any width.

That unwinds the compensating machinery from the last two commits:

- The meta line is identical in both densities again — badge or
  imperative, repo#, diff size, wait-note, flag cluster — with every
  truncate/max-w cap removed (the status dot and the popover's recovery
  header go away; the popover is back to pure flag explanations).
- The rail rides the same wrapping flow in compact; ml-auto keeps it
  right-aligned on the line, and in a narrow column it drops below the
  text instead of starving it (a rigid rail left a 385px Classic column
  only 156px of text width).
- The hover-actions overlay from the previous commit stays: reserving
  invisible button width would now cost wrap lines instead of clipped
  text, which is just as real.

Verified in the dummy board at 1280px: zero truncated elements in all
four views (My work, People, Classic, Review). Review's full-width lanes
keep 84 of 90 rows at a single 27px line; Classic's 385px columns pay
the honest cost of full text, wrapping to ~66px median (down from 100px
with a rigid rail). tsc, build, and all 54 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Motion (from a full inventory of every keyframe and transition):

- fresh-flash drops 1.4s to 800ms (delay 300 to 200ms) and animates
  background-color instead of the background shorthand — a socket burst
  that flashes many rows now settles fast and interpolates one paint
  property.
- spin-once refresh ack drops 600 to 400ms; the settle entrance drops
  300 to 240ms with a tighter 30/60/90/120ms stagger; the notice banner
  drops to 200ms. A big "+N more" reveal stops animating past the first
  two dozen rows — starting 50+ animation instances in one frame is
  main-thread work nobody can see.
- Every hover state that snapped in a single frame now eases over 150ms
  like its neighbors: closed rows, the flag-cluster and sign-off ledger
  triggers, signature list rows, fold summaries, "+N more", the Filters
  popover rows/buttons, and the v1 link. Interactive triggers get the
  shared .pressable (which already carries the house 150ms ease-out and
  press scale); the three inline transitions that defaulted to plain
  `ease` now use ease-out to match it.

Performance (every claim verified in code by the audit):

- deriveCached ignored time, so the store's own 60s heartbeat was
  silently defeated: a quiet pull returned the same cached DerivedPull
  forever, Row's memo bailed, and ages, "Nm ago" text, and the
  starved amber/red ramp froze until the pull's next real socket
  update. A minute bucket in the cache key makes the heartbeat honest.
- humans/bots in App are memoized (they rebuilt on every keystroke,
  settings toggle, and heartbeat); People's rest-lane filter was
  O(n²) via Array.includes and now uses a Set; the notifications
  effect scanned every pull per publish even with notifications off
  and now bails first; ClosedRow is memoized like Row (closed pulls
  never change, but their folds re-render on every publish).

Verified in the dummy board: zero console errors across all four
views, 150ms computed transition-durations on the previously snapping
hover surfaces, the new flash timing live. tsc, build, and all 54
tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The lens had a status bar, two leaderboards, starvation, and
merge-time-by-size — a taste of the board's economics. Everything else
the wire already carries went unread. Add nine cards of pure reductions
over the same open pool + 14-day closed window, organized into labeled
bands so thirteen cards read as a story instead of a wall:

- Flow (last 14 days): Shipping (merges per day as a mini column chart,
  this-week-vs-last delta, and median time to FIRST CR — the
  responsiveness number time-to-merge hides), Review pulse (CR+QA
  stamps per day, the input-side twin), and the existing merge-by-size.
- The board right now: Review debt (CR/QA stamps still owed, re-stamps
  a push invalidated, unclaimed QA), age mix (bucketed with the same
  amber/red thresholds the rows use), review-effort mix by weight,
  Friction (conflicts, blocks, CI red, drafts — stuck on something no
  stamp will fix), open PRs by author, and by repo (with each pile's
  awaiting-CR share and oldest age).
- People: the two leaderboards, starvation, and new Give-and-take
  (distinct PRs you reviewed vs stamps you received, self-stamps
  excluded — the review-economy balance).

All ten new reductions live in model/stats.ts with unit tests (54→64).
The mini column chart and the XS→XL weight ramp move into stats/parts
as shared pieces (MergeSizeCard's local copy deleted).

The dummy backend re-dated pulls into the present but left signature
timestamps frozen at fixture age, so every stamp-timeline stat read
empty in dummy mode; it now spreads signatures across each pull's
lifetime the same way it re-dates the pulls themselves.

Verified in the dummy board: all thirteen cards render with live
numbers, zero console errors, zero truncated text. tsc, build, and all
64 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The server already serves v1 and v2 from the same process — the only
split was the build. Chaining build:v2 into postinstall makes the
existing deploy (docker build → npm install --unsafe-perm) produce both
frontends with no Dockerfile or run-command changes. This is the hard
chain on purpose: a v2 build failure now fails the deploy instead of
silently shipping a stale or missing /v2.

The dependency trees stay deliberately separate (no workspaces): v1 is
React 17 + webpack, v2 is React 19 + Vite, and hoisting into one
install graph could change what v1's webpack resolves — the one thing
this branch has provably never touched. build:v2 also switches from
npm install to npm ci so image builds are reproducible off the
committed lockfile.

Verified: npm run build:v2 passes with npm ci (lockfile in sync,
dist built); rollback is unchanged — master's postinstall has no chain,
so redeploying master builds v1 only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two CSS bugs and one design debt. The term column was a fixed 74px with
flex-none children and no wrap, so any wide sample — the two row-flag
examples, the new+updated chip pair — overflowed straight into the
definition text and overlapped it. The term cell now right-aligns and
wraps within its own 88px column, so a wide sample folds to a second
line instead of colliding.

The redesign turns the flat 15-row glossary dump into a grouped key:
Sign-offs, Reading a row, Badges, Catching up, and Keys, each under a
small uppercase header with a hairline rule. The keyboard row becomes
four per-key rows with real keycap chips instead of a bold "/ j k c"
string; badges use their inline (text-sized) variant; the panel caps at
85vh and scrolls for short windows.

Verified in the dummy board: 18 rows, zero term/definition overlaps
(measured every row's boxes, including children escaping the term
cell), all five groups and keycaps rendering. tsc and build pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The lane-length setting only worked where rows happened to flow through
<Lane> or an explicit laneShown() call. Four call sites bypassed it
with hardcoded Truncated caps: Classic's six columns (fixed 15 — the
lens the setting most visibly ignored), FoldRows (every "rest of the
board" fold), and the two recently-shipped folds in Review and My work
(default 30). All four now route their default through laneShown(), so
the user cap (and its 1.5× compact multiplier, and 0 = uncapped)
applies board-wide.

Verified in the dummy board: with the cap set to 5 in compact, Classic's
large columns fold at 8 rows with honest "+N more" counts, small
columns are unaffected. tsc, build, and all 64 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deep in a long lane, nothing on screen said which section you were in
— "Yours to do" scrolled away with its rows. Every section header now
sticks just below the app header: the lane GroupHeader (Review, My
work, People, and the rest-of-the-board groups), Classic's six column
headers (each pins independently over its own column), and the Stats
band labels.

The app header's height varies — its toolbar wraps on narrow screens —
so a ResizeObserver publishes the measured height as --header-h and
every sticky header offsets from that instead of a hardcoded top.
Headers sit at z-5 (under the z-10 app header, z-50 popovers, z-100
settings) with the page canvas as their backing so rows scroll cleanly
beneath; the spacing under each title moved from margin to padding so
it's part of the opaque surface.

Verified in the dummy board at a width where the header wraps to 99px:
scrolled mid-lane, the lane header pins at exactly 99px from the top in
Review, all three tall Classic columns pin independently, and the Stats
band label pins the same. tsc, build, and all 64 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In production one Express process serves v1 at / and v2 at /v2, but in
dev the two frontends run as separate servers (webpack :8080, Vite
:5173) — so localhost:8080/v2/ 404'd and the navbar cross-links were
broken locally. The webpack dev server now forwards /v2 (including the
websocket, which carries Vite's HMR) to the Vite server, so :8080 gives
the same single-origin, side-by-side experience as production.

Dev-config only: webpack.prod.config.js and the deployed v1 bundle are
untouched. If Vite isn't running, /v2 just 502s and v1 is unaffected.

Verified: on :8080, / serves the v1 bundle and /v2/ boots the full v2
board through the proxy (61 rows in dummy mode), with the navbar links
crossing between them on one origin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two audits (cursor/affordance inventory, hover-dependence/modality) drove
one pass. The headline finding: Tailwind v4 ships no button cursor rule
at all, and only five elements in the app set one — so roughly 27
buttons (nav tabs, row actions, segmented controls, every quiet button,
"+N more", the column headers, all the popover triggers) showed the
arrow. One global rule now declares the convention once: buttons (except
disabled), summaries, and checkbox labels get the hand.

Pointer correctness:
- ClosedRow raised its decorative avatar and repo#/timestamp cluster
  above the row's stretched click layer, punching dead zones into an
  otherwise fully-clickable row; CardShell did the same to inert avatars
  whenever no onPerson handler exists. Raising is now conditional on the
  content actually being interactive — verified by hit-testing: both
  former dead zones now resolve to the row's link.
- The RowDetails trigger set cursor-default while hover-highlighting —
  a self-contradiction; it inherits the pointer now.
- The two hand-authored :hover rules (a:hover, the row-actions reveal)
  are wrapped in @media (hover: hover) like every Tailwind hover:
  variant already is, so touch taps can't stick them on.

Touch and keyboard reach:
- AgeStamp's second clock ("last activity") lived only in a native
  title — mouse-only, invisible to touch and screen readers. It's now a
  hover/tap popover with both clocks and an aria-label.
- The SigPips panel restates "N of M" so a tap shows how many stamps
  are still needed, not just who signed.
- A shared .hit utility extends sub-24px targets (avatars at 16-22px,
  row actions, quiet buttons, the star, the Filters text buttons) by
  5px per side via a pseudo-element — no layout movement.

Honest affordances:
- Filters repo rows highlighted on hover but only their bare checkbox
  was clickable; a label now wraps checkbox+name+count so the row does
  what its hover promises (the people rows already worked this way).
- Classic's column headers had zero hover feedback and no disclosure
  cue beyond a native tooltip: they get the rotating chevron, a hover
  tint, and the house transition.
- Active-but-still-clickable states (starred star, armed destructive
  confirm, active filter chip, selected person chip) gain the hover
  feedback their inactive branches already had.

Verified live: pointer cursor confirmed on every previously-arrow
control probed; avatar hit extension catches clicks 3px outside the
circle; the age popover reads "opened 33d ago / last activity 35m ago";
the CR panel shows "· 0 of 2"; column headers show the chevron with a
150ms hover; label-wrapped filter rows toggle from the row text. tsc,
build, and all 64 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Below 720px the copy/snooze/refresh cluster was display:none with no
replacement — hover-reveal has no phone story, so the actions simply
didn't exist on touch devices (flagged by the pointer-modality audit).

Every row's rail now carries a quiet kebab below 720px that opens a
tap-friendly labeled menu (Copy branch name with the branch shown,
Snooze until tomorrow, Re-fetch from GitHub) through the house
click-to-open popover — no hover anywhere in the path. The three
actions extract into a shared useRowActions hook and one icon set, so
the desktop cluster and the phone menu can never disagree on behavior;
the desktop cluster is unchanged and the kebab is hidden at >=720px.

Building it exposed a general Popover bug: positioning anchored to the
trigger with no viewport clamp, so a right-anchored panel wider than
the space left of its trigger hung off a phone screen. place() now
measures the panel (second pass after first paint) and clamps to 8px
gutters — every popover benefits, not just the menu.

Verified at 375px in the dummy board: the kebab is on every row, the
menu opens fully on-screen (16..288 of 375), Copy shows its feedback,
and Snooze genuinely removes the row; the age popover clamps too. At
desktop width the kebab is hidden and the hover cluster is back. tsc,
build, and all 64 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Team lens needs two model-layer pieces before any UI exists: a
per-browser list of teammate logins (deliberately NOT GitHub teams —
the people you actually work with rarely match the org chart, and a
setting needs no server or DB change), and the rules for how a
team-authored pull sorts into review buckets.

teamBuckets mirrors People.tsx's per-person reviewable/mine/rest split
generalized to a member list, so the two views can never drift on what
"reviewable by you" means. A needs_recr pull naming you in recrBy lands
in stamped, not reviewable: your stale stamp is a personal to-do that
Review's "Yours to do" already surfaces.

isBotLogin moves from an app.tsx local into model/visibility.ts so the
upcoming team picker can exclude bots without duplicating the check or
importing from app.tsx (which would cycle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jarstelfox and others added 29 commits July 23, 2026 15:39
Destructive removals wore the same ✕ as close/dismiss, so erasing a
saved thing looked like shutting a panel. A stored thing being deleted
(a code region, an emptied roster) now wears a trash glyph; ✕ keeps
meaning close.

The owner's question "how do I remove a team?" exposed the deeper gap:
a roster could only be deleted after being emptied member by member,
when a trailing button finally appeared. Its management panel — the
same door that renames it and edits its members — now carries an
arm-then-confirm Delete team action, so the place a roster is managed
is the place it can end. The empty-roster shortcut stays (instant:
there is nothing left to lose).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hover verb dock held the rail's only word-buttons; every other row
affordance already speaks in glyphs. One glyph per verb now — a clock
for snooze, a hand raised to take the review for claim — with the
standing-vs-offer split carried the same way it always was: an offer
reveals on row hover, a choice you made stands in place, plus
aria-pressed for the state. The words the glyphs gave up move whole
into title and aria-label, and the kebab's claim item gains the same
hand so the verb reads identically in both homes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "pulldasher" wording next to the mark popped in and out abruptly at
the 2xl breakpoint (display:none either side of 1536px). It is now one
span per letter, dealt: letters slide out from behind the deer
left-to-right on show and tuck back toward it far-end first on hide —
the same gesture as the board dealing you a review — with the house
quint easing. Transforms and opacity only, and the h1 is absolutely
positioned, so neither state ever reflows the header; visibility flips
on the wrapper after the letters land. The component starts hidden and
flips on the first painted frame so page load gets the one deal-in.

The global reduced-motion rule now also zeroes animation and transition
delays: stagger-by-delay content (the settle entrance, these letters)
otherwise sat invisible for its delay with durations already zeroed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2 supports the most recent commit only — earlier iterations of its own
storage and URLs are legacy by decree, so the compatibility shims go:

- settings.ts loses the whole load-time migration block (repoPrefs
  'mute', mutedPeople, starredPeople/myTeam folding). A blob from an
  older build simply falls back to field defaults.
- Scope.notAuthors is required now; the persistent store's defaults
  merge always supplies it, so the ?? [] and ?. dances at every call
  site were guarding nothing. RepoFilter/PeopleFilter take the real
  Scope type instead of their own inline subset.
- describeHash forgets the retired person=/team= params and the
  'people' lens alias — nothing can apply them anymore.
- Dead code found by the audit sweep: FreshTag (bits), MiniColumns
  (stats/parts), CheerTone, and the cheers baseline's nagLevel field
  with nagStepFor/NAG_STEPS — carried state whose own comment said
  no toast reads it anymore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The turn rotation was computed three times per board tick — once for
the rows (rowOpts), once inside desktop notifications, once inside the
cheers evaluator — and the "is this claimed" predicate existed as four
private copies (store, deal, cheers, notifications), each with a
comment apologizing for the duplication. Three surfaces reading the
same question three ways is how they drift into disagreeing about
whose turn it is.

app.tsx now computes pools/turns once and hands the map to
useNotifications and useToasts (whose CheerInput requires it); the
claim predicate moves to model/reviewers.ts — the module whose doc
already explains why a claim IS a review request — and every former
copy delegates to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A trigger's text used to swap to its value when active ("People" →
"People · 4 people ×"), so narrowing the board reflowed the whole bar —
the last layout shift left in the header. Trigger labels are constant
now; the active state rides as a small absolutely-positioned corner
badge of 1–3 characters (a count, a lone weight's own letters, a −count
for an exclusion, the hidden ledger's quiet standing count), so no
selection, load, or clear can move anything. Full words live in the
hover title and aria-label; the per-trigger clear × folds into each
panel as a Clear row, and the hidden ledger's "hide them again" ×
moves into its panel for the same reason. The lens tabs share the same
CornerBadge for the My-work count, replacing the reserved numeral slot.

Badges wear the soft badge tint, not solid brand — the bar should
whisper what's narrowed, not shout it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "everyone except" scope survived the tri-state chips' retirement
with no UI writer left. Each People row now carries an "except"
quick-action beside "only": plain click excludes that person (struck
name, standing button, unchecked box), clicking again re-includes, and
per-login the allow and exclude lanes stay mutually exclusive. The
trigger badge reads −N for an excluding selection.

The Team lens's selected chips also drop their brand ring for the
same quiet bg-secondary fill the lens tabs and pinned chips use — one
active vocabulary everywhere, per the owner's "visually loud" call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two kinds of teams survived into v2 — org-defined config.json teams
and personal rosters — rendering as parallel chip rows in the Team
lens and preset buttons in the People filter, with different powers
(config teams couldn't be pinned, renamed, or lead your queues). The
owner's call: saved searches already cover "filter by team", so the
org layer goes. The People filter drops its preset chips (per-person
surface now), the Team lens reads personal rosters only, SiteConfig
stops parsing `teams` (an older config.json's key is ignored), and the
Team/BoardTeam types disappear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bits.tsx had grown into a 1,200-line bucket holding three complete
domains next to the generic scraps: the author-identity system (the
silhouette-and-the-star avatar marks), the age ruler (AgeBaseline /
AgeStamp), and the rail's pip family (Pips / SigPips / CiStatus).
Pure cut-and-paste moves into components/identity.tsx, age.tsx, and
pips.tsx with imports updated everywhere; bits.tsx keeps the small
shared controls and vocabulary. No behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A roster search's gloss lists its members, and a three-login roster ran
straight out of the panel's right edge, under the pin control. The row
button is a flex-col with items-start, so its children shrink-to-fit:
without an explicit width bound the gloss span grows to its content and
`truncate`'s overflow-hidden has nothing to clip against. max-w-full on
both row lines gives the ellipsis something to bind to.

(Found on the owner's real board — the dummy rosters were too short to
ever overflow, which is also why a scrollWidth probe missed it: an
unconstrained span's clientWidth grows with the content.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Danny's report: PRs with red CI were kept out of the review queue,
which we've never done — CI false-negatives are common enough that
whether to review a failing build is the reviewer's call, and authors
shouldn't babysit re-runs to get review attention.

Root cause was the status chain: ci_red outranked needs_cr/needs_recr,
so a failing build could never read as "needs review" and every
reviewable pool (queue, deal, team buckets, cheers backlog, re-stamp
obligations) skipped it by construction. CI now gates readiness only,
exactly like ci_pending always has: ci_red means FULLY SIGNED OFF with
a red build — the author's last remaining move — and a red build
mid-review keeps its needs_cr status, flows through the queues, and
shows its state on the rail's CI pip.

The author's side keeps its full breadth via the ci FLAG rather than
the status: Fix CI stays their verb, and the CI-failed nudge/desktop
alert still fires the moment a build goes red, stamped or not. The
Review lens's "CI failing" fold now holds only the signed-off-but-red
pulls, and its gloss says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The letters were also dealing in on every page load — a board opened
ten times a day shouldn't perform an entrance. The component now
mounts straight into its final state (no state change, so no
transition) and the deal runs only when a resize carries the wordmark
across the 2xl breakpoint, in or out of view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Danny's screenshot showed author names truncated to single letters
("B.", "m.") in the 300px People panel. The cause was layout math, not
truncation: the hover-revealed "only"/"except" buttons hide via opacity,
which still reserves width, and together with the worded Hide button
~200px of every row was chrome before the name got a say. "except" was
the widest single word on the row.

Two changes claw the width back:

- The ExceptButton is deleted. The "everyone except" lane itself stays
  (hash xauthors=, saved searches, the -N trigger badge, the app-level
  filter all still work) - the gesture moves to shift-clicking the row's
  checkbox, taught by the checkbox title. The excluded state still
  stands: unchecked box, struck-through name, -N badge. The gesture
  rides onChange, not onClick: React synthesizes checkbox change events
  from clicks, so a preventDefault-ed onClick still fires onChange and
  the two scope writes race (verified live - the stale-closure toggle
  overwrote the exclusion with a 24-name allow-list).

- The worded Hide/Show buttons in both filter lists become one shared
  EyeButton: an open eye on visible rows (click hides), a closed eye on
  hidden rows (click shows), brand tone for the show that overrides an
  org-level hide. Full words live in title/aria. The HiddenPanel's
  "Hide them again" stays worded - it's a batch ledger action where a
  bare icon would obscure what's about to happen.

Verified live on the dummy board: full names render, shift-click writes
xauthors= and unchecks the row, plain click re-includes, the eye hides a
person into the Hidden fold and the closed eye restores them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The star used to mean three different things: a repo-level "primary
repo" toggle, a person-level "add to your team" toggle, and the
read-only identity marks (corner star = teammate, seated star = you).
Same glyph, unrelated effects - the owner's verdict was that the
star-to-sorting link was illegible and the repo star near-useless in a
monorepo. The star now means exactly one thing: YOU.

- The repo star is gone, control and setting. Review's primary/other
  split always uses the inferred set (repos you've authored or stamped
  on the current board), which the manual list silently REPLACED before:
  starring one minor repo folded your main repo out of the queue.
  Repo-level intent keeps one lever: hide.

- The people-star controls are gone from the People filter and the row
  kebab. Rosters change in one place, the Team lens. The read-only mark
  survives as a lucide Heart at the avatar corner - same seat and size
  as the you-star (13px at -3 on the 22px row avatar) so the two
  identity marks carry equal weight, per the owner's call. Its title
  names the roster(s), and the avatar hover card gains an "On <roster>"
  line, so why-this-row-leads is attributed in place.

- No user-facing text says star/starred/unstar anymore; the Legend
  keeps "star" only to describe the you-seal glyph. starve/starvation
  vocabulary (queue fairness) is unrelated and untouched.

Candidate glyphs (rocket, handshake, heart-hands) were tested on the
live board at the mark's real 9-13px: only solid silhouettes survive,
and hand/rocket glyphs collide with the claim-hand and shipping
vocabularies. Heart won.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Danny's "muted repo still shows its pulls" prod report, root-caused
live: two cooperating defects.

First, scope was persisted twice. The hash is the view state, but a
localStorage shadow copy (pd2.scope) re-applied itself on every
hash-less load - and a months-old allow-list naming a hidden repo
counts as an "explicit reveal", so the repo's pulls stayed on the board
no matter how often it was hidden, then the state effect wrote the
stale scope back into the hash. Reproduced exactly with a seeded
pd2.scope on the dummy board. Scope now lives in memory + the hash
only (createMemoryStore); the old key is deleted on boot so nothing
can mistake it for live state. Opening a bare URL now always means a
clean, unscoped board.

Second, the org-level mute itself is retired, per the owner: the
server config's v1-era hideByDefault flag is now ignored client-side.
Org muting was a v1 concept that v2 half-honored - the client knew the
state but a reveal could permanently defeat it, and nobody could tell
which of the two systems was in charge. Now there is one: each user
hides a repo once (the eye in the Repos filter), their call, stored in
their own prefs. repoState's three-way logic collapses into a plain
two-state repoHidden; the "org" chip, the brand-tone override-show,
and the org set plumbing are gone.

The filters' hidden folds also gain a rotating chevron caret - the
bare "Hidden by you (N)" summary read as a label, not a door (owner
report).

Verified live: a seeded stale scope no longer resurrects (key deleted,
board unscoped), hash scope still applies, the eye-hide survives
reload with pulls withheld and the ledger counting them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three prod reports, one afternoon, two causes.

The h1 wrapping the logo + wordmark floats over the tab row at z-[1].
Its box always spans the wordmark's full width - even below 2xl where
the letters are invisible - so at narrow widths a ~150px transparent
strip sat exactly over "Review" (sm+) or over the lens menu's label
(phone), swallowing their clicks. That's both the "Review tab not
clickable" and the "select only clickable on the icon" reports.
Nothing in the h1 is interactive, so it takes pointer-events-none and
clicks fall through to the controls beneath. Verified by hit-testing
at 315px and 700px: elementFromPoint over the tab/menu label now
returns the button itself.

The My-work count badge rides 4px above its tab, and the tab strip is
an overflow-x-auto scroll container, which also clips vertically - the
badge's top was shaved off (prod screenshot). The strip gains py-1
compensated by -my-1: 4px of in-clip headroom, zero layout movement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hover-revealed "only" sat between the always-visible count and eye,
so hovering a row made a word fade in BETWEEN two standing elements -
reading as an interjection. Owner call: the transient control belongs at
the left edge of the right-floated cluster, so it surfaces beside the
standing pair instead of splitting it. Count moves out of the label
span; same order in both the People and Repos lists so the row grammar
stays shared.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The queue interleaved repos purely by score, which broke down under
asymmetric volume: the owner reviews both the monorepo and ops, and
ops - two devs' review capacity receiving twenty AI-assisted
producers' output - was monopolizing the screen. Hiding is wrong (he
IS an ops reviewer) and pure ranking would starve whichever repo
ranked last. The owner's call: priority ordering with a cap as the
flood bound.

The queue now renders contiguous per-repo blocks in the user's repo
order, each internally score-ranked with teammates leading (stable
partition - the priority decides which repo's run comes first, never
which pull is "best"), and each showing its best N before folding into
"+N more from <repo>" (Truncated, the board's one truncation
vocabulary). Starving PRs pierce the partition and lead the lane
regardless of repo - the fairness backstop must not sit below a repo
the viewer ranked last.

The order lives where repos live: the Repos filter list now SORTS by
the priority and each row wears a grip handle - drag to reorder, or
arrow keys on the focused grip (verified live: keyboard end-to-end,
drag by the owner's own mouse). Any reorder writes the full shown
order, so the first gesture makes every visible repo's place explicit.
Settings keeps only the per-repo cap (3/5/8/No cap, in the Board group
beside Lane length) - a Settings ordering list was built first and
deleted in favor of the one surface.

The shipped default order comes from a prod activity audit (90-day PR
volume; 17 of 25 watched repos are fully dormant): monorepo, dev
tooling, ops, then non-dev planning repos - dev before ops before
non-dev, the owner's taxonomy. An unlisted repo that wakes up trails
the list by its best pull's rank.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 3/5/8 options were tuned to the dummy board's tiny fixture; against
prod volume (the monorepo alone runs ~90 open pulls) a cap of 5 folded
away most of the queue and made the blocks feel like peepholes. The cap
is a flood bound, not a squeeze - it only needs to stop one repo from
monopolizing the screen, so it now scales like the Lane length options
one group up (10/25/50/No cap). Stored values from the old scale still
apply numerically; the control just shows no selection until re-picked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Team lens header laid every roster's name-chip AND every member-chip
flat in one wrapping row, plus an aggregate chip, a New-team ghost, and
the directory door - with two teams it wrapped into a blob where team
chips and member chips were indistinguishable. A 30-design sweep landed
on the reframe (chosen over the owner's first instinct of tabs): dissolve
the whole zone into ONE foldable roster list that looks the same at zero
teams or six.

- Each roster is a section: a disclosure caret, the team name (click to
  scope, shift-click to union), a count, an owed-re-stamp dot, and a quiet
  gear. Members render indented beneath - avatar, name, open count, owed
  pip - so each person's load reads at a glance. Your teams open by
  default (the overview is the point); the directory ("Everyone else")
  folds beneath, closed, so the stranger wall stays out of sight.
- CRUD lives in the gear: it opens the existing TeamPicker in a popover -
  rename in place, add/remove members by search, arm-then-confirm delete -
  so a roster's whole lifecycle is one predictable spot. "New team" stays
  the inline ghost at the bottom.
- Selection still writes the shared authors scope and still multi-selects
  by shift-click (people or whole rosters), unchanged - only the chrome
  around it changed.

Dropped the aggregate "Everyone yours" row: it was confusing (owner
couldn't tell what it did) and redundant - the home board already shows
every roster combined when nothing is selected, and clicking an active
team again or Reset returns to it.

Verified live on the dummy board with two seeded teams: sections fold,
team/person clicks write scope, the gear opens the rename/members/delete
panel, the directory expands to non-roster authors with their own counts
and owed pips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CI lens rendered all ~70 check contexts at rest, most of them green
— a full vertical screen of "all N green" rows the owner rightly called
a wall of not-so-useful info. A 30-design synthesis landed on the
minimal-reframe answer: show what needs a human, fold the rest.

- "Check health" now leads with a one-line fleet summary in its subtitle
  ("12 checks failing · 34 PRs · slowest ~8m") — the QA-engineer's
  one-glance read — then renders ONLY the failing checks, worst first,
  each opening into the PRs it fails. The healthy checks collapse into a
  single "N checks all passing" fold at the bottom, openable for a
  whole-fleet audit but never a wall.
- Timing is elevated, not lost: the slowest FAILING check rides in the
  summary (a slow, red check is the worst kind), and every row keeps its
  average run time. No failure or timing data is dropped — the ratio bar,
  N-of-M counts, and per-check run time are all still there.
- Two audiences, no mode switch: "Your broken builds" is pinned FIRST so
  a dev sees their own red builds without scrolling the fleet ledger, and
  it renders nothing when you're green (no news is good news) so it never
  costs a QA-engineer a scroll. The fleet ledger below serves the triage
  read.

Verified live on the dummy board: only the 3 failing checks show under
"Check health · 3 checks failing · 7 PRs", each opening into its failing
PRs; healthy checks (none in the tiny fixture; ~44 on prod) fold into the
count; "Your broken builds" leads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Stats line charts were read-only: three axis ticks, no per-point
labels, so you couldn't tell which point was which day/week, and no way
to isolate one series from a busy multi-line chart.

A build-vs-buy audit said BUILD, not buy: the data is tiny (the biggest
chart is 78 points across 3 series), and the hand-rolled SVG in parts.tsx
already themes through CSS variables with zero redraw on the light/dark
toggle — a property every canvas library (uPlot, Chart.js, ECharts)
would break, and one no SVG library improves on. Recharts/visx would add
100-150KB to solve what's a few lines here (Recharts now even pulls in
Redux Toolkit). So the interactivity is hand-rolled into the one shared
LineChart, upgrading all three real charts (Shipping pulse, Monthly
volume, Time in review) at once, with no new dependency:

- a hover crosshair + tooltip: the vertical line snaps to the nearest
  point, dots mark each visible series/bar, and a tooltip headed by that
  point's date/week/month lists every series' value. Callers pass a
  pointLabels array (the sparse axis ticks can't name every point).
- a click-to-isolate legend: each legend entry is a toggle; hiding a
  series rescales the axes to what's left, so you can read one line's own
  trend without the others' range swamping it, or drop the throughput
  band's noise to judge opened-vs-merged alone.

Zoom/pan was deliberately skipped: at 12-26 points per chart everything
already fits on screen, so d3-zoom would be a dependency for no value —
revisit only if a future card ships hundreds of points.

Verified live on the Stats lens: hovering shows "Jul 17 · merged/day 1 ·
stamps/day 10" with the crosshair; clicking a legend entry hides its
series, dims the entry, and rescales the remaining line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A three-agent adversarial review of the Team/CI/Stats redesigns surfaced
real defects (verified before fixing; false positives dropped):

CI (most severe — would have shipped a bug):
- The new "checks all passing" fold had defaultOpen={allGreen}, so a
  fully-green board auto-expanded every healthy check on load — exactly
  the wall the redesign removed. Now always collapsed.
- Relabeled "N checks all passing" to "N checks clear": a check that is
  running-but-not-failing lands in this group, so "passing" overclaimed.
  Also dropped the stray caps={false} so the eyebrow matches its siblings.
- The summary counted failing PRs from the board's merge verdict while
  the checks came from the health ledger; a config-ignored check could
  make the two numbers disagree. Both now derive from the ledger.
- The lane header count showed all tracked checks (~62 on prod) on a lane
  whose point is to not show them; it now counts the failing checks.

Team:
- Restored the dropped force-open of the directory when the current
  selection is a non-roster person — otherwise the selected row hid
  inside a closed fold with nothing on the lens explaining the board.
- Merged the directory's caret + label into one toggle with
  aria-expanded, and made it a no-op while force-open so a click can't
  silently flip the stored fold state.
- Truncate long team names (min-w-0/flex-1/truncate), matching the member
  rows and every other filter list.
- An empty roster no longer highlights as the active selection at home
  (sameSet([],[]) is vacuously true — now guarded on member count).

Stats:
- touch-action on the chart svg was 'none', which blocked page-scroll for
  a finger dragging over a full-width chart; now 'pan-y' (vertical scroll
  stays native, horizontal drags still feed the hover scrub).
- Hardened the shared LineChart against ragged-length series: the bar
  crosshair dot and both tooltip row maps now skip missing values instead
  of fabricating a 0 (dormant today, but this is shared infrastructure).

Verified live: CI summary reads "3 checks failing · 7 PRs" with only the
failing checks shown; the directory force-opens for a non-roster pick;
383 tests, tsc, build, eslint all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bucket/CI/weight classification (status.ts, ci.ts, visibility.ts,
format.ts, types.ts) has lived only in frontend-v2, and is about to gain
a second consumer: a server-side /api/v1 that classifies PRs so the
what-to-review / batch-review skills can stop re-deriving buckets in
bash over a direct DB connection.

Rather than hand-write a second derive() on the backend (a fifth place
the bucket logic would live, guaranteed to drift), move the five pure,
dependency-free files into a top-level shared/ that both the frontend
and the plain-JS Node backend consume from one source. Relocation only,
no behavior change:

  - git mv the 5 files to shared/, mirroring the src/ nesting so their
    own internal cross-imports need no edits.
  - Repoint the 63 frontend importers via a deterministic codemod
    (relative paths recomputed per file). The 2 model tests stay in
    src/ (repointed) so they keep running under the frontend's vitest.
  - Let Vite's dev server read one level up (server.fs.allow) now that
    the modules sit outside frontend-v2/.

Verified: tsc --noEmit clean, 383/383 vitest pass, vite build succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The backend is plain-JS ESM on Node 24; the shared derive is TypeScript.
Rather than lean on Node's still-experimental type stripping at request
time (and ship .ts to prod), compile the shared module ahead of time:
one esbuild call bundles shared/index.ts + its 4 deps into a single
self-contained shared/dist/index.js the backend imports as plain JS. No
runtime transpiler, no experimental flag, real .js extensions.

  - shared/index.ts: the one entry re-exporting derive/ciVerdict/
    checkLedgers/etc. for the backend. The frontend keeps importing the
    modules directly (Vite); both run the same source.
  - build:shared (esbuild devDep) wired first into postinstall so the
    artifact exists before the server starts and ships in the image.
  - shared/dist is covered by the existing `dist` gitignore rule.

Verified under Node 24.18: the bundle imports and derive()/checkLedgers()
produce the right buckets (needs_cr, draft, ci_red, ready, needs_recr)
on fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two coupled pieces of the server-side classification path:

1. models/pull.js getFromDB read `mergeable` straight from the tinyint(1)
   column, so a Pull reconstructed from the DB (every open pull, on every
   restart) carried a raw 0/1 instead of a boolean. derive()'s strict
   `mergeable === false` then never matched, and a genuinely conflicted PR
   mis-bucketed as mergeable until a GitHub refresh re-derived it. Normalize
   at the DB boundary, mirroring the adjacent `draft === 1`. Corrects both
   the board (client derive) and the new API.

2. lib/review-model.js runs the SAME derive the board runs (imported from the
   compiled shared bundle) over a Pull: it sources the RepoSpec from
   config.repos and normalizes the pull to its exact on-the-wire shape first,
   so server and client derive are provably equivalent. It reads only
   DB-backed fields, never the in-memory review_requests cache, so it is
   correct right after a cold restart.

test/pull-mergeable.test.js pins the normalization (1->true, 0->false,
null->null). Full backend suite green (50/50).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One call returns every open PR already classified with the same derive
the board runs, so the review skills can stop re-implementing buckets in
bash over a direct DB connection.

  - controllers/api.js: GET /api/v1/pulls returns the live board
    (pull-manager) classified via lib/review-model -- status bucket, CI
    verdict, weight, sign-off counts, staleness, age -- plus GET
    /api/v1/me. Metadata only; no diff (a consumer fetches that from
    GitHub with the same token). Default sort is the board's canonical
    STATUS_ORDER, not reviewer-priority (that stays client-side).
  - lib/api-auth.js: Bearer auth where the token is the caller's OWN
    GitHub token, resolved to a login against api.github.com/user
    (SHA-256-keyed ~5-min identity cache) and gated on the same org
    membership the web login checks. No new credential minted or stored.
    MOCK_AUTH_AS_USER bypasses for local dev.
  - pull-manager.getPulls() exposes the live board (the set the socket
    already ships); its cull setTimeout gets .unref() so the board model
    can be imported by a short-lived process without pinning the loop.
  - authentication.js exports confirmOrgMembership so the Bearer gate
    reuses the exact web-login check.

test/api.test.js drives the real middleware + controller over HTTP
(GitHub stubbed): 401 without/for a bad token, /me identity, and a board
pull served as needs_cr/none/S. Full suite 54/54, clean exit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test/derive-model.test.js runs mysql2-shaped rows through the REAL
Pull.getFromDB -> toObject -> shared derive path (not a hand-built
DerivedPull), asserting the buckets the /api/v1 endpoint depends on:
needs_cr, ready, ci_red (+ ciFailing), needs_recr (+ recrBy), draft, and
unmergeable -- the last pinning that a raw tinyint mergeable=0 reaches
derive as `false` so the conflict registers instead of passing as ready.

Verified under Node 24.18 (the backend's target). Full backend suite
60/60, clean exit. Also confirmed with a live run (temp harness, not
committed): the real routes booted and curl returned 401 without a token
and the seeded pulls classified as ready/needs_cr/ci_red.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bots and weightLabels lived only in the frontend's static public/config.json
(a separate fetch), so wiring them into the backend derive would have meant a
second copy of the same values. Instead config.js becomes the one source: the
board receives them in the socket initialize payload (alongside repos), and the
/api/v1 endpoint reads them straight from config.js -- so the board and the API
can never disagree, and the "load-bearing config.json" deploy step is gone.

Backend:
  - pull-manager sendInitialData emits bots + weightLabels from config.
  - review-model derives weight with config.weightLabels (a label overrides the
    size heuristic) and exposes isBot(login) from config.bots; the API record
    now carries is_bot.
  - config.example.js documents both keys; test/fixtures/config.js sets them.

Shared:
  - parseWeightLabels(raw) -> validated Map lives in shared/ (one validator for
    both the board and the API); InitializePayload gains optional bots +
    weightLabels; isBotLogin is exported for the API.

Frontend:
  - the store reads bots/weightLabels off the initialize payload; app.tsx drops
    the loadSiteConfig effect and reads extraBots from the snapshot.
  - prefs.ts loses loadSiteConfig/parseWeightLabels; the dummy backend supplies
    the same fields so dummy mode still exercises label weights + bot folding.
  - public/config.json + its example are deleted, the gitignore entry removed,
    the README points deployers at config.js.

DEPLOY NOTE: the deployed config.js must now carry bots + weightLabels (move
them out of the old public/config.json).

Verified: backend 61/61, frontend tsc + 383 vitest + vite build, all green.
New tests pin the weight-label override and is_bot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@danielbeardsley

Copy link
Copy Markdown
Member

I don't think this is worth CRing. The QA will be more important.

The only thing to look out for in my mind would be too-much-data if somehow we're constantly transferring the whole stack of pulls to the UI on each update, but that's easily validated.

CR 👍

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