Draw GitHub pull request and issue links as state pills #434
- MA
**Stack:** 1. #429 (summary route, merged) → **2. this PR (pills), based on `main`** → 3. hover card (based on this branch). Merge in order; rebase-merge only.
GitHub pull request and issue links in a document now draw as tinted state pills (style A from the jig review): a 14px state glyph, the authored title in primary ink, and a quieter `#number` that never truncates.
## What changed
- **Icons.** Seven GitHub state glyphs plus a pending ring and a 16px padlock live in `packages/icons/src/github.tsx`. They take no caller props, so the file needs no forwarded-styling exception. `line.tsx` is untouched, so its review hash still holds. - **Colour.** A new 12-step `--color-plum-*` scale and `--color-merged-{surface,graphic,icon,text}` roles, added through the design-contract steps: - `theme.css` - `DESIGN.md` gets a `merged` colour plus prose for merged roles and pills - `.impeccable/design.json` gets `colorMeta.merged` - the record mapping, with a drift test - `docs/design-contract.md` now counts fifteen colours - **Decoration.** `packages/editor/src/github-references.tsx` registers mutation listeners for `LinkNode` and `AutoLinkNode`. It sets `data-gh-kind`, `data-gh-state`, `data-gh-number`, `data-gh-editing` and an `aria-label` on Lexical's own `<a>`. `github-references.css` draws the pill: a `::before` mask glyph and an `::after` number. There is no dialect, Yjs or server-mirror change; the MDX stays a plain link. - **Store.** `apps/web/src/github-references.ts` (`GitHubReferenceCache`): - batches requests over 50ms, at most 20 refs each - dedupes in-flight requests and serves stale answers for up to 60s while it refetches - shares one in-memory cache across documents - a 401 clears the cache; sign-out already reloads the page - a failed request never claims "no access": it keeps the last answer, or shows neutral "status unavailable" - **Plumbing.** The store reaches editors through a React context, `GitHubReferencesProvider`, not a `PlanEditorProps` field. Adding a prop would change `plan-editor.tsx` and `static-plan-editor.tsx`, which are pinned by design-contract review hashes, and agents may not renew those. With the context, any editor the host wraps shows pills: the collaborative one, a read-only one, or the static specimen. Only `room-workspace.tsx` wraps today.
## Decisions
**Number rendering.** One text run can't truncate the title and also keep the number whole and quieter, so the number is generated content: `::after { content: attr(data-gh-number) }`. - The paste rule writes `[Title](url)`, and the decoration adds `#N`. - If the authored text already ends in that `#N` (for example the fallback `owner/repo#12`, or hand-written `Title #12`), no suffix is drawn, so nothing repeats. - A bare-URL link text still gets the pill and the suffix. Its URL text truncates, but the number stays readable. - Caret robustness: when the caret is inside a pill, `data-gh-editing` switches the pill from `inline-flex` to inline text in the same capsule. The 12.5rem clamp and clipping go away, and Home, End and the arrow keys move as they do in prose; a flex box would be its own line for them. Pseudo-element content is not selectable, copyable or exported.
**Paste race.** The rule inserts the link immediately, at the selection the paste targeted. If the store already has the summary, the link gets its title right away; otherwise it gets `owner/repo#N`. When the title arrives later (within 5s), it replaces the fallback text only if all of these still hold: - the node key and URL are unchanged - the link has a single unformatted text child that still equals the fallback - this editor's caret is not inside it
Anything else keeps the fallback. Links already in the document are never retitled. I chose this over awaiting the fetch with a timeout because the alternative has to resolve a selection later: another collaborator, or the author, may have typed in the meantime, and a late insert can land in the wrong place. Here the only asynchronous write is a guarded text swap on a node this client just made. The upgrade is its own undo step.
The paste rule skips: - ⇧⌘V (it shares the existing `isPlainPasteKey`) - code and maths - existing links - non-collapsed selections - clipboards carrying Lexical content or files
**Accessible name.** For example, `Pull request: Render decision cards #2, merged` or `Issue: acme/private#9, no access`. Kind comes from GitHub's answer when available, because `/pull/N` can redirect to an issue.
## Tests
- Unit tests for state mapping, number suppression, accessible names, paste detection, DOM decoration, glyph/CSS sync (the CSS masks must equal the `@chopin/icons` markup), the store (batching, chunking, SWR, failed revalidation, sign-out), and design-record drift. - `e2e/github-pills.e2e.ts` covers all seven fixture states plus no access by role and name, the paste rule (fallback upgraded to the title, and the unavailable fallback), and caret editing inside a pill. I ran it locally three times against my own server on a non-default port (not `bun run e2e`, to avoid port collisions); CI is the real check. - `bun run fix`, `bun test` (4257 pass), `bun run types` and `bun run ci` all pass locally. - Checked visually in a headless browser against the fake GitHub; it matches the jig's pill A.
## Needs a design-contract review
None. No review hash changed. The context plumbing above is how this PR avoided changing one.
## Notes for review
- Pre-existing, not changed here: a link whose text equals its URL (for example a typed or ⇧⌘V-pasted URL that the autolink plugin turns into a link) seems to export as a `<https://…>` autolink. The server's MDX parse rejects that, and in my local fake-GitHub run it took the server process down (`PlanParseError` from `room.ts` apply). Worth its own issue. This is why the e2e doesn't type a bare URL.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Unknown GitHub usercommitted 49c94421b008last month on GitHub
Unknown GitHub usercommitted 3615896c64c4last month on GitHub
The preview deployment for **chopin** is ready. 🟢
[Open app](http://434-chopin.githubnext.com) | [Open Build Logs](https://next-appserver.tail49836a.ts.net/project/l9buznc9ua1if0vflxrjwajs/environment/lxqsvczv19eopxk29vfb46mj/application/hb85gdd9mwqqlfwcteflvpbj/deployment/pctdus63vhu7ckewkso48h8n) | [Open Application Logs](https://next-appserver.tail49836a.ts.net/project/l9buznc9ua1if0vflxrjwajs/environment/lxqsvczv19eopxk29vfb46mj/application/hb85gdd9mwqqlfwcteflvpbj/logs)
Last updated at: 2026-10-09 14:13:51 CET
MaggieAppletonreferenced this in Open a GitHub card from a pill on hover, focus or caret #435last month on GitHub