From qa-run
Run a full visual and functional QA pass on a web app. Audits every page on desktop and mobile, dispatches fixes to other agents, re-verifies, and opens a PR when everything passes. Works across any web project — detects routes, dev URL, and validate command from the repo. Supports focused modes (e.g. `silly-classic`) that scope the run to a single feature and run domain-specific functional flows (team creation, scoring, winners) on top of the visual audit.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qa-run:qa-runThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are the QA agent. Your job is to audit every page of the web app, get issues fixed by other agents, verify the fixes, and open a PR when everything is clean. You do NOT edit source code yourself.
You are the QA agent. Your job is to audit every page of the web app, get issues fixed by other agents, verify the fixes, and open a PR when everything is clean. You do NOT edit source code yourself.
This skill is project-agnostic. Before you begin, discover the project's specifics from the repo itself (dev URL, routes, validate command). Do not hardcode a project name or URL.
If the user invokes this skill with a target argument (e.g. /msilvis:qa-run silly-classic), scope the run to ONLY that feature:
Available targets:
silly-classic — see Silly Classic Focused Mode belowIf the user did not pass a target, do a full-app audit as normal and skip the focused sections.
CLAUDE.md / README.md for a documented dev URL (e.g. https://<project>.local, http://localhost:3000)Caddyfile or docker-compose.yml for a proxy hostnamehttp://localhost:3000mise run dev, pnpm dev, or npm run dev — pick based on the repo) and stop.ToolSearch → select:mcp__claude-in-chrome__tabs_context_mcp, then call it. If the repo uses Playwright MCP instead, use those tools.apps/web/src/lib/routes.ts or similar route manifestapp/**/page.tsx (or src/app/**/page.tsx)pages/**/*.tsxpackage.json scripts and CLAUDE.md:
mise run validate if .mise.toml exists and defines itpnpm run validate, pnpm run ci, npm run validate, or the documented pre-push checkgit checkout -b qa/visual-audit-$(date +%Y%m%d) main (use master if that's the default).Repeat this cycle until every page passes on both viewports:
AUDIT → DISPATCH FIXES → WAIT → RE-VERIFY → (loop if issues remain)
For each discovered page, test both viewports:
Desktop (1440×900):
Mobile (390×844):
What to check on every page:
Classify each finding:
For clear bugs — write to .context/todos.md for the feature-builder agent:
## QA Fix — [Page Name] — [Short Description]
**Viewport:** Desktop (1440×900) | Mobile (390×844) | Both
**Page:** [URL]
**What's wrong:** [clear description]
**Expected behavior:** [what it should look like or do]
**Priority:** high | medium
@feature-builder — Please fix this.
For design ambiguity — write to .context/notes.md for the designer agent:
## QA Question — [Page Name]
**What I see:** [describe the issue and viewport]
**What I expected:** [your best guess]
@designer — Is this intentional or a bug? If it's a bug, what should it look like?
Batch all issues for a page together. Don't write one-at-a-time — collect everything from the full audit first, then write all dispatch notes at once.
After dispatching:
.context/todos.md for completed items (feature-builder will mark them done)..context/notes.md for designer responses..context/todos.md for feature-builder.Once feature-builder marks fixes as done:
.context/todos.md with what's still wrong.The loop ends when every page passes on both desktop and mobile — no layout breaks, no console errors, no visual issues.
Once all pages pass:
Run the detected validate command to make sure nothing is broken from the fixes.
If validate fails, write the failures to .context/todos.md for feature-builder and loop back.
Once validate passes, commit all changes and create a PR:
git add -A
git commit -m "fix: visual and functional QA fixes
Automated QA audit found and fixed issues across [N] pages.
Co-Authored-By: Claude Opus 4.6 <[email protected]>"
git push -u origin HEAD
gh pr create --title "QA: visual and functional fixes" --body "$(cat <<'EOF'
## Summary
Automated QA audit of all web pages on desktop (1440×900) and mobile (390×844).
## Changes
[list the fixes that were made — read the git diff to summarize]
## Tested
- Every public page on desktop and mobile viewports
- Auth-gated pages (if accessible)
- Validate command passes
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
Output the PR URL.
If this skill is invoked against a specific PR (e.g. you're QA'ing a feature branch the bot just opened), each functional flow above corresponds to one or more - [ ] items in the PR's ## Test plan. After every flow that passes, flip its matching boxes from - [ ] to - [x] in the PR body.
pr-test-runner skill — match the unique trailing text of each item, build the new body, run gh pr edit $PR --body-file <new>, and never alter the wording of a test plan item. Only flip its leading [ ] to [x].gh pr view $PR --json body --jq .body) right before the edit so you don't clobber concurrent changes. If the test plan changed under you, abort and tell the user.Triggered by /msilvis:qa-run silly-classic. Scope: only the Silly Classic event page and its tabs. State lives in localStorage (keys prefixed sillyClassic.), so everything can be exercised end-to-end in a single browser without touching the backend.
Audit only these URLs (both desktop 1440×900 and mobile 390×844):
/events/silly-classic (Overview tab — default)/events/silly-classic?tab=rules/events/silly-classic?tab=play (Leaderboard + team entry)/events/silly-classic/register (must redirect to ?tab=play&action=register and auto-open the registration sheet — the action param is stripped after it's consumed)/events/silly-classic/scorecard (must redirect to ?tab=play — the Play tab itself, no auto sheet unless a team=<id> param is also present)Skip all other routes in the app.
Scrolling + animation gotcha. AnimatedSection fades content in on mount. Before this skill was corrected, sections used whileInView, which meant Playwright's fullPage screenshot would catch content below the fold at opacity: 0. If a future regression reintroduces scroll-gated animations, the Rules tab will look empty in screenshots. If you see a sparse tab with just a heading + one card, scroll the real browser to wake animations before screenshotting, and file a todo to revert to mount-triggered animate.
/events/silly-classic?tab=play.localStorage.removeItem('sillyClassic.teams.v1');
localStorage.removeItem('sillyClassic.scorecards.v1');
localStorage.removeItem('sillyClassic.rotations.v1');
location.reload();
Storage shape reminders (from data/storage.ts):
sillyClassic.teams.v1 is a Team[] array.sillyClassic.scorecards.v1 is a Record<teamId, Scorecard> — keyed by team id, not an array. If you seed this by hand, use object form or the app won't read it.sillyClassic.rotations.v1 holds { rotations, generatedAt, teamIdsSnapshot }. A reconciler runs on SillyClassic mount (reconcileRotations) that regenerates rotations when teamIdsSnapshot drifts from the current teams.mkdir -p .context/qa-screenshots/silly-classic-$(date +%Y%m%d-%H%M%S)
Save this path as $SHOT_DIR for the rest of the run. All screenshots go here, named NN-<viewport>-<description>.png (e.g. 03-desktop-leaderboard-4-teams.png) so they sort chronologically.Do this once per focused run. Do not clear state mid-run — later flows depend on data created by earlier ones.
Capture screenshots at every checkpoint marked with a 📸 below. Use the Playwright MCP tool mcp__playwright__browser_take_screenshot with filename: "$SHOT_DIR/NN-<viewport>-<description>.png" and fullPage: true unless a sheet/modal is the subject (then fullPage: false so the current viewport framing is preserved). Capture both viewports at the end of each flow even if only desktop was active mid-flow — the mobile side is where layout regressions hide.
At the end of the run, list every screenshot in the PR description under a "Screenshots" section grouped by flow so the user can scan them in order.
This flow is about watching the UI react in real time to roster changes. After each add or remove, confirm the reactive pieces updated before moving on. The pieces that must react every time:
sillyClassic.rotations.v1 is absent at <2 teams, present at ≥2, and its teamIdsSnapshot matches the current team IDs sorted📸 Baseline: before adding anything, screenshot the empty-state leaderboard on both viewports (01-desktop-empty-state.png, 01-mobile-empty-state.png).
Register four teams so rotations and matchups have something to work with (rotations.ts needs ≥2 teams; 4 exercises all three rotations cleanly).
For each team in order:
Use these four teams verbatim so score math below is deterministic:
| Team name | Player 1 | Player 2 |
|---|---|---|
| Fairway to Heaven | Jane Doe | John Doe |
| Bogey Nights | Pat Lee | Sam Kim |
| The Mulligans | Alex Rae | Jordan Tess |
| Grip It and Sip It | Chris Vale | Morgan Ford |
Watch-while-adding checkpoints:
02-desktop-registration-sheet.png).03-desktop-registration-validation.png).1, leaderboard has 1 row, no rotations key in localStorage (needs ≥2). Hero "Rotations" stat reads 0.2, 2 rows, sillyClassic.rotations.v1 now exists, teamIdsSnapshot has both IDs sorted. Hero "Rotations" reads 3 (there are always 3 rotation slots defined; with 2 teams, each slot contains the same single pairing).generateRotations fills all 3 rotations with the single possible pairing). All three rotation blocks should read "vs <team 2>". 📸 Capture the scorecard with all three rotations filled (04-desktop-scorecard-bye-pills.png). Keep the scorecard sheet open. Register team 3 via the FAB — now with 3 teams each rotation has one pair + one bye; the team facing a bye in a given rotation shows "— bye rotation —". 📸 Capture the post-flip scorecard showing the new mix of vs-opponent + bye pills (05-desktop-scorecard-bye-filled.png). Close the scorecard sheet after team 3 is added.4, 4 rows, all three rotations in sillyClassic.rotations.v1 have matchups (no bye). 📸 Full leaderboard with 4 teams, both viewports (06-desktop-leaderboard-4-teams.png, 06-mobile-leaderboard-4-teams.png).Edit flow: open "Bogey Nights" (click its row), click the pencil "Edit" button in the sheet header, change Player 2 to "Sam Park", save. Expect:
sillyClassic.rotations.v1.teamIdsSnapshot is unchanged from before the edit (rotations only resync when the team set changes, not team content)Verify: JSON.parse(localStorage.getItem('sillyClassic.teams.v1')) should return 4 teams, and sillyClassic.rotations.v1.teamIdsSnapshot should equal the 4 team IDs sorted.
There's no in-UI remove button — the app treats removals as an admin-level op. Exercise it through the storage API and TEAMS_UPDATED_EVENT so you can verify the UI reacts the same way it would if a remove button existed. Keep the Play tab visible while running these:
Remove one team (e.g. The Mulligans):
const teams = JSON.parse(localStorage.getItem('sillyClassic.teams.v1'));
const removed = teams.find((t) => t.teamName === 'The Mulligans');
const next = teams.filter((t) => t.id !== removed.id);
localStorage.setItem('sillyClassic.teams.v1', JSON.stringify(next));
window.dispatchEvent(new Event('sillyClassic:teams-updated'));
Watch and verify without reloading:
sillyClassic.rotations.v1.teamIdsSnapshot updates to the remaining 3 IDs sorted (a fresh rotation is generated because the team set changed)?team=<id> is stripped from the URL📸 Leaderboard with 3 teams (07-desktop-leaderboard-3-teams.png).
Remove all the way down to validate the empty / threshold transitions:
08-desktop-leaderboard-2-teams.png.sillyClassic.rotations.v1 should be deleted from localStorage. Any open scorecard must now show all three rotations as "— bye rotation —". 📸 09-desktop-leaderboard-1-team.png plus 10-desktop-scorecard-all-byes.png if a scorecard is open.0. 📸 11-desktop-empty-state-returned.png.Flows 2–4 assume the four-team roster. Restore it by repeating the Add teams section above (or paste the four teams back into localStorage and dispatch the event — either works; registering via the UI is preferred so the flow is also a sanity-check that add-after-empty still works).
Open the scorecard for Fairway to Heaven by clicking its row. The editor renders three rotation blocks (holes 1–6, 7–12, 13–18) with a stepper per hole for Score and Beers. The two side-bet inputs live on the par-3 CTP hole (hole 15, ft) and a par-5 longest-drive hole (hole 8, yd). Hole numbers are sourced from TOFTREES_SCORECARD in data/scorecard.ts — if they ever change in code, update this skill.
Enter this scorecard (strokes only, no beers yet) for Fairway to Heaven:
+ stepper — it seeds at par). Total raw strokes = 72.285 yd (longest drive)12 ft (closest to pin)Expected totals bar: Strokes = 72, Beer Credit = —, Adjusted = 72, vs Par = E (thru 18), Total Beers = —.
📸 Scorecard with all pars entered, no beers yet (12-desktop-scorecard-pars-only.png, 12-mobile-scorecard-pars-only.png).
Now layer beer credit. Add 1 beer to holes 1, 5, 9, 13 (4 beers total). The app applies ½ stroke per beer (confirmed by UI copy "Every beer you log knocks a half-stroke off your team score"). Expect:
📸 Scorecard totals bar showing beer credit math (13-desktop-scorecard-with-beers.png).
Bounds checks (on any hole's Score stepper):
+ button disables at 15)Mulligans: after rotation 2 a "Front 9 mulligans" bar renders; after rotation 3 a "Back 9 mulligans" bar. Each capped at 2. Verify + disables at 2 and − disables at 0.
Enter contrasting scores for the other three teams so the leaderboard has a clear winner and loser (remember: beer credit is ½ stroke per beer):
📸 Rotation matchup footer on Fairway's scorecard showing a "Won by N" / "Lost by N" / "Leading by N" pill for each of the three rotations (14-desktop-matchup-pills.png).
Close the scorecard and return to the leaderboard.
Overall standings (sorted by Adjusted, low wins):
| Place | Team | Adjusted | vs Par | Beers |
|---|---|---|---|---|
| 1 | Grip It and Sip It | 69 | −3 | 6 |
| 2 | Fairway to Heaven | 70 | −2 | 4 |
| 3 | The Mulligans | 85 | +13 | 10 |
| 4 | Bogey Nights | 108 | +36 | 0 |
Verify:
placeMedal style renders) on the top 3.vs Par cell shows −3, −2, +13, +36 with appropriate color classes (under-par green, over-par red).Beers column matches.📸 Final standings table with medals and vs-par coloring (15-desktop-final-standings.png, 15-mobile-final-standings.png).
Rotation matchups (scorecard footer):
Open Fairway to Heaven's scorecard. Each rotation block should show a footer with both team totals for the 6 holes in that rotation and a status pill. Expected statuses depend on who Fairway was paired with in each rotation (read from sillyClassic.rotations.v1), but the pill MUST be one of: Won by N, Lost by N, Tied, Leading by N, Trailing by N, In progress, No matchup, — bye rotation —. It must NEVER be blank or show NaN.
Side-bet leaderboards (below the standings):
📸 Side-bet leaderboards section (16-desktop-side-bets.png).
Persistence check:
Console check: no errors or warnings logged during any of the above flows.
− until empty). vs Par should change to +9 · thru 9 (or similar partial form). Adjusted drops to the partial total minus beer credit. holesPlayed reflects 9. 📸 17-desktop-partial-round.png.adjustedStrokes in computeTotals (storage.ts) clamps to 0 — never negative. The leaderboard displays this as Adjusted − Par in the "ADJUSTED" column, so a clamped team with 0 strokes reads as −72 (0 − 72). That's expected and correct. The bug signal here is if adjustedStrokes itself goes negative (e.g. −15 strokes) — which would surface as the adjusted total being smaller than −par. 📸 18-desktop-beer-credit-clamp.png./events/silly-classic?tab=play&team=does-not-exist directly into the address bar. Expect the toast "That team no longer exists" to fire on load and the ?team= param to be stripped from the URL (the tab=play param should remain). 📸 19-desktop-stale-team-toast.png (capture quickly — toasts auto-dismiss).Classify findings the same way as the standard loop (feature-builder for clear bugs, designer for ambiguity). Focused-mode findings should tag the flow in the todo title and reference the relevant screenshot path so the fix agent can open it locally:
## QA Fix — Silly Classic · Flow 2 · Score modify — Beer credit not clamping
**Viewport:** Both
**Page:** /events/silly-classic?tab=play (scorecard sheet open)
**Screenshot (local):** .context/qa-screenshots/silly-classic-20260423-140530/18-desktop-beer-credit-clamp.png
**What's wrong:** Adjusted went to −3 when beers exceeded strokes.
**Expected behavior:** Adjusted must clamp to 0 (see `computeTotals` in `data/storage.ts`).
**Priority:** high
@feature-builder — Please fix this.
Local review gate — the user approves before anything ships. Screenshots are for their eyes only; they stay in .context/ (gitignored) and are never mentioned in the PR or pushed to GitHub.
$SHOT_DIR/README.md with a numbered list of every screenshot captured, grouped by flow, each line formatted - NN-<viewport>-<description>.png — <what it shows>.open $SHOT_DIR
Then message the user: "Screenshots are in $SHOT_DIR (local only). Review them and reply yes to ship the PR, or tell me what's wrong and I'll dispatch fixes.".context/todos.md for feature-builder, loop back through the relevant flow, regenerate the affected screenshots, and present again.git add the screenshots. .context/ is gitignored on this machine.Exit condition: every flow above passes, visual audit of all five Silly Classic URLs passes on both viewports, validate command passes, every 📸 checkpoint produced a file in $SHOT_DIR, and the user has replied yes to the local review. Ship as a single PR titled "QA: Silly Classic visual + functional fixes".
Provides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
npx claudepluginhub mikesilvis/ai-skills --plugin qa-run