Skip to content

Colour a linked pull request by its state, in GitHub's own colours - #243

Merged
juanmaguitar merged 2 commits into
juanmaguitar/tickets-own-cardfrom
juanmaguitar/pr-state-colours
Aug 10, 2026
Merged

Colour a linked pull request by its state, in GitHub's own colours#243
juanmaguitar merged 2 commits into
juanmaguitar/tickets-own-cardfrom
juanmaguitar/pr-state-colours

Conversation

@juanmaguitar

Copy link
Copy Markdown
Collaborator

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 what
happened 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 state field only ever says open or
closed; a merge is visible solely in pull_request.merged_at, which the search/issues response the
app already fetches carries. Keeping the distinction therefore costs no extra request against the
shared unauthenticated 60/hour that src/patch-sources.cjs is careful with.

Three constraints the design is built under, all three load-bearing:

  • The colour accompanies the word, it never replaces it. A bare coloured dot would tell a
    contributor who cannot separate red from green less than the plain text it replaced.
  • The closed pill must not read as an error. Red means "something failed" everywhere else in this
    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 (#fcf0f1 on a
    #d63638 border), and it is a borderless pill rather than the bordered, alert-shaped box the
    errors use.
  • No copy is added. The pill says open, merged or closed and nothing else.

The mapping lives in a new src/renderer/pr-state.cjs with its own test, per the renderer invariant
in the review standard; index.jsx is 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.

  1. In Trac ticket, type 29798 and click Link ticket. (Chosen because it has one open and
    one closed linked pull request; any ticket with linked PRs works.)
    → Under Linked pull requests, #12943 shows a red CLOSED pill and #8913 a green OPEN
    pill, each on the line under the title, with updated <date> in grey beside it.
  2. Read the two pills next to the error styling elsewhere in the app — e.g. trigger any error
    banner, or compare against the yellow rate-limit notice on the same card.
    → The CLOSED pill is visibly a label, not a warning: no border, no icon, a softer red than the
    bordered #d63638 alert boxes.
  3. Turn off the network and click Refresh.
    → 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, never
    unlabelled.

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, make prState return 'merged' for a closed item, restart, and the row
shows the purple MERGED pill. It is covered by unit tests either way.

What must not have happened:

  • The word must never disappear. Colour is added to the label, not substituted for it — a pill with
    a colour and no text is the regression to watch for.
  • The list itself must not change: same pull requests, same order (newest first), same "Latest"
    marker, same Apply… behaviour. Only the state's rendering moved.
  • No second GitHub request. The rate-limit budget is unchanged; merged_at comes from the response
    already being parsed.

Risks and limitations

  • Presentation only. It does not close A ticket's pull request list says "closed" for work that landed in core and work that was abandoned #227.
  • The purple/merged branch is real in the API and correct to implement, but it will essentially never
    render for wordpress-develop (see above). It is unit-tested rather than screenshotted from the real
    repo.
  • After a GitHub failure the row falls back to the cached list, so a pull request merged since the
    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.
  • Self-review: 0 [fix here] · 1 [follow-up], deferred with a reason below.

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. latestPill
already established the shape in this exact row, so following it was cheaper than inventing one — the
two now share a pillStyle object 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 test
pins the two apart.

Why merged rather than a boolean merged flag on the closed state. Three states is what the UI
renders and what GitHub itself models. A {state, merged} pair would push the same three-way
decision into every consumer.

Unknown states read as open. prStateBadge falls back to the open styling for anything it does
not recognise — a list cached by an older build carries only open/closed, and "not closed reads
as 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 pass
in a fresh context): 0 [fix here] · 1 [follow-up] — deferred.

Deterministic layer clean: npm run lint clean, npm test 762/762 on .nvmrc's Node 24.18.0,
npm run test:electron 762/762 on Electron's bundled Node.

The follow-up, tests · 🔵 low, on test/pr-state.test.cjs: two assertions read index.jsx's
source 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 the
exactly-one-prStatePill(-call-site count would fail if a second, legitimate call site were added.

Deferred, consciously:

  • The call-site count is the house pattern, adopted one commit ago in test/ticket-branch-list.test.cjs
    for 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.
  • The #fcf0f1 assertion is a deliberate canary with its failure message written for exactly this
    case ("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.cjsparseLinkedPrs calls a new prState(item): merged when
pull_request.merged_at is set, else closed/open from item.state. The pull_request guard the
function already had (it is what tells a PR from an issue in a search/issues response) means the
property access cannot throw on an issue item. merged_at presence in this response was verified
against the live API.

src/renderer/pr-state.cjs — new: PR_STATE_BADGES (label + background + colour per state) and
prStateBadge(state), case-insensitive with the open fallback. No React, no DOM; testable.

src/renderer/index.jsxpillStyle extracted from latestPill; new prStatePill(state) beside
it; the PR row's second line becomes a flex row of pill + updated <date> instead of a single
dot-joined string.

Tests: test/patch-sources.test.cjs gains the merged/closed-unmerged/open trio;
test/pr-state.test.cjs is new. Both halves were verified to fail without the source change —
parseLinkedPrs: a merged PR is merged, not closed fails on the old parser, and
the row renders the state through the pill fails on the old index.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/2026

After — a red CLOSED pill and a green OPEN pill, each followed by updated <date> in grey.

Merged, staged locally by forcing prState to return 'merged' (wordpress-develop has no merged
pull requests to screenshot): the same row with a purple MERGED pill.

juanmaguitar and others added 2 commits August 10, 2026 15:38
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
juanmaguitar merged commit d227939 into trunk Aug 10, 2026
3 checks passed
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`:

![Other tickets on this
site](https://raw.githubusercontent.com/WordPress/contributor-toolkit/juanmaguitar/docs-ticket-branches/docs/public/screenshots/ticket-list-card.png)

**The tickets card, unlinked** — `ticket-list-unlinked.png`:

![Your tickets on this
site](https://raw.githubusercontent.com/WordPress/contributor-toolkit/juanmaguitar/docs-ticket-branches/docs/public/screenshots/ticket-list-unlinked.png)

**The question about loose trunk edits** — `trunk-work-question.png`:

![You have 1 uncommitted change on this site, not on any ticket yet.
What should happen to
them?](https://raw.githubusercontent.com/WordPress/contributor-toolkit/juanmaguitar/docs-ticket-branches/docs/public/screenshots/trunk-work-question.png)

**After choosing to carry them** — `carried-work-notice.png`:

![Your 1 uncommitted change came along into #62281, and will go into its
patch.](https://raw.githubusercontent.com/WordPress/contributor-toolkit/juanmaguitar/docs-ticket-branches/docs/public/screenshots/carried-work-notice.png)

**The ticket card with coloured pull request states and the
unsubmitted-work note** — `linked-pull-requests.png`:

![A red CLOSED pill and a green OPEN pill on the two linked pull
requests](https://raw.githubusercontent.com/WordPress/contributor-toolkit/juanmaguitar/docs-ticket-branches/docs/public/screenshots/linked-pull-requests.png)

**The three cards in their new order** — `site-with-tickets.png`:

![Trac ticket, then Other tickets on this site, then Apply a patch or
PR](https://raw.githubusercontent.com/WordPress/contributor-toolkit/juanmaguitar/docs-ticket-branches/docs/public/screenshots/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>
@juanmaguitar
juanmaguitar deleted the juanmaguitar/pr-state-colours branch August 11, 2026 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A ticket's pull request list says "closed" for work that landed in core and work that was abandoned

1 participant