Pulldasher frontend v2: a React + TypeScript rewrite#477
Draft
jarstelfox wants to merge 296 commits into
Draft
Conversation
Toolbar ordering: the search box moves to the right end of the filter row, and the "?" legend lifts up next to the settings cog in the top row, so the two help/config affordances sit together and the filter row reads tabs → filters on the left, search on the right. And the concrete diff size is back: +added −deleted (GitHub's green/red) sits beside the repo#number on every card. It was cut when the weight meter landed, but the abstract five-segment gauge loses the exact number a reviewer often wants; now both are there — the meter for a glance, the count for precision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stamp ledger only revealed who signed on click, so scanning a board for review history meant a click per row. Make it a hover preview: brush the pips and the who/when panel appears, move away and it closes. A click still pins it open and moves focus in for keyboard users, so the drill-down stays reachable without a mouse. Hover-open deliberately never steals focus (only click/keyboard does), so sweeping the cursor across a lane can't yank the page around. Also anchor the pop animation to the panel's own top-right corner so it grows from where it's pinned instead of scaling across the row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ClosedRow (the full-width list in Review/My work) and Classic's ClosedCard (the narrow board column) each re-derived the same merged/closed-time logic and then disagreed on how to show it: ClosedRow used the shared status-badge pill, while ClosedCard rendered bare colored text. Same information, two vocabularies a few files apart. Extract closedEpoch() (the closed_at-with-updated_at-fallback rule) into format.ts and a ClosedBadge pill into bits.tsx, and render both closed views through them. Standardize on the badge, since StatusBadge already establishes that vocabulary for every open state, and lead the card meta with it the way open rows lead with their status. The two shells stay separate on purpose: the horizontal ClosedRow collapses a title in a narrow column, so columns keep the CardShell layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The four Stats cards each hand-rolled the same panel chrome (rounded surface + title/subtitle header), the same proportional bar row (h-2 track + fill, pct-of-max width), and the leaderboard/starvation cards repeated the same avatar + login + you-tag cell. Extract StatsCard, BarRow, and PersonCell into a colocated parts.tsx and render all four cards through them, leaving only each card's real variance (fill color ramp, leading/trailing content) at the call site. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Legend, SigPips, and Filters each hand-rolled the same structure: a trigger button with aria-haspopup/aria-expanded, a root ref span, and an absolutely-positioned role=dialog panel carrying the same .popover class stem and z-50/mt-1 chrome. Extract a Popover component around the existing usePopover hook that owns the root/panel plumbing and ARIA, taking side, hover, width, and panel-class props for the parts that actually vary; each caller passes its own trigger via a render prop and its own panel content. usePopover itself (click-away, Escape, focus return, hover-pin) is untouched, so behavior is unchanged. Settings stays out on purpose: it's a portal drawer with a scrim, not an inline popover, and folding it in would reintroduce the special-casing the wrapper removes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings owned a Segmented pill control (a radiogroup for a small closed choice), while Filters' Mine/All drafts toggle hand-rolled the same visual as two aria-pressed buttons, duplicating the class strings a third time. Move Segmented into bits.tsx and render the drafts toggle through it. Besides deleting the duplication, this upgrades the drafts toggle to one radiogroup, which reads as 'pick exactly one' instead of two independent toggles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
settings.ts and prefs.ts each hand-rolled the same per-browser store: load a JSON blob merged over defaults, keep a listener Set, and expose a useSyncExternalStore hook plus a persisting setter. Extract createPersistentStore(key, defaults) into storage.ts and back both on it. The factory keeps the one real divergence as a first-class method: prime() updates the live value and notifies without persisting, which is how primeScope lets a shared URL seed the board without overwriting the saved one. store.ts's lastSeen stays as-is on purpose: its write is gated on attended seconds and it's woven into the larger snapshot machinery, so a generic factory would either lose the gating or bloat to fit one caller. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lane and RestGroup each re-typed the same title/subtitle header row, and RestGroup re-typed the exact rounded-surface container the Rows component already exports. Factor a GroupHeader (title, optional sub, optional right-aligned count) that both use, and have RestGroup render through Rows. The collapsible BoardColumn header stays separate: its title lives inside a collapse button, which is a different affordance, not the same header. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The open-count next to the logo doubled as a link into the Stats lens
("N open · stats ▸"). Stats is already one of the lens tabs a row below, so
the shortcut was a second door to the same place. Keep the open count as
plain ambient text beside the live-connection dot and remove the link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CR/QA pips sat justify-end in a fixed-width slot, so the empty space landed between the label and its pips and shrank as the required-stamp count grew: a 1-stamp card and a 2-stamp card showed a different CR-to-pip gap, so the spacing looked inconsistent card to card. Switch the slot (and the not-required dash) to justify-start: pips now hug the label with a constant gap, and the leftover space falls after them where it reads as ordinary column padding. The slot keeps its fixed width, so the QA and age columns still line up down the board. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sign-off ledger opened inside a row, and the row list is a rounded, overflow-hidden surface — so the panel was clipped at the card's bounds and tangled with the row's stacking context, reading as translucent where row content showed through. Render the panel through createPortal to document.body with fixed positioning derived from the trigger's rect, following it on scroll/resize, so it escapes the clip and sits above everything at full opacity. Because a portaled panel is no longer a DOM descendant of the popover root, teach usePopover's click-away to treat the panel ref as inside too; otherwise clicking a control in the (portaled) Filters panel would count as an outside click and close it. Hover handlers ride the panel as well so the mouse can travel from trigger into the panel without it closing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sign-off popover listed who stamped and when but went nowhere; v1's
SignatureListItem links each one to the comment via Pull.linkToSignature.
The data is already on the wire: the backend's identifySignatures() sets
source_type ('comment' | 'review') per signature, which is the discriminator
that picks the permalink anchor, since comment_id spans two GitHub id spaces
(#issuecomment- for a comment, #pullrequestreview- for a review).
Add source_type to the Signature wire type, a signatureUrl() helper mirroring
v1's anchor logic, and make each row in the stamps popover an anchor opening
that comment in a new tab. source_type is absent on the old dummy fixture, so
a missing value falls back to #issuecomment- (same as v1's non-review branch).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The only way to force a re-fetch was the per-row refresh button, one PR at a time. Add a "Refresh all" button under a new Data group in Settings that re-fetches every open PR from GitHub at once. The socket protocol has no bulk-refresh event (the server only accepts per-pull "refresh"), and this rewrite keeps the protocol unchanged, so refreshAll() fans out one refresh per open pull and lets the server's serial refresh queue drain them; the board updates via the usual pullChange stream as each returns. The button reports how many were queued. Closed pulls are historical, so they're skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Who you're signed in as is low-value chrome that ate horizontal space next to the board controls; your own PRs and stamps already read as "you" in context. Remove it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "changed since your last look" marker was a 7px dot in the card's left gutter: hard to see, poorly aligned with the avatar, and its meaning (solid = new PR, ring = updated) was a guess unless you opened the Legend. Replace it with an inline tag in the meta line — a solid brand "new" chip for a brand-new PR, a brand-outline "updated" chip for one that merely changed — so the state is legible and self-describing, and it flows with the row instead of floating in the padding. Move the marker into Row's meta and drop CardShell's fresh prop and the absolutely-positioned dot; the row-flash on first appearance is unchanged. Update the Legend to show the two tags. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was no way to get back to a clean slate: a bad age threshold, a stuck scope, or a muted repo you forgot about all just persisted. Add a Clear settings button under the Data group that removes every pd2.* localStorage key (settings, scope, last-seen marker) via clearStoredPrefs() and reloads so the in-memory stores re-init from defaults. It's a two-click confirm (the button arms to "Click again to confirm" for a few seconds) rather than a native dialog, since a wipe shouldn't be one stray click but doesn't warrant a modal for browser-local prefs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audit found five independent answers to "what's next" (authorMove, reviewerMove, ownVerb, waitingOn, and Row's cue) that phrase the same state differently across lenses and can disagree. Introduce rowNote(pull, me) in the model as the single source every lens will render: the imperative verb when it's your move (enriched with the when/which detail a badge can't carry), otherwise a terse who/when/why for whoever's move it is, never restating the badge's noun. Source-agnostic wording throughout — a CR, re-stamp, or dev block reads the same whether it came from a comment tag or a GitHub review — and "you" substitution everywhere (fixing the "you's" grammar bug the old waitingOn had). Not wired up yet; the next commit routes Row and the views through it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One consistent scheme now, in all lenses: the StatusBadge is the canonical
"what state" signal (shown on every row, no more per-lane suppression), and a
single rowNote line carries the imperative when it's your move or a terse
who/when otherwise. This kills the redundancy the audit found:
- Row's cue() and My work's waitingOn() are deleted; both were reimplementing
the same status→text mapping with divergent wording. Review's "Yours to
do" / My work's "Your move"+"Waiting" now render plain <Row> and let rowNote
supply the text, so the imperative ("Re-stamp", "Merge it") and the wait
note come from one place. AnnotatedRow and Row's note/noteTone props go away.
- The WarnFlags "QAing: X" chip is gone — it always duplicated the note's
"X is QAing"; the note wins.
- "iterating" is suppressed when the note already says "fix pushed …", so a
just-repushed re-CR doesn't state the same fact twice.
- The badge option (badge:false) is removed along with the dead cue toggle, so
the badge is consistently present rather than hidden in three Review lanes.
Also drops the now-unused flag-qaing style and FoldRows' unused extra prop, and
updates the Legend's QAing entry to the plain-text treatment rows now use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The amber sign-off pip's tooltip said 'invalidated by a later push — a re-stamp is owed', a third restatement of the fact the badge (Needs re-CR) and the row note (Re-stamp / waiting on X's re-stamp) already carry. Trim it to 'invalidated by a later push' and let the primary row text own the word. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Even after unifying the note, a your-move row still paired the badge and the verb for the same axis: "Needs re-CR" next to "Re-stamp". They're two names for one thing. So when it's your move, let the imperative be the signal — it leads the row (in the badge's slot) and the badge steps aside; when it's not your move, the badge names the state and the note only adds who/when. Also de-echo the wait notes that parroted their badge: "waiting on alice's re-stamp" → "waiting on alice", "dev-blocked by alice" → "feedback from alice", and similar for QA/CR/deploy, so the trailing text adds information the badge doesn't instead of restating it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The imperative led the row as bare bold text, which read quieter than the status-badge pill it replaces on other rows, so a your-move row looked less substantial than a waiting one. Add a badge-do pill — the badge's shape and size, a lighter brand-50 tint and slightly bolder weight — so the imperative carries the same visual weight as a status badge while still reading as an action, not a state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1 alerted you when one of your PRs became ready to merge or a re-review (re-CR/re-QA) fell to you, via a navbar bell that also toggled a sound; v2 dropped it (a listed known gap). Add the engine back: useNotifications watches the whole board (not the current filter) and fires a desktop Notification for items that newly match since the last update, so a steady board stays quiet. The first payload after load only primes the baseline — it never alerts for the backlog already sitting there, which v1's version did. Two settings back it (notify, notifySound, both default off); the next commit adds the Settings toggles. The optional sound is a short synthesized chime rather than a shipped mp3, so v2 stays self-contained and CSP-safe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose the ported desktop-notification feature: a Notifications group with an On/Off toggle (enabling it requests browser permission, and stays off if the permission is denied, with a hint pointing at the browser settings) and a separate Sound toggle for the chime. Both are gated behind Notification support, with a plain note when the browser lacks it. This puts the control in Settings where v2 keeps personal config, rather than v1's always-on navbar bell. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move it out of the known-gaps list (it's ported now) into the feature list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first pass was a bare port. Audit turned up real gaps; close them: - Coverage: alert on every transition that actually lands on you, not just ready + re-review. New model/actions alertMove returns the nudge-worthy action (mergeable / CI failed / feedback / rebase / re-CR / re-QA) and excludes standing states (find-a-QA-er, drafting, self-claimed QA). Locked with actions.test.ts. - Clickable: each notification focuses the tab and opens its PR, and carries a per-PR tag so a newer state replaces the old alert instead of stacking. - Not noise: fire only while the tab is unfocused — when you're looking, the board already surfaces it. The baseline still updates every tick, so returning and leaving never replays what you saw. - Sound that actually plays: reuse one AudioContext unlocked by the toggle's click (a user gesture), so the chime isn't muted by autoplay policy when it fires in a backgrounded tab. Two-note chime instead of a single beep. - Verifiable: a 'send a test notification' button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Sound toggle unlocks the AudioContext on its click, but when notify was persisted on from a prior session that toggle is never touched, so the first chime could be autoplay-blocked. Add a one-time pointerdown/keydown backstop that resumes the context on the session's first interaction when sound is on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Changing ageWarnDays in Settings recolored the age chip (AgeStamp already read the setting) but left the actual starved/aging logic pinned at the hardcoded STARVE_DAYS=4: the "Aging without full review" lane membership, the "open Nd" flag, and the "unreviewed for Nd" note all ignored the user's threshold. So the setting silently half-worked. Thread warnDays into derive() (defaulting to STARVE_DAYS for non-UI callers) so the starved flag uses it. The store passes getSettings().ageWarnDays, keys the derive cache on it so a change actually re-derives instead of returning the cached value, and subscribes to settings changes to re-publish immediately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All three rendered the same amber, so 'Only CI left' (benign, nearly done) looked exactly as urgent as 'Dev blocked' (you owe feedback) and 'Cant merge' (conflict) — collapsing the very distinction the status vocabulary was built to draw. Split them: a new calm slate tone for the CI wait, amber kept for the action-owed dev block, and gray for the git-conflict rebase. Dots follow the badges. Adds badge-slate / --slate tokens in both themes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Compact was one CSS rule that shaved 8px of row padding and nothing else, so it was barely distinguishable from comfortable. Finish the density that the data-density hook only pretended to implement: - CardShell gets a real compact layout: the two-line card (title above, meta below) collapses to a single line — avatar, a truncating title, then the meta chips and the right-anchored rail — with the avatar shrunk 22->16 and tighter padding. This is the structural change that actually changes rows-per-screen. - Row padding drops to 2px, the metric-rail gap and sign-off pips and weight meter shrink, and lane headers and section spacing tighten — all scoped under data-density=compact so the whole row shrinks together. - Lanes show ~50% more rows before folding in compact, since the rows are much shorter. Threaded as opts.compact from settings.density through RowOptions so it rides the existing row plumbing and the memo comparator. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lane fold thresholds were hardcoded per view (6-15 rows). Add a Lane length setting (Board group: 10 / 25 / 50 / No cap, default 10) that overrides them uniformly, so a reviewer on a wide monitor can see a whole queue at once and someone who wants the page short can keep it tight. No cap (0) shows every row with no fold. Threaded as opts.laneCap through RowOptions; Lane falls back to its own default when the setting is unset, and the compact 1.5x multiplier still applies on top. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An audit of the action/state machine against the live board (82 open PRs) found four places the imperatives lied, one of them on a quarter of the board: 1. "Answer the review" fired on bot reviews. The wire's unstamped_reviewers carries every reviewer, and the CI review bot COMMENTs on most PRs — 21 of 45 open non-draft PRs had a bot COMMENTED review, and on 11 the bot was the only unstamped reviewer, so the author's card read "Answer the review · from claude[bot]", grouped under Respond, bucketed "Waiting on me", and the branch's early return buried the real queue-position / nudge context. Filter bots (same suffix check engagedNoStamp already uses). 2. DISMISSED reviews demanded answers. A dismissed review no longer stands, so it owes nothing; live examples on #61656/#63206/#62457 were all nagging their authors. Drop DISMISSED from the answer-the-review set. 3. Re-stamp / Finish QA / Re-QA ignored the masking statuses. The personal-obligation early exits ran before the draft / dev_block / ci_red branches, contradicting the machine's own precedence: a pull converted back to draft told its stale stampers "Re-stamp" (truth: not reviewable), a dev-blocked pull asked for re-stamps while the author still owed changes, and the push that broke CI demanded a re-review of a head the author is about to replace. New authorOwnsIt gate covers reviewerMove (desktop nudges), reviewerNote (cards), actionState (the State filter), is:restamp (the query token), and the Team/People owes-re-stamps maps, so every surface agrees: while the author owns the pull, reviewers are asked for nothing, and the obligation returns when the mask lifts. QA's parallel-with-CR behavior across the OTHER statuses is unchanged. 4. A clean stacked PR could get a "Rebase" nudge. authorMove keyed on status === 'unmergeable', which also covers dependent-without- conflict — signed off, waiting on its parent, nothing to rebase. Key on the conflict flag alone; authorNote already said "lands with its parent" correctly. actionState now derives the restamp bucket from the note's action instead of raw recrBy/reqaBy, so the filter count can never disagree with the word on the card (this also fixes the edge where a re-tester holding the QAing label bucketed "Re-stamp owed" while reading "Finish QA"). The masking rule is documented in the Legend's Sign-offs group. Six new tests pin the gate, the bot/DISMISSED filters, and the stacked no-rebase case; two existing tests that pinned the old unmergeable → Rebase and ci_red → Re-stamp behavior are updated to assert the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The legend had grown into a 380px encyclopedia: seven groups, twenty-odd items, four multi-paragraph fold-out essays (weight, regions, claims, turn rotation), ~2,600px of scroll. Most of it restated things a mark already explains in place, and its illustration glyphs (✦, ✋, a v1 eye icon) didn't even match the marks actually on the board. The redesign follows locality of information — the lane-subtitle popover pattern: an explanation lives where the thing it explains sits, and the legend keeps only what no single mark can teach. Moved to their marks: - The pip vocabulary keys itself inside the sign-off ledger popover (stands / staled by a push / needed), right where the pips are read. - How weight is decided (label wins, else the 50/150/600/1500 diff steps, +1 class past 15 files) lives in the weight letter's popover, replacing the "from diff size" stub and the legend essay. - The claim button's own title now carries the essential fact: claiming adds you as a reviewer on the PR itself. (The restart-timestamp trivia from the old essay is cut, not moved — server arcana.) - The re-stamp masking rule glosses the wait-words where a masked card actually sits: "CI red" and "blocked" headers now say stale stamps wait for the author's fix. "in QA" now says how QA is claimed (the QAing label) — the only how-to the old legend held exclusively. - Age, the hidden ledger, group-header words, and regions already explain themselves in place; their legend entries are deleted, not moved. What remains is one screen, five groups, no folds: the invented word (stamp — now correctly including GitHub approvals, not just comments), the circle-mark family with the author-moves-first rule, CI's same-marks convention (the one thing a mark can't teach about itself: passing is invisible at rest), the color grammar (brand = your move, amber = act, gray = fact), the three coordination signals shown as the REAL marks (the lucide hand, the "requested from you" words), the four query tokens, and the keys. A lead-in line sets the contract: hover anything, everything explains itself where it sits. 420px wide so definitions wrap at two to three lines; ~980px tall against the old ~2,600px. The bundle drops ~8kB of essay prose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Coworker report, reproduced on the live board: a PR that was both a draft and parked in Cryogenic Storage sat at the TOP of "Your move" under Re-stamp. The draft half was fixed by the authorOwnsIt gate; this closes the parked half and renames the draft verb. Parked (the Cryogenic Storage label) now masks the whole action machine, the author included — stronger than authorOwnsIt, where the author still has a move: parked means deliberately shelved, so nothing is owed by anyone until the label comes off. The new `parked` predicate gates rowNote (the card reads "parked, kept open on purpose" and groups under a new "parked" wait word with its own header gloss), authorMove and reviewerMove (no lane slots, no desktop nudges), the turn rotation (a starved parked pull no longer gets pointed at anyone), Review's offered-work pools (queue, bot queue, QA), the Team/People owes-re-stamps maps, and the is:restamp token. actionState follows rowNote, so the State filter agrees. This only ever mattered when a parked pull was revealed (the hidden ledger, showCryo, or show-all) — but revealed is exactly when the coworker hit it. "Finish the draft" becomes "Undraft": the exit verb, matching the Unblock naming pattern the owner asked for. The move on your own draft is marking it ready for review, not "finishing" it — the old word implied the board knew how done the code was. Still last in the do-word ranking and still excluded from desktop nudges and Review's home lane (planning, not minutes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The knocked-out ✗ made CI's failure the loudest glyph on the rail — louder than any human mark (owner call: drop it entirely). CI is now a glyphless circle, the machine's mark; check glyphs are reserved for human stamps, so the rail reads people-with-checks beside machine-with-a-circle. Three standings, one 14px geometry (.ci-ring / .ci-disc): - Failing: a solid red disc. Mass and color carry the alarm, no glyph. It lands with a single 320ms scale-settle and then holds still — an alarm that keeps moving is a nag, not a mark. - Running: the slate ring sweeping clockwise, the filled share being checks done. It breathes on a 2.4s cycle so a live run reads as alive, and --sweep is a registered @Property (top-level, layers don't own it) so the share GLIDES between values instead of snapping when another check finishes. - Passed: a green ring that draws itself closed on row hover or focus. Invisible at rest (no news is good news), and the reveal IS the animation. Replaces the green ✓ disc, which borrowed the human mark. prefers-reduced-motion disables the breathe, the land, and the draw (the pass ring simply appears closed). The dead .pip-fail, .pip-run, and .pip-progress classes are removed; the Legend's CI line and DESIGN.md's rail taxonomy now describe the circle language. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four interlocking changes from one design pass, touching the same rows.
Snooze is Review's own gesture. It used to hide a pull from EVERY lens
via the shared visibility pipeline, with its only ledger a count buried
in Settings. Now the Review view applies it itself: snoozed rows drop
out of Review's lanes only, and collect in a visible "snoozed by you"
fold at the bottom of The rest of the board, with the wake-all beside
them — hiding is trustworthy when you can always see what's hidden.
The Settings block and the hidden-ledger's snoozed row are gone (the
board section replaces both), and the snooze button says what it now
does: off your Review lens.
The age numeral moves to the rail's far right and whispers. It was a
heavy black column in the meta line; now it caps the rail — the row's
true right edge, beside the marks it ranks with — wearing the age
line's own border tint at rest and rising to ink on row hover. The
warn/rot font weight survives for the hover read. A new setting picks
which clock it shows (days since opened, or since last update); the
urgency weight always follows the opened clock, and both clocks stay
in the popover.
The rail reorders: CR · QA · CI · age. CI is the rail's only
optionally-rendered mark (green shows on hover only), so it moves to
the end where its appearance can't shift the human marks — and
Classic's narrow columns stop hiding it outright: the quiet slot
collapses at rest and returns on row hover, so green is one hover away
everywhere. The CR cluster becomes ONE door: the label, weight letter,
and pips are a single trigger, and the weight drill-down (effort word,
exact diff, the sizing rule, the filter action) is now a section of the
CR ledger panel — the standalone weight popover is deleted. QA's label
joins its trigger the same way, and a no-stamps cluster still opens its
panel ("No stamps yet." plus the key) instead of being a dead spot.
Parked/draft audit fixes: `starved` is now false for parked pulls at
the source (status.ts) — they age on purpose, so starve nags, starve
scores, and the turn rotation all stop seeing them as rotting; and the
cheer/nag system sits parked pulls out entirely (author edge toasts,
the board-cleared count, and the review-requested toast all gate on
cryo).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pass ring drew itself closed on row hover — a flourish that broke the mark's own rule: animation should mean something CHANGED (a check finishing glides the sweep, a failure lands the disc), and hovering isn't a change. The green ring is now statically closed and simply appears with the quiet slot's existing reveal; the hover-draw rules and the reduced-motion special case they needed are deleted. The doctrine line in DESIGN.md records the cut so the flourish doesn't come back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The solid red disc read as a blob (owner call, from four live-DOM candidates at real pixels: ring+dot, thick ring, broken ring, disc). The winner keeps the machine family hollow — every CI standing is now ring-built — with the center dot as the smallest mass that still says alarm, and gives failing a FORM apart from the green passed ring rather than leaning on hue alone. Drawn with the house SVG-mask technique so the strokes match the pip family's machining. The landing settle and everything else about the mark's behavior is unchanged; Legend and DESIGN.md record the new form and why the disc was cut. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In Classic's ~380px columns the rail wraps to its own line, and the hover action cluster (copy / snooze / re-fetch / claim) rendered inline right beside the marks — four icons crowding the row's most information-dense line (owner report, with screenshot). Mobile solved this long ago with the kebab; desktop narrow columns now borrow it: in ≤520px containers the inline cluster collapses outright and the kebab takes over, revealed on row hover or focus (opacity, so the slot never jumps), opening the same labeled menu. Wide lanes keep the inline cluster; below 720px nothing changes (the kebab was already the always-visible answer there). The rules ride in the existing unlayered container block so they beat the kebab's own hidden-at-desktop utility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Size unknown" existed because of an ingest gap, not a real state: GitHub's pulls.list omits additions/deletions/changed_files (only pulls.get and webhook payloads carry them), and the bulk open-pulls refresh handed list items straight to the parser — so every REPLACE INTO overwrote good sizes with NULL, and a pull stayed "unknown" until some webhook happened to deliver its stats. Worse, changed_files was never read back from the DB at all, so even a webhook-populated value vanished on the next server restart (silently dropping the >15-files weight bump). Server: parse() now fetches the full pull when the incoming payload lacks additions (one extra API call on top of the ~8 the per-pull fan-out already makes, and only for list items), and getFromDB round- trips changed_files. Together they guarantee diff stats on every open pull; existing NULL rows backfill on the next startup refresh. Client: the unknown state is deleted outright. sizeKnown leaves DerivedPull; the weight letter is always a real class (no more "?"); the Weight filter drops its "Size unknown" bucket; the CR panel drops the "estimated" hedge; the sorts and quick-win checks stop guessing a 2.5-rank middle for sizeless pulls. Stats keeps its own additions-null check for HISTORIC closed pulls, which are never re-fetched. The dummy fixture predates diff stats, so the demo loader synthesizes a deterministic spread across every weight class (and the >15-files bump) instead of reading all-XS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four identical icon buttons (copy, snooze, refresh, claim) gave equal salience to unequal decisions: Claim and Snooze decide something about the work, copy and re-fetch are plumbing you reach for rarely. The hover cluster now shows only the verbs, as WORDS — "Snooze" in quiet ink, "Claim" in brand, because committing to a review is the board's core gesture and a word invites where an anonymous hand icon didn't. The cluster floats over the row's tail at zero standing width (the compact overlay promoted to the only mode), so the words cost nothing at rest. The utilities move to the kebab at every width — one overflow pattern board-wide, hover-revealed on desktop (extending the narrow-column pattern), always visible on phones. Its menu reorders to match: the verbs lead (Claim, then Snooze), a divider, then copy / re-fetch, then repo and person management. Snooze also becomes context-honest: it renders only on the Review lens (opts.showSnooze), the one lens a snooze quiets — offering it on Classic while it silently acted elsewhere was a lie of placement. The held-claim hand stays always-visible outside the hover cluster, as before: a commitment, not a hover affordance. DESIGN.md records the verbs-vs-plumbing rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 8px band only appeared when the cursor found the line's own 10px hit strip — pixel-hunting for a 1px hairline. Hovering the CARD is the "tell me about this row" gesture, and the deepened band is part of that answer, so it now grows with the row hover (the same trigger that reveals the verbs, the kebab, the green CI ring, and the ink on the age numeral — one gesture, one coordinated reveal). The band's own hover still opens its popover, and keyboard focus still grows it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The worded hover overlay lasted one commit: it made the verbs inviting but left the board's core gesture undiscoverable at rest — first-class means living in the row's anatomy, not materializing on hover. The rail now ends in a verb dock: "Snooze" and "Claim" as STANDING words after the age numeral, whispered at 60% ink-3 (readable cold, so the affordance needs no hover and works on touch), rising on row hover to their true colors — Claim to brand, Snooze to ink. The same rest-then-rise ramp the age numeral wears: one quiet tier, board-wide. A claim you hold turns the slot into "Release" in standing brand with the hand mark — the separate always-visible hand button is absorbed; one home for the whole claim lifecycle. The Claim slot is reserved on unclaimable rows (your own, already-claimed) so the dock reads as one column down a lane, and Snooze still renders only on Review, the lens it acts on. Utilities stay in the kebab; the overlay pill, its visibility CSS, and the dead narrow-column hider are deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The standing whispered words put quiet texture on every row of a lane — sixty Snooze/Claims of it. The dock now separates PLACE from PRESENCE: the slot keeps its reserved width (one column down the lane, nothing ever shifts), while the words rest at opacity 0 and fade in on row hover or focus. A fade, not an animate-in: opacity is the board's one reveal mechanism (the green CI ring, the kebab), and motion stays reserved for state changes — nothing slides because a cursor passed. On touch, where hover doesn't exist, the words stay visible at a whisper. With the dock quiet at rest, the held claim needed a standing home that keeps the column's vertical rhythm — and it has a semantic one: a claim is literally a pending review request, so its mark is now the brand hand riding with the CR pips. "Release" joins the dock as a normal revealed verb. Commitments stay always-visible; the dock stays empty until a row has your attention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reserved dock was a worse design (owner call): it held ~100px of invisible words at the row's end, which pushed the age numeral — the board's strongest column, the right-edge anchor — inward and paid dead space on every row for it. A place is only worth standing if something visible stands in it. The verbs keep their fade-on-row-hover reveal but now own no geometry at all: they float just left of the rail (absolute off the rail's own box, both densities), on a muted backdrop for legibility over the meta text beneath, with pointer-events following the fade so an invisible word can never steal a click from the text under it. The rail reads CR · QA · CI · age flush to the right edge again — verified one exact x for every age numeral down the lane — and appears exactly where the eye already is when a row has your attention. Touch keeps the kebab as the verbs' labeled home (the float doesn't exist there); the held claim's brand hand stays standing with the CR pips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The verbs now name the exit, not a glyph: a claimed row's slot reads "Release", a snoozed row's reads "Unsnooze" (new unsnoozePull in the store, publishing like its siblings so the row walks straight back into the lanes) — the same slot, the opposite word. The hand icon is gone everywhere: the CR-cluster standing mark, the kebab item, the Legend (whose claim row now shows the word the UI actually renders, in brand). A claimed row already reads through position — it groups under Finish CR in Waiting on you — so a second standing mark restated what structure says. The kebab's snooze item carries the same toggled word, and per-row wake means the snoozed fold no longer needs the all-or- nothing Wake all as its only undo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The states you choose deserved a standing record, not just a toggled hover verb: "claimed" and "snoozed" now render as flags beside conflicts and stacked — the board's existing channel for standing row facts — in a new brand flag tone, because brand is the yours color and a choice you made stays visible with no hover required. The flags ride the shared flag popover (brand dot, one-line meaning pointing at the hover verbs for the exit) and appear on every lens the row renders on, so a snoozed pull seen on Classic now says why it's missing from Review. The hover verbs (Release / Unsnooze) are unchanged: state stands in the meta line, the exit reveals on approach. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The meta-line flags lasted one commit — restating in the meta what a
standing button can say itself split one fact across two homes (owner
call). The claimed/snoozed record now lives IN the buttons: the float
splits into two independent chips, each with one rule. A chip that
RECORDS a choice you made stands — a claimed row always shows
"Release", a snoozed row always shows "Unsnooze", brand and ink
respectively, no hover needed, and if both states are yours both chips
stand. A chip that merely OFFERS ("Claim", "Snooze") reveals on row
hover/focus, opacity-only with pointer-events following. The state and
its exit are the same pixel.
The brand flag tone, its CSS, the rowFlags claimed/snoozed entries, and
the Legend's brand-flag sentence all revert; touch keeps offers in the
kebab while standing exits stay visible everywhere.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The board spoke two subsection languages. Word groups ("RE-STAMP · 1")
were quiet eyebrow bands the owner liked, but they didn't fold; the
rest-of-the-board ledger, "their other PRs", and "you've stamped" were a
different species entirely — 13px sentence-case <details> rows with a
large chevron and a colored status dot. A reader had to learn both, and
the two tiers side by side made the ledger read heavier than the primary
lanes above it.
Merge them: Fold IS the eyebrow now. One collapsible band — 11px caps,
brand for a do-word, muted for everything that waits, "· count", a 12px
chevron as the only disclosure affordance, no status dot (rows inside
already carry their pips; a second color code on the band was redundant
weight). Hovering the label opens its one-sentence gloss; clicking
anywhere on the band toggles — including the label, whose popover
trigger would otherwise swallow the click, so the trigger's onClick
drives the <details> by hand. Word groups now fold too (open by
default, choice remembered per fold id), which the owner asked for
directly: collapse the REVIEW group once and the lane stays personal.
What this unlocks per lens:
- Review: the ledger buckets adopt the wait-word vocabulary the groups
already speak (Blocked, Deploy hold, Conflicts, CI running, CI red,
Drafts…), so the rest of the board reads as a closed table of
contents in the same words as the lanes above it. Hints became
hover glosses — locality over always-on chrome.
- Team/People: "their other PRs" was one undifferentiated pile; it now
word-groups under "The rest of their work", so re-stamps, blocks, and
waits are legible per person — and the hand-made "you've stamped"
fold dissolves, because a live stamp of yours already emerges from
rowWord as the "stamped" group.
- Ci: the per-check bands become folds (caps off — check names are
case-sensitive identifiers), completing the pattern.
- Classic/My work: inherit the collapsible groups through WordGroupRows.
Truncation moves inside each group (cap per group, "+N more" in the
fold) instead of one shared ration across groups — a collapsed group
can't spend the open ones' budget.
DESIGN.md: the four-tier header system is three tiers now, with the
Fold contract written down.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
People and Team were the same board with different pickers: both split a member list through the reviewable / stamped / rest rules (People inlined them, Team called the unit-tested teamBuckets), both rendered the same queue and rest-group below. Two nav tabs whose bodies agree is one tab — and People made a weak top-level lens anyway: the daily loop never starts at a directory, and its cold-open default (whoever has the most PRs) was a board nobody asked for. What People uniquely carried survives, inside Team: - The owed re-stamps roll-up: every directory chip still wears the amber pip and count, the only at-a-glance "who is sitting on stale approvals" on the board. - Person as destination: clicking any avatar anywhere still lands on that person's page, now under the Team tab (#lens=team&person=x). - GitHub-team boards, via the config team chips. The merged layout answers "what is this tab for" in its own geometry: people, starting with yours. Row one is your picked team, the "Your team" aggregate chip (the tab's home board, active by default) leading its member chips and the Edit picker; behind it the directory: config teams, then everyone else, starred first, your members deduped out. An explicit pick earns the summary card; the home board's summary is the member strip itself. The build-your-team invitation only appears when you have no team and nothing picked, with the directory still browsable above it. Everything routes through teamBuckets now — a person is a one-member team — so People's duplicate inline bucket logic is gone and the live-vs-stale stamp doctrine keeps its single tested home. Legacy #lens=people URLs rewrite to team (person=/team= params carried along, the board→classic precedent), and old saved filters describe themselves as Team. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-check ledger (failing/running share on the fixed ruler, average run time) sat at the bottom of the lens, after every pull list — but it's the lens's answer to its own headline question, "how is CI doing right now": one glance says which check is hurting before any individual pull matters. Dashboards summarize first, then itemize; the pull lanes (your broken builds, failing by check, running) are the itemization and now follow it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deciding "can I act on this?" often needs one more layer than the rail shows: the description — the summary, the QA checklist, the linked issue. That layer lived a tab away on GitHub. Now the title itself is a door: hover the visible words and the description opens beside them, rendered as GitHub would render it (gfm, single-newline breaks, inert task-list checkboxes) and sanitized (DOMPurify; PR-template HTML comments drop out, every link opens a new tab). Embedding the real GitHub page was considered and is impossible: github.com sends X-Frame-Options: deny, so it refuses to render in any frame. Rendering the body we already carry (PullData.body has been on the wire all along) is both the possible and the lighter answer — no server change, no extra fetch. Decisions along the way: - The hover region is the visible title text, not the row. The title anchor's stretched hit layer covers the whole card, and a preview from anywhere on the row would be a popover storm — so Popover grows a hoverTriggerOnly option that scopes its hover handlers to the trigger element instead of the root. - No click-pin. Clicking a title means "open the PR"; pinning a preview behind the tab you just opened is noise. - No door where there's nothing behind it: an empty description gets a plain title. - marked + DOMPurify load as their own chunk on the first hover (~24 kB gz); board startup pays nothing. - .md-prose sets the quoted document a notch under board type; blockquotes render as muted insets, not side-stripes. The dummy board's flat placeholder bodies become three rotating realistic descriptions so the feature demos honestly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The description preview never opened for a real pointer: the title anchor's stretched click layer (.pd-link::after, z 0) paints over the row's inline content, so the cursor hit-tested the layer and the trigger span inside the anchor never received mouseenter. The dummy verification passed because it dispatched synthetic mouseover events, which bypass hit-testing — a trap worth remembering: verify hover features with a real pointer. The fix is the house pattern the layer's own comment prescribes: genuinely interactive children sit above it via .pd-raise. The span stays inside the anchor, so clicking the words still opens the PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Check health and "Failing, by check" were the same list twice — every check ranked worst-first up top, then the same checks again below with their pulls. Merged: each check is one band carrying its glance (the fixed-ruler failing/running share, "5 of 57 runs failing", average run time), and a failing check opens into the PRs it's failing, yours included. Healthy checks render the same band without a chevron — nothing to open, nothing pretending otherwise. All bands start collapsed and stay that way until the user chooses: this view's first audience is QA engineering asking "are our tests failing in certain ways?", and the glance row answers that; the pulls are drill-down. Fixing that default exposed a Fold bug: browsers fire `toggle` on a <details> that MOUNTS open, so every defaultOpen fold was writing itself into the open-state store on first paint — an explicit- choices-only store that was silently recording choices nobody made. onToggle now ignores any toggle matching the rendered state. Fold grows two small props for the band: `detail` (right-aligned content) and `showCount` (off when the detail says the count in words). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Thirty stranger-chips stood above your own team's board and outweighed it. The directory (config teams + every author) now sits behind one quiet "Everyone · N" control at the row's far end — yours on the left, the door to the rest of the world on the right. The door opens itself exactly when hiding it would lie: no team configured yet (the directory is the only content), or the current pick lives outside your team (the chip explaining whose board you're on must stand). Your own toggle wins for the rest of the visit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The popover stated facts the wire could substantiate but didn't hand
over: context at a glance means the door to the evidence, not a trip
through GitHub's tabs.
- Conflicts name the branch they fight ("conflicts with master") and
link straight into GitHub's conflict editor (/conflicts).
- Blocks say which kind (dev blocked / deploy blocked) — the comment
permalink was already there via signatureUrl.
- Each failing CI check links to its own run log (the status's
target_url; the PR checks tab as fallback).
- closes/connects render as issue links — the server has parsed these
body tags all along (models/pull.js), the client types just never
declared them.
- branch shows its base ("into master"), and milestone, assignees, and
the comment count/last-comment age surface when present.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Live updates lost … retrying in the background" was sticking after overnight sleeps because the retry could never succeed: - A failed /token fetch stayed CACHED: the promise rejected once and every later caller — including each reconnect — replayed that same rejection forever. Failures now evict themselves from the cache. - The auth handshake had one shot: on reconnect, a token failure set the error banner and stopped. The socket sat connected but unauthenticated, so the server never re-sent data. Authentication now retries with backoff (2s doubling to a 30s cap), reset by any connect/disconnect. - Nothing kicked the socket on wake: Chrome freezes background tabs and laptops sleep mid-backoff, and socket.io would then sit out the rest of its delay. The tab becoming visible, the window refocusing, or the network returning now reconnects immediately. - The one failure a client can't heal alone — an app session cookie that expired overnight — gets a guarded page reload (visible + online tabs only, at most once per 5 minutes): the auth gate re-runs the OAuth handshake, which is silent with a live GitHub session, and the board comes back signed in. /token redirects now read as failures instead of JSON parse errors, so that path is detected at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four changes from Danny's first-day feedback thread in #pulldasher,
sharing one theme: the board should never move state the user can't
see or predict.
Drop v1 URL compatibility. legacy.ts translated v1 bookmarks
(?repo=&cr=0&closed=1) into v2 state behind an undismissable chip.
Nobody wants the old URLs; the translation layer, its filter branch,
its Classic-only props (column collapse, the Recently Closed column),
and its tests all go.
"Requested of you" becomes "review requested". The standalone lane
merged into Waiting on you some time ago; what remained was phrasing.
"GitHub asked you directly" read oddly — a PERSON asked, GitHub
carried the message. The row context and Legend now say "review
requested", matching the toast title that already existed.
Your own pulls wear your mark. v1 had a blue star; v2's star means
"starred person", so ownership rides the identity mark itself: your
avatar wears a 1.5px brand ring on every lens. One glyph, one
treatment, no new vocabulary.
"Changed since your last look" becomes "Recently updated (Clear)".
The old model guessed attention: tab visibility plus a glance-guard
timer advanced the baseline, and opening a PR silently removed its row.
Both guesses were wrong in ways users could see but not explain — a
hidden virtual desktop still counts as "visible", and click-vanish read
as the board losing things. Now there is one state and one visible
control: the lane's sub-line names the window out loud ("new or
updated in the last 14d") and its Clear button is the only thing that
moves the baseline. Retired: the visibility/pagehide stamping, the
glance-guard setting, per-row acks, Settings' "mark everything seen"
(the control lives at its point of effect now), and the shipped
toast's dismiss handler that quietly cleared the ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The brand ring around your own avatar didn't survive contact with the owner: a halo on a photo reads as selection/focus, and lands as decoration. Five candidates went head-to-head at real pixels — the ring, a "you" word in the meta line, a corner dot (reads as online presence), a brand-tinted repo#number (brand text implies a link), and v1's actual treatment, which turned out to be substitution: authoredByMe() replaced the avatar with a blue faStar outright. The pick is a hybrid of the ring and v1's star: the identity slot holds a minted coin — the brand ring as the coin's edge around a filled brand star, the same visual mass as a face so row rhythm holds. Glyph candidates were auditioned in the coin at 12px: a pen reads as an edit button, a feather reads as another pen, the @ (GitHub's own author:@me) was the runner-up; the star won on filled mass and a decade of v1 muscle memory. The star vocabulary stays one concept — people who matter to you: corner star on someone you starred, the full coin for its limit case, you. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The you-mark's final form, arrived at through a fifty-idea sweep. The previous iteration — ring around the avatar plus a star badge at the top-right corner — was close but was still TWO marks: a ring that faintly pleads "selected" and a badge that faintly pings "notification", three strokes negotiating one corner at 16px. The seal fuses them: the brand ring parts at the upper-right (an 80° gap, round-capped terminals) and the filled brand star sits IN the opening, on the ring's own circumference. One object. A parted ring cannot read as a focus ring, because focus rings never break — which retroactively solves the objection that sank the original plain halo. Your face stays; the star crowns the coin rather than replacing it. Both densities get exact geometry (22px avatar: r=13 ring, star at 45°; 16px: r=10, slightly wider gap), colors are --brand/--surface only so both themes come free, and the mark never animates: identity, not state. Candidates rejected across the whole arc are recorded in DESIGN.md so the decision holds its shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full-strength brand on both ring and star made the seal the loudest thing on a quiet row. The mark needs one voice, not two at equal volume: the ring drops to 45% opacity — an edge you feel more than read — while the filled star stays full brand and carries the scan. Opacity rather than a second token, so both themes keep their own brand value and the tint tracks it for free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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