Pulldasher frontend v2: a React + TypeScript rewrite#477
Draft
jarstelfox wants to merge 341 commits into
Draft
Conversation
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>
The lens carried a private person=/team= selection model: invisible to the filter bar, incapable of multi-select, and a second thing to learn next to the People picker that already narrows by author. Selection now reads and writes scope.authors — a click is visible and clearable in the bar, narrows every lens identically, and shift-click composes an exact set of people to poke at (person chips toggle one login, roster and config-team chips toggle the whole group; a selection that equals a roster earns that roster's name on the summary card). An avatar click anywhere still lands on that person's board, now via the same scope. v2 is a clean cut, so the retired person=/team= params and the old board/people lens aliases are dropped rather than migrated; history entries now come from lens switches alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Draft, for the review trail. This branch is already running on dev
(
pulldasher-dev.cominor.com) and prod (pulldasher.cominor.com), deployedstraight from
frontend-v2. The PR exists so a change this size has a homefor 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
prefers-reduced-motion.Server-side (small, and safe)
lib/claims.jsstores a per-recordexpiresAtfrom a clamped[5m, 24h]client TTL;app.jsforwards it from theclaimReviewsocket event. The broadcast wire is unchanged ({ login, at }). Covered bytest/claims.test.js./(the old/v2/path is gone).Testing
frontend-v2/src/model/and componentstscstrict — cleantest/claims.test.js/auth-gates (302),config.jsonservesweightLabels, no errorsQA
prefers-reduced-motiondegrades every animation to a stillsize: *chain), i.e.config.jsonis present indist/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 priorpulldasher-dev:<sha>. Build bakesfrontend-v2/public/config.json(weight labels) intodist/— load-bearing, gitignored.🤖 Generated with Claude Code