Colour a linked pull request by its state, in GitHub's own colours - #243
Merged
juanmaguitar merged 2 commits intoAug 10, 2026
Merged
Conversation
The state rendered as grey text the same weight as the date beside it and separated by a middle dot, so the one word saying what happened to the work read as part of the timestamp. It now wears the pill the "Latest" marker already uses, in GitHub's three colours: green open, purple merged, red closed. Merged is a third state the parser used to throw away — GitHub's `state` only ever says open or closed, and the merge shows in `pull_request.merged_at`, which the search response the app already fetches carries. Keeping it costs no second request against the shared unauthenticated quota. The colour accompanies the word, never replaces it, and the closed pill is GitHub's red rather than this app's error pair: a closed pull request is not a failure, so it must not be dressed as one. Part of #227. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
juanmaguitar
added a commit
that referenced
this pull request
Aug 10, 2026
…es that predate it (#245) > [!WARNING] > **Do not merge this before the ticket-branches stack (#168 #185 #198 #205 #218 #238 #241 #242 #243).** It documents features that are not on trunk yet. Merged first, the published guide describes an app nobody can download. ## Why The user guide describes a site as one working tree and one implicit patch, because that is what the app was when the guide was written. The ticket-branches stack makes a ticket a branch on the site, which leaves several pages not merely incomplete but wrong: what a patch contains, what **Unlink** costs you, what a trunk update does to work in progress, whether the applied-patch banner describes the ticket you are looking at. The stack also adds surface nothing documents at all — the tickets card, the question asked about edits made before a ticket was picked, the note about unsubmitted work, the switch progress line, coloured pull request states. ## What changes Docs only. The diff is `docs/` — guide pages, `docs/.vitepress/config.mjs` for the sidebar, and screenshots. No `src/`, no `scripts/`. **New page: `guide/ticket-branches.md`, "Working on several tickets".** The model (a site is the expensive substrate, a ticket is a cheap branch), starting a second ticket, the tickets card in both its states, what a switch says while it runs and what to do when one fails part-way, deleting one ticket's work, the four-choice question about loose trunk edits, the unsubmitted-work note, and what a ticket's patch does and does not contain. Added to the sidebar under "Contributing changes", after "Working on a Trac ticket". **Corrections to pages that describe the old model:** | Page | What was wrong | | --- | --- | | `trac-tickets.md` | Unlink described as only forgetting an association; no mention that a site holds many tickets, that linking asks about loose edits, or that PR states are now coloured pills with a third value | | `submitting-changes.md` | "everything this site has that its copy of trunk does not" — the diff is now the ticket's own work, measured from its branch point. Also gained what a patch can and cannot carry: deletions now travel, binaries and unreadable files are named above the diff rather than dropped | | `trunk-updates.md` | Said nothing about being on a ticket — that the update parks and returns, and that the branch point deliberately does not move | | `applying-patches.md` | Applied-patch state is per ticket now; and "use Update to latest trunk to reset the checkout" does not hold on a ticket branch, where the update puts you back exactly where you were | | `creating-a-site.md` | Implied a site per piece of work | | `managing-sites.md` | **Delete this site** takes every ticket's work with it; and its dirty-tree dialog is about loose edits only, never parked ticket work | | `troubleshooting.md` | New entry for the refused-switch message, which is what someone will search for | | `getting-started.md`, `setup-wizard.md`, `submit-*.md`, `running-the-site.md` | The button is **Review & submit changes**; the guide still quoted **Submit changes** everywhere | **Deliberately not in this PR:** new entries in `scripts/screenshots/shots.cjs` for the panels the stack adds. A shot definition belongs with the code that draws the panel, and one added here would fail on trunk where the panel does not exist — see Risks. ## How to test this **Platforms:** any. This is a static site; nothing here touches the app. **Starting state:** this branch checked out. 1. `npm run docs:build` → completes. This is the real check: `ignoreDeadLinks: false`, so a broken link or a missing image fails it. 2. `npm run docs:dev` and open `/guide/ticket-branches` → the page renders, the six screenshots load, and "Working on several tickets" is in the sidebar under **Contributing changes**, between "Working on a Trac ticket" and "Applying patches and PRs". 3. Follow the internal links out of that page — `#what-a-patch-contains`, `#edits-you-made-before-picking-a-ticket`, `#deleting-a-ticket-s-work`, `trunk-updates#updating-while-you-are-on-a-ticket`, `submitting-changes#what-a-patch-can-and-cannot-carry` → each lands on the heading it names, not at the top of the page. 4. `npm run lint` → clean. **And the part a build cannot check** — drive the stack (the Buildkite artifact for `juanmaguitar/pr-state-colours` exercises all nine PRs) with the new page open, and check the prose against the app: 5. On a site with two tickets, read "Starting a second ticket" and follow it literally. → Unlink, then type the second number. The page should not describe a control that is not on screen. 6. Read the switch progress sentences in the page against the ones the panel actually shows. 7. Compare the six screenshots against the panels as they render for you. **What must not have happened:** - **No `src/` or `scripts/` file in the diff.** `git diff --stat origin/trunk...HEAD` must list only `docs/`. If a screenshot-harness change slipped in, it will fail on trunk. - **No invented UI string.** Every label quoted in these pages was read out of the stack's source or seen in the app. A plausible-sounding button that does not exist is worse than no documentation. - **No page left describing the old model.** The table above is the list; if another page still says a site carries one ticket, it was missed. ## Risks and limitations **This PR must merge after the stack.** It is the only real risk here and it is not defended by anything automated: nothing in CI knows the difference. The Pages deploy runs on trunk, so merging early publishes it immediately. - **`scripts/screenshots/shots.cjs` has no entries for the new panels**, so the six new images are not reproducible with `npm run shots` as things stand. They were taken by driving the stack through the same Playwright harness in a throwaway worktree, with fixture and shot definitions that were not committed anywhere. Follow-up, and it belongs on a branch that has the panels: the fixtures need real repositories with ticket branches, where the current ones are empty directories. **Follow-up issue not yet filed** — happy to file it. - **`site-view.png` is re-shot** and is the only existing image replaced. Its button still read **Submit changes**, which was already stale on trunk after #235 renamed it — so that image and the text corrections around it are true of trunk today, stack or no stack. - **The `MERGED` pill is documented but not screenshotted.** A repository-wide search returns no merged pull requests on `wordpress-develop`; the state is real in the API and unit-tested, and #243 says the same. - **The screenshots name a real ticket and its real pull requests** (#29798), pulled live from GitHub when the images were taken. They will age the way any screenshot of live data ages. - Review outcome: **5 [fix here] · 1 [follow-up] — all 6 addressed.** Every one was a factual claim about the app, not prose. Details in the collapsed block. ## Related Documents #168, #185, #198, #205, #218, #238, #241, #242, #243 — the ticket-branches stack, tip branch `juanmaguitar/pr-state-colours`. Part of the contribution-flow tracker #110. Follows the docs site (#230), its screenshot harness (#231) and the guide itself (#232). --- <details> <summary>Design decisions and alternatives considered</summary> **A new page rather than growing `trac-tickets.md`.** That page is about one ticket: linking it, and reading the work that already exists on it. Several tickets is a different subject with its own vocabulary — parking, switching, deleting a branch — and folding it in would have doubled the length of a page a first-time contributor reads on their first ticket, to describe something they do not do yet. `trac-tickets.md` gains pointers instead. **Corrections before additions.** A page that is wrong costs more than a page that is missing: someone acts on it. So the pass over the existing guide came first, and the new page was written to fill what was left. **Screenshots taken by hand rather than by adding shot definitions.** The harness lives on trunk and the panels live on the stack, so a shot definition committed here would fail every `npm run shots` on trunk until the stack lands. The images were taken by assembling the two in a scratch worktree — stack tip, plus trunk's `scripts/screenshots/` and the `TOOLKIT_USER_DATA_DIR` hook, plus fixtures that build real repositories with ticket branches — and only the resulting PNGs were copied here. That worktree is gone; nothing of it is in this diff. **Quoting the app rather than paraphrasing it.** Where a sentence in the app is the thing being explained — the question about loose edits, the switch stages, the confirm before a delete — the page quotes it verbatim, so someone reading with the app open can match what they see. Every quote was read out of the stack's source. </details> <details> <summary>Review outcome (5 [fix here] · 1 [follow-up] — all addressed)</summary> Run per `.github/instructions/code-review.instructions.md`. Deterministic layer inline and clean — `npm run lint` clean, `npm test` 638/638, `npm run docs:build` clean. The judgement pass went to a subagent with the diff, the instructions file and the stack's source, and nothing from the session that wrote the pages; it was asked to check every quoted string and every behavioural claim against the code rather than to read the prose. Every finding was an accuracy finding — the guide asserting something the app does not do. All five were verified against the source before being fixed. | # | What was wrong | | --- | --- | | 1 🔴 | The new page's central how-to. "Type the number into the **Trac ticket** panel" — the panel holds no input while a ticket is linked; the field is in the unlinked branch only. The route is **Unlink**, then link the new number. Unfollowable as written. | | 2 🟡 | The mid-switch advice described a recovery that does not exist: the marker is written only when a checkout throws inside a running app, so a force-quit leaves nothing behind, and `midSwitchBlock` refuses the retry along with everything else. The one action it allows is **Unlink**, which is now what the page and `troubleshooting.md` say. | | 3 🟡 | `applying-patches.md`'s "use **Update to latest trunk** to reset the checkout" escape hatch. On a ticket the update parks, resets trunk and checks the branch back out — applied patch included — so it leaves you where you started. | | 4 🟡 | "Nothing is rebuilt" was true and misleading: a switch does not rebuild, so a running dev server keeps serving the previous ticket's assets. The page now says to run `npm run build` after a switch, which is what the site view itself says. | | 5 🔵 | "If the note says two changes, the patch has two files." The note counts everything the patch speaks about, binaries included, and those are named above the diff rather than carried — so one text file and one image is two in the note and one in the diff. It also contradicted this PR's own `submitting-changes.md`. | | 6 🔵 | The by-hand replay recipe skipped the trunk update that gives the new branch a newer base, and offered **Delete this ticket's work** for the ticket you are on, which the card never lists. | **`[follow-up]`, both taken here rather than deferred** because they were two sentences each: `managing-sites.md` and `trunk-updates.md` described the same dirty-tree dialog differently, and `troubleshooting.md` had no entry for the refused-switch message. Style notes, also applied: the tickets card is additionally gated on the setup checklist being finished or skipped, and the loose-edits question can be raised from the **Attach to Trac** card's link field too. The pass also confirmed what was most likely to be wrong and was not: every other quoted label and message is verbatim, all six screenshots match their alt text, `#deleting-a-ticket-s-work` resolves under VitePress's slugifier, and the new page is in the sidebar. </details> <details> <summary>Implementation notes</summary> **How the screenshots were taken.** `git worktree add` from the stack tip, then `git checkout <trunk> -- scripts/screenshots docs/…`, `playwright-core` installed, and the `TOOLKIT_USER_DATA_DIR` hook from `src/main.js` re-applied — the stack predates all of it, so `npm run shots` does not exist on that branch. Two throwaway fixture variants were added, both building real repositories through the app's own `src/ticket-branches.js` (`startTicketBranch`, `parkCurrentWork`, `switchToBranch`) so the state is the state the app makes: one site on `ticket/29798` with parked work and a second branch, one parked back on trunk with a loose edit. Same 1200×800 window and `--force-device-scale-factor=1` as `capture.cjs`, so the new images sit beside the existing ones without a size jump. The worktree has been removed. **The new slugs**, none of which `shots.cjs` knows about: `ticket-list-card`, `ticket-list-unlinked`, `trunk-work-question`, `carried-work-notice`, `linked-pull-requests`, `site-with-tickets`. **`linked-pull-requests.png` is one image doing three jobs** — the coloured state pills (#243), the unsubmitted-changes note (#241), and the reorganised ticket card with the tickets list no longer inside it (#242). **`docs/` is its own npm package**, so `npm run docs:build` needs `npm --prefix docs ci` first on a fresh checkout. </details> <details> <summary>Screenshots or recording</summary> Six new images, and one replaced. **The tickets card, linked** — `ticket-list-card.png`:  **The tickets card, unlinked** — `ticket-list-unlinked.png`:  **The question about loose trunk edits** — `trunk-work-question.png`:  **After choosing to carry them** — `carried-work-notice.png`:  **The ticket card with coloured pull request states and the unsubmitted-work note** — `linked-pull-requests.png`:  **The three cards in their new order** — `site-with-tickets.png`:  **Replaced:** `site-view.png`, whose action bar read **Submit changes**. No stack behaviour is visible in it — it is a straight re-take of a stale image. </details> --------- Co-authored-by: Claude Opus 5 (1M context) <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.
Why
A linked pull request's state renders as grey text, the same size and weight as the date beside it
and separated from it by a middle dot:
closed · updated 08/08/2026. The one word that says whathappened to the work reads as part of the timestamp, so the panel answers "when was this touched"
loudly and "did this go anywhere" in a whisper.
Part of #227 — its "make the outcome legible" half.
What changes
The state gets the pill the "Latest" marker already uses, in GitHub's own three colours: green for
open, purple for merged, red for closed. Same shape, same size, same radius — only the word and the
colours differ, so the row keeps one visual vocabulary.
Merged is a third state the parser used to throw away. GitHub's
statefield only ever says open orclosed; a merge is visible solely in
pull_request.merged_at, which thesearch/issuesresponse theapp already fetches carries. Keeping the distinction therefore costs no extra request against the
shared unauthenticated 60/hour that
src/patch-sources.cjsis careful with.Three constraints the design is built under, all three load-bearing:
contributor who cannot separate red from green less than the plain text it replaced.
window — a half-done update, an error banner. A closed pull request is not a failure. So the pill
wears GitHub's red (
#ffebe9/#82071e) rather than this app's error pair (#fcf0f1on a#d63638border), and it is a borderless pill rather than the bordered, alert-shaped box theerrors use.
open,mergedorclosedand nothing else.The mapping lives in a new
src/renderer/pr-state.cjswith its own test, per the renderer invariantin the review standard;
index.jsxis left holding the JSX and the call.Deliberately not in this PR: anything about what a state means. This does not close #227 — its
other half, telling a pull request whose work landed from one that was abandoned, needs the ticket's
own state and is not solved by colour.
How to test this
Platforms: any. Nothing here touches paths, spawning, line endings or signing.
Starting state: any initialized site, past the wizard. Needs network — the list comes from
GitHub's search API.
29798and click Link ticket. (Chosen because it has one open andone closed linked pull request; any ticket with linked PRs works.)
→ Under Linked pull requests,
#12943shows a redCLOSEDpill and#8913a greenOPENpill, each on the line under the title, with
updated <date>in grey beside it.banner, or compare against the yellow rate-limit notice on the same card.
→ The
CLOSEDpill is visibly a label, not a warning: no border, no icon, a softer red than thebordered
#d63638alert boxes.→ The cached list still renders, still with its pills. A row whose state the cache does not
recognise renders as
OPEN, which is what the row has always done — never blank, neverunlabelled.
The merged/purple case cannot be reached against wordpress-develop. A repository-wide search
returns zero merged pull requests ever, against roughly ten thousand closed — pull requests there are
opened for review and the change lands as a commit. To see it, force it locally: in
src/patch-sources.cjs, makeprStatereturn'merged'for a closed item, restart, and the rowshows the purple
MERGEDpill. It is covered by unit tests either way.What must not have happened:
a colour and no text is the regression to watch for.
marker, same Apply… behaviour. Only the state's rendering moved.
merged_atcomes from the responsealready being parsed.
Risks and limitations
render for wordpress-develop (see above). It is unit-tested rather than screenshotted from the real
repo.
last successful fetch keeps its old pill until the next successful lookup. The cache is already
labelled "showing what was last seen …", so this is visible rather than silent.
Related
Part of #227. Stacked on #242.
Design decisions and alternatives considered
Why a pill and not coloured text. Coloured text at 11px next to a grey date is a contrast
problem before it is a legibility win, and it keeps the state looking like part of the timestamp
sentence. The pill separates the two: a label and a date, not one run of small print.
latestPillalready established the shape in this exact row, so following it was cheaper than inventing one — the
two now share a
pillStyleobject rather than two copies of the same eight properties.Why GitHub's colours and not the app's palette. The contributor's next click on that row is
through to GitHub, where the same three states are painted the same three ways. Borrowing an
in-house green would mean the same fact wears two different colours a second apart.
Why red for closed, given red means failure here. The alternative was grey — but grey is what the
state looks like today, and it collapses closed and merged into "not open", which is exactly the
distinction the parser change exists to keep. GitHub's red is a different red from this app's error
red, and the pill shape carries no alert affordance: no border, no icon, no
role="alert". A testpins the two apart.
Why
mergedrather than a booleanmergedflag on the closed state. Three states is what the UIrenders and what GitHub itself models. A
{state, merged}pair would push the same three-waydecision into every consumer.
Unknown states read as open.
prStateBadgefalls back to the open styling for anything it doesnot recognise — a list cached by an older build carries only
open/closed, and "not closed readsas open" is the contract the row has always had. Failing to a blank or dropped label would be worse
than failing to the old behaviour.
Review outcome (required — see AGENTS.md)
/self-review(the procedure in.github/instructions/code-review.instructions.md, judgement passin a fresh context): 0 [fix here] · 1 [follow-up] — deferred.
Deterministic layer clean:
npm run lintclean,npm test762/762 on.nvmrc's Node 24.18.0,npm run test:electron762/762 on Electron's bundled Node.The follow-up, tests · 🔵 low, on
test/pr-state.test.cjs: two assertions readindex.jsx'ssource text rather than behaviour, so an unrelated change could turn this file red —
assert.ok(source.includes('#fcf0f1'))couples it to the error-banner background, and theexactly-one-
prStatePill(-call-site count would fail if a second, legitimate call site were added.Deferred, consciously:
test/ticket-branch-list.test.cjsfor the same reason — counting calls rather than bare names so a comment naming the helper is not a
red suite. Departing from it here would leave two conventions.
#fcf0f1assertion is a deliberate canary with its failure message written for exactly thiscase ("the error banner background moved; this test no longer compares against the real one").
Without it, the "closed is not the error styling" comparison rots silently the day the banner is
restyled — which is the more expensive failure of the two.
Both are cheap to revisit if the second call site or the restyle actually arrives.
Implementation notes
src/patch-sources.cjs—parseLinkedPrscalls a newprState(item):mergedwhenpull_request.merged_atis set, elseclosed/openfromitem.state. Thepull_requestguard thefunction already had (it is what tells a PR from an issue in a
search/issuesresponse) means theproperty access cannot throw on an issue item.
merged_atpresence in this response was verifiedagainst the live API.
src/renderer/pr-state.cjs— new:PR_STATE_BADGES(label + background + colour per state) andprStateBadge(state), case-insensitive with the open fallback. No React, no DOM; testable.src/renderer/index.jsx—pillStyleextracted fromlatestPill; newprStatePill(state)besideit; the PR row's second line becomes a flex row of pill +
updated <date>instead of a singledot-joined string.
Tests:
test/patch-sources.test.cjsgains the merged/closed-unmerged/open trio;test/pr-state.test.cjsis new. Both halves were verified to fail without the source change —parseLinkedPrs: a merged PR is merged, not closedfails on the old parser, andthe row renders the state through the pillfails on the oldindex.jsx.No new dependency, no IPC surface, no persistence-shape change, no platform-split code.
Screenshots or recording
Ticket #29798 on a real site, which has one closed and one open linked pull request.
Before — the state is grey 11px text, dot-joined to the date:
#12943 …/closed · updated 08/08/2026#8913 …/open · updated 06/07/2026After — a red
CLOSEDpill and a greenOPENpill, each followed byupdated <date>in grey.Merged, staged locally by forcing
prStateto return'merged'(wordpress-develop has no mergedpull requests to screenshot): the same row with a purple
MERGEDpill.