diff --git a/.changeset/vivid-yaks-fly.md b/.changeset/vivid-yaks-fly.md new file mode 100644 index 000000000..24ef32a2e --- /dev/null +++ b/.changeset/vivid-yaks-fly.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 4477 +--- +**`/gsd-ui-review` can capture post-interaction UI states** — a new default-off `workflow.ui_interaction_capture` key on the `ui` capability lets `gsd-ui-auditor` add hover, focus-ring, open-menu and filled-form captures through the `chrome-devtools` CLI, driven from Bash with no MCP server and no tool-surface change. Requires an installed Chrome; when off, or when none resolves, the Playwright-only static capture runs exactly as before. (#4223) diff --git a/agents/gsd-ui-auditor.md b/agents/gsd-ui-auditor.md index f502d99c3..8d7797a98 100644 --- a/agents/gsd-ui-auditor.md +++ b/agents/gsd-ui-auditor.md @@ -78,29 +78,21 @@ If no UI-SPEC exists: audit against abstract 6-pillar standards. ## Screenshot Storage Safety -**MUST run before any screenshot capture.** Prevents binary files from reaching git history. +**MUST run before any screenshot capture.** Prevents capture output from reaching git history. ```bash # Ensure directory exists mkdir -p .planning/ui-reviews -# Write .gitignore if not present -if [ ! -f .planning/ui-reviews/.gitignore ]; then - cat > .planning/ui-reviews/.gitignore << 'GITIGNORE' -# Screenshot files — never commit binary assets -*.png -*.webp -*.jpg -*.jpeg -*.gif -*.bmp -*.tiff -GITIGNORE - echo "Created .planning/ui-reviews/.gitignore" -fi +# Append any pattern the file lacks — an older .gitignore is still covered; never rewritten. +[ -f .planning/ui-reviews/.gitignore ] \ + || { printf '# UI-audit captures — never commit\n' > .planning/ui-reviews/.gitignore; echo "Created .planning/ui-reviews/.gitignore"; } +for p in '*.png' '*.webp' '*.jpg' '*.jpeg' '*.gif' '*.bmp' '*.tiff' 'interaction/'; do + grep -qxF -- "$p" .planning/ui-reviews/.gitignore || printf '%s\n' "$p" >> .planning/ui-reviews/.gitignore +done ``` -This gate runs unconditionally on every audit. The .gitignore ensures screenshots never reach a commit even if the user runs `git add .` before cleanup. +It keeps capture output out of a commit even after `git add .`: static screenshots by extension, and the `interaction/` directory as a whole (its snapshot carries form values; its console output can carry tokens); a directory pattern covers the next artifact type by construction. @@ -141,6 +133,149 @@ If dev server not detected: audit runs on code review only (Tailwind class audit Try port 3000 first, then 5173 (Vite default), then 8080. + + +### Interaction capture (default-off — `workflow.ui_interaction_capture`) + +The static captures show the first paint of `/` and nothing after it: `npx playwright screenshot` has no click, fill, hover, press, snapshot or console verb, yet the Experience Design pillar is scored on exactly that. When the `` block carries `interaction_capture: true` (the `workflow.ui_interaction_capture` key, read by `/gsd:ui-review`) **and** a Chrome binary resolves, the `chrome-devtools` CLI (`chrome-devtools-mcp`) adds post-interaction captures over `Bash` alone: no MCP server, no `tools:` change. Key off, or no Chrome: one status line, then the audit as before. + +```bash +# INTERACTION_CAPTURE: the block's `interaction_capture` (absent = off); SCREENSHOT_DIR/DEV_URL: above. +INTERACTION_CAPTURE="${INTERACTION_CAPTURE:-false}" +INTERACTION_STATUS="off" + +# An installed Chrome, never a download; CHROME_BIN overrides. +CHROME_BIN="${CHROME_BIN:-}" +if [ -z "$CHROME_BIN" ]; then + for _c in google-chrome google-chrome-stable chromium chromium-browser chrome; do + if command -v "$_c" >/dev/null 2>&1; then CHROME_BIN=$(command -v "$_c"); break; fi + done +fi +if [ -z "$CHROME_BIN" ] && [ -x "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then + CHROME_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" +fi +if [ -z "$CHROME_BIN" ] && [ -x "${PROGRAMFILES:-/nonexistent}/Google/Chrome/Application/chrome.exe" ]; then + CHROME_BIN="${PROGRAMFILES}/Google/Chrome/Application/chrome.exe" +fi + +# Floor, not a pin (--workspace needs 1.9.0); -y answers npx's prompt. --sessionId (hex/dashes) keys the +# daemon socket; concurrent audits need their own: BASHPID not $$ (subshells share $$) + $RANDOM. +CDT_SESSION="$(date +%s)-${BASHPID:-$$}-$RANDOM" +CDT="npx -y -p chrome-devtools-mcp@${CHROME_DEVTOOLS_MCP_VERSION:-^1.9.0} chrome-devtools --sessionId $CDT_SESSION" +# cdt [args...]: every driver call is bounded (no timeout(1) on macOS, no gsd-tools here) by a +# watchdog killing the job's process group at the ceiling (TERM, KILL 2 s later; npm forwards SIGTERM only to its +# direct child). An exec'd bash (a subshell keeps the caller's saved stdio open) polling the job's GROUP (a child +# can outlive the leader holding stdout), standing down once it is empty — never signalled: bash 3.2 may not +# interrupt `wait` for a trapped signal; Git Bash hangs on a signal to a process still starting up. No sleep, no fire. +CDT_T_START="${CHROME_DEVTOOLS_START_TIMEOUT:-180}" +CDT_T_STEP="${CHROME_DEVTOOLS_STEP_TIMEOUT:-60}" +cdt() { + local ceiling="$1" pid wd rc=0; shift + set -m; $CDT "$@" & pid=$!; set +m # -m: job = own process group + "${BASH:-bash}" -c 'n=$(($1 * 10)); while kill -0 -- "-$2" 2>/dev/null; do [ "$n" -gt 0 ] || { kill -TERM -- "-$2" 2>/dev/null; sleep 2; kill -0 -- "-$2" 2>/dev/null && kill -KILL -- "-$2" 2>/dev/null; exit 0; }; sleep 0.1 || exit 0; n=$((n - 1)); done' _ "$ceiling" "$pid" >/dev/null 2>&1 & wd=$! + wait "$pid" || rc=$? + wait "$wd" 2>/dev/null || true + return "$rc" +} + +if [ "$INTERACTION_CAPTURE" != "true" ]; then + echo "Interaction capture: off (workflow.ui_interaction_capture is false)" +elif [ -z "${SCREENSHOT_DIR:-}" ] || [ ! -d "$SCREENSHOT_DIR" ]; then + INTERACTION_STATUS="skipped (no dev server reached)" + echo "Interaction capture: skipped — static capture reached no dev server" +elif [ -z "$CHROME_BIN" ]; then + INTERACTION_STATUS="skipped (no Chrome binary resolved)" + echo "Interaction capture: skipped — no Chrome binary resolved (set CHROME_BIN)" +else + DEV_URL="${DEV_URL:-http://localhost:3000}" + INTERACTION_DIR="$SCREENSHOT_DIR/interaction" + mkdir -p "$INTERACTION_DIR" + ICAPTURED=0 + IFAILED=0 + PAGE_ID="" + # cdt_me: this shell's pid (bash 3.2 has no BASHPID). + cdt_me() { exec /bin/sh -c 'echo "$PPID"'; } + CDT_STARTED=0; CDT_SHELL=$(cdt_me) + + # ishot