| name | lightpanda-upstream-pr |
| description | Drive a single upstream Lightpanda contribution end-to-end — pick one item from this skill's references/upstream-wishlist.md (Section A bug or Section B missing method), verify it's still broken on current nightly, locate the Zig code in /Users/navid/code/browser, implement the fix with a Zig test, build a self-contained reproducer (Lightpanda + CDP only — never Ruby/Capybara), file a GitHub issue first with mermaid sequence diagrams of broken-vs-expected flow and the runnable repro script, then open a linked PR (`Closes |
| user_invocable | true |
| model | opus |
| effort | xhigh |
Lightpanda Upstream PR
Drive one Section A or Section B item from references/upstream-wishlist.md to a merged-quality upstream PR. This skill is end-to-end implementation, not reconnaissance — you write Zig, run Zig tests, push a branch, and open the PR.
Two repos are in play. Keep them straight:
| Path | Role | What this skill edits |
|---|
/Users/navid/code/capybara-lightpanda | The gem (Ruby). Source of the wishlist. | NOTHING — only reads references/upstream-wishlist.md (this skill's own reference) and gem workaround sources for context. |
/Users/navid/code/browser | Lightpanda upstream (Zig). | All edits land here. New branch, new commit, PR opened from here. |
The gem stays untouched in this skill. Removing the gem-side workaround happens in a separate turn after the upstream PR merges — never speculatively delete a workaround in the same session as the fix.
Reference files (load when needed)
references/upstream-wishlist.md — source of truth for items A1–A19, B1–B11. Read at Step 0.
references/file-mapping.md — wishlist item → gem-side workaround file + upstream Zig source file, plus a directory test-runner table for webapi/. Read at Steps 1c, 3, and 4.
references/templates.md — implementation prompt, reproducer skeleton, issue body, commit message, PR body, final report. Read at Steps 4, 6, 7, 8, 9.
references/visual-verification.md — GitHub markdown rendering checklist used by Steps 7c and 8e.
references/gfm-features.md — advanced GitHub-flavored-markdown features (alerts, <kbd>, <details>, mermaid, color swatches). Optional polish for issue/PR bodies at Steps 7 and 8.
Audience: write for a Zig browser engineer, not a Rubyist
The Lightpanda maintainer works in Zig + V8 on a browser engine. They are not familiar with Ruby, Rails, ActiveRecord, Capybara, RSpec, Turbo, Stimulus, or any Ruby-side framework. Every issue body, PR description, commit message, and reproducer must land for that audience or it gets ignored.
Hard rules:
- Never reproduce a bug with a Ruby/Capybara/RSpec test. Reproducers must run with only Lightpanda + a CDP client the maintainer can run in 30 seconds:
curl over websocket, a tiny Node script using chrome-remote-interface, or lightpanda fetch against a static HTML file. A Rails or Capybara repro is opaque — they can't run it without a Ruby toolchain they don't have.
- Never describe behavior in framework terms. Don't say "Capybara's
click_link calls Page.navigate and then…" — say "a CDP client sending Page.navigate then awaiting the Page.loadEventFired event observes…". CDP semantics and HTML/DOM spec citations are universal; framework names aren't. Same for Turbo Drive — describe the actual DOM mutation pattern (document.body.innerHTML = …; document.body.replaceWith(newBody)) rather than naming the library.
- Mermaid diagrams are required, not optional. Every issue gets a sequence diagram of the broken vs. expected CDP/event flow. Every PR gets a flowchart of the old (broken) vs. new (fixed) code path. They cut review time dramatically when the reviewer is doing surgery on engine code they didn't write — a 10-line diagram replaces a paragraph of prose.
- Cite the spec, not the consumer. When stating expected behavior, link to: HTML Living Standard section, CDP protocol reference (
https://chromedevtools.github.io/devtools-protocol/), or Chrome's source if the spec is silent. Never "because Capybara expects…".
- Reproducer must be self-contained: one
repro.html + one repro.sh (or repro.js). No bundle install, no gem install, no Gemfile. If you need Node, declare the exact one-liner: npm install --no-save chrome-remote-interface and inline the script.
Step 0: Pick the item
Source of truth: references/upstream-wishlist.md. Each entry has an ID (A1–A19, B1–B11).
User typically names the item directly ("fix A14"). If they don't:
- Ask which one, OR
- Suggest the next from the recommended order (Step 0a) and confirm.
Refuse and explain if the user names a Section C item — those are inherent (no rendering engine). Recommend running cuprite for that lane instead. For items that may already be addressed (already fixed, open PR by us), the duplicate check in Step 1b will catch them — don't hardcode their state here.
Step 0a: Recommended order if the user is undecided
The wishlist's "Quick wins" section reflects the priority. If unsure, pick the smallest still-actionable item.
First, filter through the wishlist's own annotations. Each item in references/upstream-wishlist.md may carry a **Upstream issue**: / **Upstream PR**: line — items already filed by us are ineligible for this skill (don't open duplicates). Skim the candidates' wishlist entries before consulting the priority list below; Step 1b's gh pr list is the second-pass safety net, not the first.
Treat (open as of YYYY-MM-DD) annotations as hints, not facts. The wishlist is hand-maintained and stale fast — items can flip from open to merged-and-shipped on the same day you start work. Always cross-check git fetch && git log --oneline origin/main -20 and gh pr list --state all --limit 20 before committing to an item. Step 1a/1b run these checks anyway, but a stale annotation can mislead the recommended-order picking before you ever get there.
- A14 —
requestSubmit() polyfill on HTMLFormElement. Smallest, isolated, easy to test. Good first PR.
- A6 —
Page.reload replays POST. Targeted CDP fix, single domain file.
- A1 + A2 + B3 — cookie clearing trio. Bundle these because they share a root cause. (Exception to the "one PR per item" rule — only because the upstream fix is a single change.)
- A3 —
Page.handleJavaScriptDialog actually dismisses/accepts. Touches page.zig + dialog plumbing.
- A8 —
#id selector regression after body replacement. Check gh pr list first; we may already have a PR open.
- B1 —
XPathResult + document.evaluate. Largest scope (~700 LOC drop on the gem side). Last because it's the most invasive Zig change.
Section A bugs > Section B missing methods, generally — bugs have clearer "want" semantics (Chrome behavior). Missing methods may need design discussion upstream first.
Step 0b: Anti-patterns (refuse to do these)
- Bundling unrelated fixes into one PR. Each item gets its own branch + issue + PR. Reviewers reject mixed changes. The A1+A2+B3 bundle above is the only exception, and only because they share a one-function fix.
- Writing Ruby tests for the Zig fix. Verification on the gem side is a separate phase (Step 5) and uses the existing gem-side test as a regression check, not a new spec.
- Skipping the Zig test. Every fix gets at least one
test "..." block in the same .zig file or under src/browser/tests/. CI requires it and reviewers will block.
- Opening a PR without an issue first. Always file the issue (Step 7) before the PR (Step 8). An orphan PR has no place to record the reproducer cleanly and gives the maintainer no chance to weigh in on approach before code review.
- Opening a PR without a
Closes #<n> line. The PR body MUST include the literal text Closes #<issue-num> referencing the Step 7 issue. This wires up GitHub's auto-close on merge. Without it, the issue stays open after the PR merges and someone has to remember to close it manually — which never happens. Step 8d verifies GitHub actually parsed the link via gh pr view ... --json closingIssuesReferences. If that returns empty, the PR body is wrong and must be edited before continuing.
- Pasting a Ruby/Capybara/RSpec reproducer into the issue. The maintainer can't run it. Reproducers are CDP + HTML only — see Step 6. If you can't reduce the bug to a CDP-only repro, the bug isn't isolated enough to fix yet.
- Skipping mermaid diagrams. Issue and PR both require diagrams (sequence diagram for the issue, flowchart for the PR). They're not decoration — they're the fastest way for a Zig engineer to understand a bug they didn't write.
- Filing the issue or PR without visually verifying the rendering. After every
gh issue create and gh pr create, navigate to the URL with the Playwright MCP and confirm mermaid diagrams render as graphs (not as mermaid code blocks), code fences are intact, Closes #<n> is hyperlinked, and the body reads cleanly. See references/visual-verification.md. Steps 7c and 8e are mandatory, not optional.
- Re-filing an item we already filed. If
gh pr list (Step 1b) returns an open PR by us for this item, report status and stop — don't open a duplicate.
- Branching off a stale
main. Every session that enters /Users/navid/code/browser MUST git checkout main && git pull origin main before creating the fix branch — even if the repo "looks fine" or you were on main recently. Upstream moves fast; stale-base branches conflict and miss fixes. Step 2 enforces this; never skip it.
- Hand-injecting
mise exec -- or $V8 is not your job. Zig 0.15.2 is pinned in ~/.config/mise/config.toml globally, so plain zig in any shell (including Claude's non-interactive Bash subshells) resolves to the mise-pinned binary. The prebuilt-V8 -Dprebuilt_v8_path=… flag is auto-injected by the lp-zig-build-rewrite.sh PreToolUse hook (~/.claude/hooks/). Just write zig build check / zig build test — the hook substitutes the literal $V8 with the resolved flag if you include it, or appends the flag if you forget. See "Local build & test commands" below.
- Running
make build or make build-dev for verification. Those forces ReleaseFast and rebuild the V8 snapshot — slower than what you need. Use zig build check / zig build test directly.
Step 0c: Pivot policy when verification surfaces an adjacent bug
The Step 1a probe sometimes retracts the original wishlist item AND incidentally exposes a real bug in a sibling code path. Today's session is the canonical example: A4 (form.submit() doesn't navigate) was retracted, but the same probe matrix revealed A20 (formaction/formmethod/formenctype on submit button ignored) — distinct bug, same Frame.submitForm function.
When this happens, decide between pivot in-session or stop and let the user pick the next item:
- Pivot in-session when ALL of the following hold:
- The adjacent bug lives in the same Zig file (or a sibling file in the same area) as the retracted item — fix scope is similar.
- The verification probe you just wrote covers ≥80% of the new item's reproducer (no fresh fixture/server design needed).
- The new bug has a clear spec-citable "want" behavior (HTML Living Standard, CDP protocol, Chrome source). Avoid pivoting to bugs whose correct behavior is ambiguous.
- The new bug doesn't require a different fix shape (e.g. don't pivot from "missing CDP method" to "wrong event timing" — those are different review surfaces).
- Stop otherwise. Surface the new finding to the user (one paragraph: what you saw, where, why it's distinct from the retracted item) and ask whether to pivot to it, file a tracking issue without a PR, or move to the next wishlist item.
When you pivot:
- Add a new entry for the discovered bug to
references/upstream-wishlist.md (mirror the format of existing items: Today / Want / Gem workaround / Drop-on-fix). Pick the next free A## or B## ID.
- Retract the original entry per the
A4/A5/A9 precedent (NOT A BUG with empirical evidence + git-blame citation).
- Restart the skill flow at Step 1c for the new item — Steps 0/0a/0b are already satisfied (you just selected it).
- Reuse the verification probe from Step 1a — usually it's already 80% of Step 6's reproducer.
Step V: Verifying existing work on a branch (skip the full Step 0–9 flow)
Use this branch when the user says "branch X is done, verify it" / "we made changes in the worktree, check it's correctly done" / "review PR #N" rather than asking to start a new fix. The skill's main 0–9 path assumes greenfield implementation; verification has a different shape — the issue/PR may already exist and the toggle-off + reproducer were ideally done at implementation time but you're confirming retroactively.
Run these checks in order. Each one cross-references an existing Step in this file — go read that Step's detail if a check fails:
- Locate the worktree, then check state. Run
git worktree list | grep <branch> from anywhere inside /Users/navid/code/browser* to find which directory has the branch checked out — a fresh session won't know, and trying to git checkout <branch> from the main clone when it's worktree-locked fails with '<branch>' is already used by worktree at <path> (and the workaround of checking out fork/<branch> puts you in detached HEAD, which silently misroutes any later git push). If grep returns nothing, the branch is not in any worktree — it lives in the main clone. Fall back to cd /Users/navid/code/browser && git checkout <branch>; don't git worktree add (creates a duplicate that drifts) and don't assume the branch is missing. Then cd <worktree-or-main-clone>; git status is clean; git log --oneline main..HEAD shows the expected commits; git diff main..HEAD --stat is scoped to relevant files only (no repro/, no editor config).
- Issue + PR linkage.
gh issue view <n> --repo lightpanda-io/browser --json state,title and gh pr view <n> --repo lightpanda-io/browser --json state,closingIssuesReferences. The PR's closingIssuesReferences MUST include the issue number — see Step 8c. If empty, the Closes #<n> line is missing or malformed in the PR body.
- Toolchain pin.
zig version prints 0.15.2 — see Step 2's pin block.
- Compile clean.
zig build check is clean — see Step 4a.
- Targeted test passes. Run the new test with the right
TEST_FILTER form (see "Local build & test commands" — <test-name>#<html-basename> for fixtures). Confirm it passes.
- Toggle-off proves the test exercises the fix.
git checkout main -- <production-file>, re-run the targeted test, confirm it fails. Restore with git checkout HEAD -- <production-file>. See Step 4a's two-pattern bullet.
- Full suite has no NEW failures.
zig build test. Cross-reference any failures against the "Known-flaky-on-macOS tests" list; only investigate failures NOT on that list.
- End-to-end reproducer. Build the local debug binary if missing (
zig build), then LIGHTPANDA_BIN=<worktree>/zig-out/bin/lightpanda bash repro/<dir>/repro.sh exits 0. See Step 6c.
- Issue + PR body content.
gh issue view <n> --json body --jq .body > /tmp/issue.txt and same for PR. Audit per references/visual-verification.md: 2 mermaid diagrams (sequence in issue, flowchart in PR), CSS/HTML/CDP spec citation, no Ruby/Capybara/gem leak, no wishlist-ID leak, Closes #<n> literal text in PR body. See Steps 7a/7c and 8b/8d.
- PR CI.
gh pr checks <n> --repo lightpanda-io/browser — for first-time external contributors only CLAAssistant runs until a maintainer approves; that's normal, surface as a status note, don't try to "fix" it.
If any of 1–8 fail, surface the specific failure to the user with a fix proposal and STOP — don't silently re-do work the user might have already validated. If 9–10 reveal cosmetic issues (rendering, hyperlinks), fix-and-republish via gh issue edit --body-file / gh pr edit --body-file.
Local build & test commands
The recipes below are bare zig build …. Two layers do the right thing automatically so the recipe stays minimal:
- Toolchain pin —
~/.config/mise/config.toml lists zig = "0.15.2" and zls = "0.15.1" globally, so the snapshot PATH that any shell (interactive or not) inherits resolves zig to mise's 0.15.2 install. No per-project mise.toml, no mise exec -- prefix needed. If you ever see a Zig stdlib mismatch error (error: root source file struct 'fs' has no member named 'File' and friends), the snapshot is stale — zig version should print 0.15.2.
- V8 prebuilt flag — the
lp-zig-build-rewrite.sh PreToolUse hook (~/.claude/hooks/) intercepts every zig build command targeting /Users/navid/code/browser* and injects -Dprebuilt_v8_path=/Users/navid/code/browser/.lp-cache/prebuilt-v8/libc_v8_<version>_macos_aarch64.a if missing. Writing zig build $V8 check is also fine — the hook substitutes the literal $V8 with the resolved flag. Without the hook, plain zig build would rebuild V8 from source (~20+ min). The hook stderr-logs every rewrite so you can audit it in the tool output.
| Command | When to use |
|---|
zig build check | After every Zig edit. Fastest signal — type-check only, no codegen, no link. Catches compile errors across the whole project. |
TEST_FILTER='<test-name>#<html-basename>' zig build test | After writing/changing the test for the fix. Both halves are substring matches via std.mem.indexOf, so 'Element' will pick up WebApi: Element AND MCP - findElement — be specific. The #<html-basename> half is only consulted by htmlRunner(...) to filter which tests/<dir>/*.html fixtures run; omit it for plain test "..." blocks. Concrete examples: TEST_FILTER='Selector: Parser.attributeValue' (Zig unit test), TEST_FILTER='WebApi: Element#attribute_value_escapes.html' (one HTML fixture under tests/element/). |
zig build test | Before pushing. Full unit-test suite — verifies nothing else regressed. |
zig build | When you need a debug binary at ./zig-out/bin/lightpanda (e.g., to re-run the Step 6 reproducer post-fix). |
zig build run -- <args> | Build & run the binary in one step. |
Sanity-check the toolchain once per session before the first build: zig version must print 0.15.2. If it prints 0.16.x (system Homebrew install), the global mise pin in ~/.config/mise/config.toml isn't reaching this shell's PATH — restart Claude Code so the snapshot regenerates, then retry. If zig isn't installed at all, mise install zig@0.15.2 zls@0.15.1 from anywhere does it.
Performance notes:
- First run after a dep update builds curl/brotli/sqlite/html5ever (~1–2 min). Subsequent runs are incremental.
zig build check typically finishes in <10s after warm-up.
zig build test runs in 30s–2min depending on what changed.
- The
extras step (legacy_test, snapshot_creator) is not in the default — don't trigger it.
Known-flaky-on-macOS tests (reproduce identically on main HEAD, NOT caused by your branch):
MCP - findElement (src/mcp/tools.zig:1141)
cdp.lp: action tools (src/cdp/domains/lp.zig:357)
cdp.lp: waitForSelector (src/cdp/domains/lp.zig:423)
If zig build test fails ONLY on tests in this list, do NOT investigate. Confirm upstream CI on main is green for zig-test / e2e-test (gh run list --repo lightpanda-io/browser --branch main --limit 3) and move on — these are local-environment (likely macOS-vs-Linux) drift, not regressions you introduced. To prove pre-existence in seconds: cd /Users/navid/code/browser && TEST_FILTER='<test name>' zig build test against the unmodified main. Same panic = pre-existing. Only investigate if a NEW test fails or one of these starts producing a different failure mode.
Local verification reduces the cost of a broken CI run: catch trivial errors on your machine first, let upstream CI be the authoritative second opinion (it builds on Linux against the upstream toolchain, which catches macOS-vs-Linux drift you can't see locally).
Step 1: Pre-flight
Before touching any code, confirm three things. Do this before Step 2 — the entire branch + tasks + issue draft is wasted work if the bug is already fixed or already filed.
1a. Verify the bug with a pure-CDP probe (the wishlist's Today line is not authoritative)
The wishlist entries are written from gem-level Capybara observation by humans on a specific date. The gem routes through workarounds (CLICK_JS, fetch+swap, polyfills) that can mask or misdiagnose what's actually broken upstream — you'll see this most often when the wishlist's Today claim is broader than the actual bug. Always confirm with a pure-CDP probe before bootstrapping the branch.
cd /Users/navid/code/browser
git fetch origin && git log --oneline origin/main -10
Three confirmation paths, in order of cheapness:
- Pure-CDP probe against the installed nightly — the strongest signal and the right default for any DOM / event / navigation / form / cookie bug. Copy
references/probe-lib/cdp.js into /tmp/probe-<id>/, write a 30-line probe.js that exercises the bug via Page.navigate + Runtime.evaluate + Target.attachToTarget, and run it against a lightpanda serve from the binary already on disk. Asserts the wishlist's claim with no gem code in the loop. Do this even if you "know" the bug is real — the 5 minutes saves the 30+ minutes of branch + tests + issue-draft work that gets thrown away when you discover the bug is already fixed.
- Grep the relevant
.zig file for the missing symbol — only valid for pure absence (e.g. B1's XPathResult doesn't exist anywhere; B3's Network.getAllCookies not in the dispatch enum). Absence is the repro. Don't use this path for "method exists but misbehaves" — that needs a probe.
- Build local debug binary + re-run probe — only when the installed nightly is older than recent commits to the relevant
.zig file AND the probe against nightly is ambiguous. zig build then LIGHTPANDA_BIN=./zig-out/bin/lightpanda <re-run probe>.
Cross-check intuition with git blame: if the wishlist comment says "method X doesn't navigate" but git blame src/<path>.zig on the relevant function shows the navigation call has been in place for weeks/months, trust blame. The wishlist note is one author's empirical claim from a single timestamp; blame is what's actually in main right now. Today's session burned an hour on A4 because the gem's CLICK_JS comment authoritatively claimed form.submit() doesn't navigate, but git blame Frame.zig:3756-3768 showed the navigation logic had been there for a month — the gem author had misdiagnosed a symptom.
If the bug appears already fixed, stop and tell the user. Either:
- The wishlist is stale → retract the entry per the
A4/A5 precedent (NOT A BUG with empirical evidence + git-blame citation in the wishlist + lightpanda-io.md) and recommend a follow-up gem cleanup PR if a workaround can now be deleted.
- The fix is in
main but not in the installed nightly → record it as merged + waiting for nightly in the wishlist; pick a different item.
If verification surfaces an adjacent real bug (the original is fixed but a sibling code path is broken), see Step 0c.
1b. No existing upstream issue or PR addresses it
gh issue list --repo lightpanda-io/browser --search "<keyword>" --state all --limit 10
gh pr list --repo lightpanda-io/browser --search "<keyword>" --state all --limit 10
Keywords to use per item: requestSubmit for A14, clearBrowserCookies for A1, getNavigationHistory for B2, XPathResult for B1, handleJavaScriptDialog for A3, etc.
If a PR exists:
- Open — link it, ask user whether to add to it (comment) or skip. Don't open a duplicate.
- Merged but not in nightly — wait for next nightly, don't re-do.
- Closed unmerged — read the close reason. Either upstream rejected the approach (don't re-file the same way) or it was superseded (find the successor).
- Closed issue with merged-but-partial fix PR — closures are sometimes partial. Read the closing PR's description for explicit deferrals (
"future work", "follow-up", "could follow separately", "larger change"). The deferred portion is usually un-filed and is the actual scope for a fresh issue+PR. Frame your new issue as the follow-up — link the closing PR (PR #X (closing #Y) wired up Z but explicitly deferred W: <quote>. This issue tracks that follow-up.) so the maintainer immediately recognizes the lineage.
1c. Locate the gem-side workaround for context
The wishlist says where the workaround lives, and references/file-mapping.md has the full table (item → gem file). Read the workaround. The fix has to make it unnecessary, so understanding what the workaround does pins down the spec — exact return shape, error code, event name the upstream fix must match.
Read gem-side comments as one author's hypothesis, not as authoritative spec. Comments like "Lightpanda's form.submit() does NOT navigate — it parses, validates, but never issues an HTTP request" describe what the gem author observed at a single point in time, often via a Capybara-level test that routes through several gem layers. The actual upstream behavior may differ — symptom misattribution is common when the bug surfaces through framework code. Cross-check every load-bearing claim in a gem comment against git blame on the upstream production code. If blame shows the relevant call has been in place for months, the comment is wrong (or stale, or referring to a different code path). Step 1a's pure-CDP probe is the tiebreaker.
Verify only the item you're fixing — don't take adjacent wishlist items at face value. The wishlist is updated by hand and entries drift between reviews; a neighbor item described as broken may have been silently fixed upstream. If the design of your reproducer or fix depends on a neighbor item's behavior (e.g., A6's reproducer happens to call form.submit(), and A4 claims form.submit() doesn't navigate), spend two minutes confirming the neighbor is still broken — empirically, via a tiny CDP probe — before designing around the wishlist's claim. Cheaper than over-engineering a workaround you didn't need.
Step 2: Bootstrap branch in /Users/navid/code/browser
Non-negotiable: every session that touches /Users/navid/code/browser starts by checking out main and pulling the latest from origin. The upstream repo moves fast — branching off a stale main means rebasing later, missed fixes, and PRs that conflict on day one. Do this even if you "just" left the repo on main in a previous session; another machine, another teammate, or a Dependabot bump may have advanced it.
cd /Users/navid/code/browser
git status
git checkout main
git pull origin main
git log --oneline -5
git checkout -b fix-<item-id>-<slug>
If the main clone is on a different open-PR branch (because a previous session left it there), prefer creating a worktree for the new fix — keeps the main clone's branch intact and matches the existing per-fix-worktree pattern. Use git worktree add -b fix-<id>-<slug> ../browser-<slug> origin/main; the worktree starts from a fresh origin/main automatically.
git worktree list only enumerates worktrees of the repo containing CWD. Running it from /Users/navid/code/capybara-lightpanda (the gem) returns the gem's worktrees, not the browser's. To see browser worktrees you must cd /Users/navid/code/browser && git worktree list (or any subdirectory of the browser repo). Same trap exists in Step 10b for review-flow — same fix.
If git status is dirty (uncommitted work from a previous session, stray repro artifacts, etc.), stop and surface it to the user — do not stash, reset, or clean without permission. The user decides whether to keep, discard, or move that work.
Remote naming: this clone uses two remotes — origin = lightpanda-io/browser (upstream, where git pull reads from) and a personal fork (e.g. fork = navidemad/browser, where pushes go). Run git remote -v to confirm. The pull above targets origin; the push at Step 8a targets the fork (git push -u fork ...) and gh pr create needs --head <fork-owner>:<branch> --repo lightpanda-io/browser since the source branch lives on the fork. If git remote -v shows only origin pointing at your fork, the convention collapses to plain origin — but verify before assuming.
Sanity-check the Zig toolchain. The repo's build.zig.zon declares minimum_zig_version = "0.15.2" but does not commit a .zig-version / .tool-versions file. The pin lives in ~/.config/mise/config.toml globally (zig = "0.15.2", zls = "0.15.1"), so any shell resolves zig to the right version without per-project setup:
zig version
If it prints anything else, restart Claude Code so the snapshot picks up the global mise pin. Building with the wrong Zig produces stdlib errors that look like real bugs and burn debugging time.
Step 3: Locate the Zig code
references/file-mapping.md has the item → Zig file map and rg recipes for finding CDP dispatch enums and JS API bindings. Use it as a starting point, then confirm with grep — file layout drifts.
Step 4: Implement fix + Zig test
Use the implementation prompt template in references/templates.md to drive the Zig changes. The template can be applied two ways — work through it inline (default for small single-file changes) or paste it into a general-purpose subagent (better for multi-file changes or to keep main context lean). The template is self-contained either way; fill in the <...> placeholders before applying.
Before adding a test "..." block in src/browser/webapi/<File>.zig, consult the Directory test runners table in references/file-mapping.md. Many webapi files own an entire tests/<dir>/ directory via htmlRunner("<dir>", .{}) — adding a sibling test block that re-runs a fixture in that directory duplicates work and gets flagged in review. Drop new fixtures into the directory the runner already covers and skip the extra test "..." block.
Work TDD: failing test → confirm it fails → implement → confirm it passes → no regressions.
4a. Verification gates before moving on
Use the local commands from "Local build & test commands" — fast enough that all of these gates are cheap:
zig version prints 0.15.2, matching build.zig.zon's minimum_zig_version. Anything else means mise isn't resolving the pinned toolchain — fix Step 2's pin before doing anything else, otherwise every subsequent build is suspect.
zig build check is clean. No compile errors anywhere in the project (not just the file you edited).
- A new
test "..." block exists in the appropriate .zig file covering the fix. Run TEST_FILTER=<test name> zig build test and confirm it passes.
- Toggle the fix off and confirm the new test fails — this proves the test actually exercises the fix, not some unrelated path. Restore the fix afterwards. Two patterns by where the test lives:
- Test in the same
.zig file as the fix (common for CDP changes — both live in src/cdp/domains/<domain>.zig): Use Edit to surgically revert just the production lines, NOT git stash. A git stash sweeps the test out alongside the fix and the toggle re-run reports 0 of 0 tests passed instead of a real failure. The reliable pattern is: Edit the fix call site to its pre-fix shape (e.g. delete the .method/.body/.header fields, hardcode .method = .GET), run, observe failure, Edit it back. If you do reach for git stash, stage the test first (git add <test-file>) so --keep-index actually retains it.
- Test is an HTML fixture under
src/browser/tests/<dir>/ (common for htmlRunner directories like tests/element/, tests/document/): the production code and the fixture live in different paths, so use git checkout main -- <production-file> to surgically revert just the production code. The fixture stays untouched and the same TEST_FILTER='<runner>#<fixture>.html' re-run reports a real fail (e.g. for the B7 fix: git checkout main -- src/browser/webapi/selector/Parser.zig, then TEST_FILTER='WebApi: Element#attribute_value_escapes.html' zig build test). Restore with git checkout HEAD -- <production-file> ONLY IF the branch has at least one commit on it. If you haven't committed the fix yet, HEAD points at the same SHA as main and the restore is a silent no-op — your edited Input.zig is gone and the next run validates the wrong-and-restored production code with no warning. Two ways to avoid the trap: (a) commit the fix first, then toggle, then git checkout HEAD -- actually restores; or (b) cp <production-file> /tmp/<file>.fix before the git checkout main --, then cp /tmp/<file>.fix <production-file> to restore. Either way, sanity-check after the restore by grepping for a unique token from your fix (e.g. grep -c snapToStep src/<path> should be ≥1).
- Verify the test actually ran the new fixture, not nothing. Both
WebApi: Element and WebApi: HTML.Input are valid test names but each owns different fixtures via separate htmlRunner(...) calls — tests/element/*.html (top-level) vs. tests/element/html/*.html (subdirectory, registered explicitly per file). A wrong TEST_FILTER matches the test name but skips your fixture entirely; both pre- and post-fix runs print 1 of 1 test passed and look fine, hiding the bug. Two tells: (1) the timing — a fixture-less run finishes in ~0.1ms; a real fixture run shows tens of ms (e.g. 9.46ms). (2) The output should print - src/browser/tests/.../<your-fixture>.html under the test name. If it doesn't, the filter missed; grep htmlRunner src/browser/webapi/<area>/<File>.zig to find which test name owns your fixture.
zig build test (full suite, no filter) passes — catches regressions in adjacent code.
- The reproducer from Step 6 has been confirmed to exit 1 (bug observed) against the current nightly binary already on disk. Recommended: build a local debug binary with
zig build and re-run the reproducer against ./zig-out/bin/lightpanda to confirm exit 0 (bug fixed end-to-end). This is the strongest pre-push signal — it validates the unit test, the binary, and the reproducer together. (The local debug build needs ~5 GB free in .zig-cache; if df -h . shows less, skip it — the unit test + pre-fix reproducer + CI cover the same ground, and a NoSpaceLeft error here only burns time. Don't auto-clean caches without asking the user.)
- The diff matches the surrounding file's existing style (naming, comment density, helper layout) and contains no "while-we're-here" reformatting. Outsider PRs get reviewed line-by-line — reviewers reject mixed scope. Every changed line traces directly to the bug; if you wrote 200 lines and 50 would do, rewrite.
git diff shows only files relevant to the fix. No editor config, no local-only artifacts.
Local pass is necessary but not sufficient — upstream CI runs on Linux against the upstream toolchain and may surface platform-specific issues you can't see on macOS. Treat CI as the authoritative final check after a clean local pass.
4b. If upstream CI is broken for unrelated reasons
If recent main runs in upstream's GitHub Actions are red without your changes (toolchain mismatch, dependency churn, transient breakage), stop and report the failing CI URL to the user. Check via gh run list --repo lightpanda-io/browser --branch main --limit 5. Do not paper over it by editing .zig-version, bumping deps, or rebasing onto an older commit — those are separate decisions the user has to make. Possible next steps to surface: pin to the last green commit, file a separate upstream issue about the breakage, or wait it out. Local zig build test passing on a red-CI main is a useful data point but doesn't substitute — surface the CI breakage anyway.
Step 5: Validate against the gem (sanity check, no edits)
The point of this PR is to make the gem's workaround unnecessary. Spot-check that the fix actually does that, without modifying gem code:
cd /Users/navid/code/capybara-lightpanda
For items where the gem skip-lists tests (spec/spec_helper.rb), the test should be runnable after the fix lands — but don't unskip in this PR's scope. That belongs to a follow-up gem PR.
For items where the gem implements a polyfill (A14, B1, A8), confirm the upstream behavior matches what the polyfill emulates — same return shape, same event sequence, same error semantics. If it doesn't, the polyfill won't be deletable, which means the fix is incomplete or the polyfill diverged from spec.
If the validation reveals a gap (fix works but doesn't fully obsolete the workaround): document the residual gap in the PR description rather than expanding scope. A partial fix is fine; a misleading PR is not.
Step 6: Build the reproducer
Before filing anything, build a self-contained reproducer the maintainer can run with no Ruby toolchain. This artifact is referenced by both the issue (Step 7) and the PR (Step 8), so do it once, well.
A working repro.js + repro.sh skeleton lives in references/templates.md ("Reproducer skeleton (Step 6)"). It already encodes the non-default chrome-remote-interface config Lightpanda needs (target: ws://... + local: true), the /json/version readiness probe that asserts the listener is actually Lightpanda, and the port-cleanup pre-step. Start from that skeleton — don't rebuild it from scratch.
6a. Where to put it
Local workspace only — do not commit the reproducer to the upstream branch. Suggested layout:
/Users/navid/code/browser/repro/<id>-<slug>/
repro.html # minimal fixture, no frameworks
repro.sh # orchestrates lightpanda + driver + assertions
repro.js # (if needed) Node CDP client using chrome-remote-interface
README.md # what the script asserts; expected vs. actual output
Add the repro/ directory to .git/info/exclude so it can't accidentally be committed.
6b. Reproducer requirements
The script must:
- Start
lightpanda serve --host 127.0.0.1 --port 9222 in the background, with a clean PID trap so it dies when the script exits.
- Serve
repro.html over HTTP (python3 -m http.server on a free port is fine).
- Drive the browser through CDP — pure curl/websocket if simple enough, otherwise a small Node script using
chrome-remote-interface (one npm install --no-save chrome-remote-interface line at the top).
- Print exactly the observation that demonstrates the bug, then
exit 1 if the bug is observed and exit 0 if not. The maintainer should see the bug in one terminal command.
- Include a header comment listing: prerequisites, what to run, expected output today, expected output after the fix.
Cap the whole thing at ~80 lines of shell + ~50 lines of JS. If the bug requires more than that to reproduce, the bug isn't isolated enough — split it.
Keep the wishlist ID out of the file contents. The directory name (a6-page-reload-replay-post/) is private — the maintainer never sees it. But anything inside the files (HTML <title>, JS comments, shell header comment, FAIL messages, Python module docstring) gets pasted verbatim into the issue body at Step 7, where strings like "A6 reproducer" or "see wishlist A4" are meaningless project-internal IDs that read as a downstream-consumer leak. Use generic, behavior-describing wording in the file contents — "reload-replay-POST repro", "FAIL: Page.reload regressed to GET.", etc. The same rule applies to references to capybara-lightpanda, Capybara, RSpec, Turbo, etc. — none of those have any business appearing inside an upstream-facing reproducer.
6c. Verify the reproducer pre-fix and (recommended) post-fix
Run the reproducer against the current nightly binary already on disk and confirm exit code 1 (bug observed). This proves the bug exists in nightly and the reproducer correctly catches it.
After Step 4 implements the fix, build a local debug binary with zig build and re-run the reproducer against ./zig-out/bin/lightpanda to confirm exit 0 (bug fixed). This is the most direct end-to-end signal — it exercises the fix through the same CDP surface the maintainer will use to verify the patch. If the unit test passes but the reproducer still exits 1, the fix is incomplete (often: the test exercises an internal helper but the CDP dispatch path was missed).
Step 7: File the issue first
The issue is filed before the PR, even when both go up the same day. The PR will close it via Closes #<n>. Filing the issue first gives the maintainer a place to comment on approach if they disagree, and gives the bug a permanent searchable record independent of any single PR's life.
7a. Compose the body
Use the issue body template in references/templates.md. The template includes both required mermaid sequence diagrams (broken vs. expected CDP flow) and slots for the reproducer.
7b. File the issue
Use the Write tool to stage the body file at /tmp/<id>-issue-body.md, then pass it via --body-file. Don't shell out a cat <<'EOF' ... EOF heredoc — body content frequently contains substrings (process.env.X, dotfile paths, .env-style references) that trip local pre-tool hooks and reject the bash invocation. --body-file also makes the re-publish loop cleaner: edit the file, then gh issue edit <n> --body-file <same-file>.
gh issue create --repo lightpanda-io/browser \
--title "<title — area: short description>" \
--body-file /tmp/<id>-issue-body.md
Capture the issue number from the response (e.g., https://github.com/lightpanda-io/browser/issues/2400 → 2400). The PR description and commit message both reference this number — wrong number means broken auto-close.
7c. Visually verify the issue rendering
Mermaid diagrams, nested code fences, and HEREDOC escape edge cases break in subtle ways that look fine in source but render wrong on GitHub. Apply the checklist from references/visual-verification.md (common section + issue-only section). If anything renders wrong or could read better, edit and re-publish before moving on.
Step 8: Open the PR linked to the issue
Push the branch and open the PR. The PR body must contain a literal Closes #<issue-num> line referencing the issue from Step 7. This wires up GitHub's auto-close: when the PR merges, the issue closes automatically, the wishlist tracker stays accurate, and reviewers can see the linked issue in the right-sidebar of the PR. Without this line, the issue stays open after merge and someone has to remember to close it manually — which never happens.
GitHub recognizes any of: Closes, closes, Close, Fixes, fixes, Resolves, resolves, plus the past-tense variants. Pick Closes for consistency across all our PRs.
8a. Push and create
Stage the PR body the same way as the issue body — use the Write tool to put it at /tmp/<id>-pr-body.md, then pass --body-file. Heredoc invocations get tripped by local hooks; --body-file survives them and makes re-publish a single command.
cd /Users/navid/code/browser
git status
git diff --stat
git add <specific files>
git commit
git push -u origin fix-<id>-<slug>
grep -E "Closes #[0-9]+" /tmp/<id>-pr-body.md || { echo "MISSING Closes line — abort"; exit 1; }
gh pr create --repo lightpanda-io/browser \
--base main \
--title "<title>" \
--body-file /tmp/<id>-pr-body.md
8b. Templates
Commit message and PR description templates are in references/templates.md. Match the project's commit style — check git log --oneline -20 for examples. Lightpanda uses lowercase area prefixes (cdp:, dom:, forms:, page:, runtime:, network:).
Do not mention capybara-lightpanda or the wishlist by name in the PR or commit. The fix should stand on its own merits — Lightpanda is a browser used by many clients, and naming one downstream consumer biases reviewers. Refer to "downstream CDP clients" generically if context demands.
8c. Verify the auto-close link is wired up
Right after gh pr create returns the URL, verify GitHub actually parsed the Closes #<n> and linked the issue. A typo (Close #1234., closes#1234, wrong number) silently fails and leaves the issue orphaned.
gh pr view <pr-num> --repo lightpanda-io/browser \
--json closingIssuesReferences \
--jq '.closingIssuesReferences[].number'
This must print the issue number from Step 7. If it's empty: edit the PR body via gh pr edit <pr-num> --body-file <file> to fix the Closes line, then re-run the check. Don't move on to Step 8d until the auto-close is confirmed.
8d. Visually verify the PR rendering
Same drill as 7c, against the PR URL this time. Apply the checklist from references/visual-verification.md (common section + PR-only section: flowchart rendering, hyperlinked Closes #<n>, Linked Issues sidebar, Files-changed tab matches Fix bullets). Fix-and-republish if anything reads sloppy — don't ship a sloppy artifact when polish is two minutes away.
8e. Post-submit hygiene
After the PR opens and the auto-close link is verified:
- Capture both URLs (issue + PR) in the report back to the user.
- Do not mark the wishlist item as fixed in
references/upstream-wishlist.md yet — only when the PR merges and ships in a nightly. Add a note next to the item: **Upstream issue**: #<i>, **Upstream PR**: #<n> (open as of YYYY-MM-DD).
- Do not delete the gem-side workaround — that's a follow-up gem PR after the nightly ships, in a separate turn.
Step 9: Report back
Use the final-report template in references/templates.md. If you stopped before submitting (because the bug was already fixed, a duplicate exists, or the fix needed design discussion), the report explains why and what's needed to unblock — no issue/PR URL, but if you filed an issue without a PR, surface that URL.
Step 10: Responding to maintainer review
Triggered when the user says "reply to the maintainer review on PR #N" / "address the comments on the PR" / "analyze the maintainer feedback" / "what did the reviewer ask for" — typically after Step 9 has already happened in a prior session and the maintainer left feedback in the meantime. This is a separate flow, not part of the linear 0–9 implementation.
The flow has two phases that can run in one turn or two: analyze (fetch comments, summarize each one, propose a path) and act (edit code, commit, reply with SHA). When the user's verb is "analyze" / "what did they ask for" / "review the feedback", do phase 1 only — stop after summarizing and wait for the user to greenlight before editing. When the verb is "reply" / "address" / "fix the comments", run both phases. If unsure, do phase 1 and ask. This protects against acting on a comment the user wants to push back on.
10a. Fetch the actual review comments
gh pr view --json reviews only returns top-level reviews (often empty body when the maintainer left only inline comments). The inline comments live at a different endpoint:
gh api repos/lightpanda-io/browser/pulls/<n>/comments \
--jq '.[] | {id, user: .user.login, path, original_line, body, in_reply_to_id, commit_id}'
Each comment carries an id (numeric, used in the reply endpoint), path + original_line (where in the diff the comment lives), and commit_id (which commit they reviewed against). If in_reply_to_id is non-null, that comment is itself a reply to an earlier one — walk the chain to understand the thread.
GitHub also has a "code suggestion" feature: a comment whose body starts with \``suggestion` proposes a literal replacement. Treat suggestions like any other comment for the purpose of replying — but if you adopt one verbatim in a follow-up commit, say so explicitly ("Adopted in ") so the reviewer knows the suggested code shipped.
10b. Address the feedback in code BEFORE replying
Locate the worktree first. Run cd /Users/navid/code/browser && git worktree list | grep <branch> — the explicit cd is required because a fresh session typically starts in /Users/navid/code/capybara-lightpanda (the gem) and git worktree list only enumerates worktrees of the repo containing cwd. The branch is often checked out at /Users/navid/code/browser-<slug>/ from a previous session — cd into the worktree directory before editing. If you skip this and git checkout <branch> from the main clone, you'll either be blocked ('<branch>' is already used by worktree at <path>) or fall back to checking out fork/<branch> and end up in detached HEAD, where edits commit fine but the eventual git push either fails or pushes the wrong ref.
If grep returns nothing, the branch is not in any worktree — it lives in the main clone (a previous session may have done all its work there, or this is the first follow-up since the branch was created). Fall back to cd /Users/navid/code/browser && git checkout <branch>; don't git worktree add (creates a duplicate that drifts from the original), don't assume the branch is missing.
Before applying review fixes, check mergeable. Run gh pr view <n> --repo lightpanda-io/browser --json mergeable --jq .mergeable. If it's CONFLICTING, the branch is stale relative to origin/main and must be reconciled before you can push fixes — see Step 10b-merge below.
10b-merge. Reconcile a stale branch before applying review fixes
Skip this sub-step if mergeable is MERGEABLE. If it's CONFLICTING:
- Use
git merge origin/main, not rebase. The branch is published on the fork and the PR is open — rebase rewrites SHAs that the PR's review threads anchor on, breaks gh pr view --json closingIssuesReferences, and forces reviewers to re-fetch. Merge keeps SHAs stable and the merge commit is its own diff for the reviewer to skim.
- Common conflict pattern: both branches added a new
test "..." { ... } block at the same file location, producing a diff3 conflict where the merge-base section (||||||| <sha> to =======) is empty. Resolution: keep both blocks complete in the new file. The trailing } } outside the conflict region close whichever test sits last — read the original file structure before the merge to see how many closing braces existed there. Don't blindly delete content from either side; both maintainers added intentional code.
- Auto-merged files need verification, not trust.
git merge reports "Auto-merging X" for files where it applied a successful three-way merge — but that doesn't prove your branch's changes survived. After git commit of the merge, grep for the key identifiers your branch added (struct fields, function names, test names, route paths, _navigated_options.body, etc.) in the merged tree. If something's missing, the merge took the wrong side; redo the resolution.
- Compile + run targeted tests (
zig build check then TEST_FILTER='<your test>' zig build test) to catch the merge dropping a struct field or rename. Then move on to apply the review fixes; commit them as a separate follow-up commit on top of the merge so the reviewer sees a clean "merge + fixes" pair, not a tangled single commit.
Skip the Step 4a toggle-off for behavior-preserving refactors. The toggle-off gate ("revert production, confirm test fails, restore") exists to prove a new test exercises a new fix. When the maintainer asks for a refactor that should preserve behavior — extract a helper, rename, dedupe, factor a single iteration — branch HEAD already contains a verified fix and the relevant tests. The verification is "tests pass before AND after the refactor", not toggle-off. Don't reach for git stash + git checkout main -- <file> here either: HEAD's previous-fix version, the stash's refactor, and main's pre-fix version are three different versions of the same hunk and git stash pop will conflict on every overlapping line. If the refactor changes observable behavior (rare in review-driven follow-ups), fall back to Step 4a's full toggle-off.
If the comment requires a code change, make and push the change first, then reply citing the new commit SHA. Replying with "will do" or "thanks, addressing now" is noise — the reviewer's GitHub feed surfaces every reply and "addressing" comments without a SHA make them re-check later. One reply, one commit, one SHA.
When the change spans multiple comments, prefer one commit that addresses all of them rather than one commit per reply. Reviewers re-review by diffing the new commit against the previous one; one commit is one diff.
10c. Compose and post the replies
Use Write to stage each reply body at /tmp/<id>-reply-<n>.json (JSON wrapper, not raw markdown — the API takes a JSON body). This avoids heredoc escape issues with backslashes and dollar-signs the same way --body-file does for issues/PRs.
Pick the endpoint based on what you're replying to. The two surfaces look similar in the GitHub UI but use different APIs:
- Inline comment (anchored to a file/line, fetched at Step 10a): reply via
pulls/<n>/comments/<comment-id>/replies. The new comment threads under the original in the PR's "Files changed" tab, exactly where the maintainer wrote their note.
gh api -X POST repos/lightpanda-io/browser/pulls/<n>/comments/<comment-id>/replies \
--input /tmp/<id>-reply-1.json \
--jq '{id, html_url, body}'
- Top-level review (a review the maintainer left via "Review changes" with state
COMMENTED / APPROVED / CHANGES_REQUESTED, surfaced by gh pr view --json reviews): there is no replies endpoint. Post a regular issue-level comment via issues/<n>/comments. PRs are issues for general-comment purposes, so the comment appears in the main "Conversation" tab and the maintainer gets a notification.
gh api -X POST repos/lightpanda-io/browser/issues/<n>/comments \
--input /tmp/<id>-reply-toplevel.json \
--jq '{id, html_url}'
In the body, briefly summarize which points you're addressing (since there's no thread anchor) and lead with the SHA — same template as 10d.
A single review session often produces both shapes — Karl's typical pattern is one inline code-suggestion plus one top-level "here's the bigger concern" review. Address both in one follow-up commit and post both replies pointing at the same SHA.
10d. Reply template
Lead with the SHA, then why (or the tradeoff). One or two sentences max. The maintainer is busy.
- Adopted verbatim:
Adopted in <SHA>. <one-sentence rationale or property the change relies on>. (Example: "Adopted in a5e5639a. Pre-sizing to input.len is a clean upper bound since CSS escapes only ever shrink.")
- Adopted with adjustment:
Done in <SHA> — adjusted to <X> instead of <Y> because <reason>. Surface the deviation up-front so the reviewer doesn't have to read the diff to find it.
- Pushed back / disagree: explain the constraint, propose an alternative, ask for direction. Don't silently ignore. Example: "Considered but it breaks — would work for you?"
- Out of scope: "That's true, but tracking separately as it's outside this PR's scope (file an issue, link the issue # here). Happy to land it as a follow-up."
Never reply with just "done" — it forces the reviewer to dig for the SHA. Never reply with "thanks!" — it's noise that costs them a notification with no information gain.
10e. Verify the replies posted correctly
gh api repos/lightpanda-io/browser/pulls/<n>/comments --jq '.[] | select(.in_reply_to_id == <comment-id>) | {body, html_url}' confirms each reply landed under the right thread. Capture the html_url values in the report back to the user so they can audit if needed.
Read the review state before deciding whether to re-request review. gh pr view --json reviews --jq '.reviews[] | {author: .author.login, state}' shows it. Two states matter for follow-up:
COMMENTED: feedback without a hard block. The maintainer is not gating merge on a re-review; replying with the SHA is enough. Don't requested_reviewers -X POST unless the user explicitly asks — it pings the maintainer for a no-op and burns goodwill.
CHANGES_REQUESTED: a hard block — merge is gated on the maintainer dismissing the review or approving a follow-up. Once you've addressed everything, prompt re-review explicitly: gh api repos/lightpanda-io/browser/pulls/<n>/requested_reviewers -X POST -f reviewers='["<username>"]'. Even here, only do it if the user asks; otherwise just push and reply, and let the maintainer come back on their own schedule.
APPROVED reviews don't need a reply at all — surface the approval to the user as a status update and stop.