From 73e0ebd247ba49e458d04d18599198e3ddc449c4 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Sun, 8 Nov 2026 19:27:52 -0500 Subject: [PATCH 1/4] fix(scripts): make a fresh --vite scaffold ready for the pipeline The /build-from-screenshot run in issue #145 had to leave the documented path on a fresh `setup-project.sh my-app --vite` scaffold. PR #146 already made the app its own pnpm root and pinned the coverage provider (items 1 and 2). This takes the rest of the scaffold's share: - Playwright (item 4): the shared config derives baseURL and webServer.url from one PORT constant (E2E_PORT, falling back to 3000), and the scaffold sets that fallback per framework (Vite, Vue and SvelteKit 5173, Astro 4321). `@playwright/test` is installed with the other tooling; the browsers stay a one-time `pnpm exec playwright install chromium`, printed at the end. - Storybook (item 3): the shared, Vue and SvelteKit .storybook templates move to Storybook 10, which supports the scaffold's Vite 8 since 10.3: docs and a11y addons only (essentials and interactions are in core since 9), Preview typed from the framework package. generate-stories.js imports Meta and StoryObj from the framework package named in .storybook/main.ts (@storybook/react-vite when none is found) and the MDX blocks from @storybook/addon-docs/blocks. When the app's package.json lists no Storybook package it exits 0 with `status: skipped` and the install command instead of writing stories that break tsc and the build. The scaffold still copies the config and prints the install command rather than installing Storybook. - SEO (item 6): the scaffold ships public/robots.txt, which Lighthouse's SEO audit wants and no design supplies. - App root (item 1): common_app_root() in lib/common.sh is the current directory when it holds a package.json, else the repo root. check-responsive.sh installs @playwright/test there, resolves it from the app's package.json (a /tmp script cannot import it on its own), uses `pnpm exec` instead of npx, and keeps its screenshots under the Aurelius checkout's .claude/visual-qa/. mktemp --suffix is GNU-only; mktemp -d is not. Tests run the scaffold's port function on a copy of the template, check the dry-run tooling list, the Storybook 10 package names and the install hint, run the generator with and without a Storybook dependency, and run check-responsive.sh with a fake pnpm from a sibling app directory. Refs #145 Co-Authored-By: Claude Fable 5.1 --- scripts/README.md | 7 ++ scripts/__tests__/check-responsive.test.js | 64 +++++++++++- scripts/__tests__/generate-stories.test.js | 112 ++++++++++++++++++++- scripts/__tests__/setup-project.test.js | 79 ++++++++++++++- scripts/check-responsive.sh | 51 ++++++---- scripts/generate-stories.js | 69 ++++++++++++- scripts/lib/common.sh | 13 +++ scripts/setup-project.sh | 28 ++++++ templates/README.md | 6 +- templates/shared/.storybook/main.ts | 9 +- templates/shared/.storybook/preview.ts | 2 +- templates/shared/playwright.config.ts | 9 +- templates/sveltekit/.storybook/main.ts | 9 +- templates/sveltekit/.storybook/preview.ts | 2 +- templates/vite/public/robots.txt | 2 + templates/vue/.storybook/main.ts | 9 +- templates/vue/.storybook/preview.ts | 2 +- 17 files changed, 421 insertions(+), 52 deletions(-) create mode 100644 templates/vite/public/robots.txt diff --git a/scripts/README.md b/scripts/README.md index 3958dac..52ad718 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -90,10 +90,16 @@ All scripts live in `scripts/` and are designed to run from the project root. - `--output-dir ` -- Save diff images (batch mode) - `--json` -- Output results as JSON - `--region-grid ` -- Grid size for region analysis (default: 4, meaning 4x4) + - `--crop ` -- Compare only this region of the actual capture, for a reference that covers part of the page (a screenshot that starts below the top, or one narrower than the viewport); the result carries `crop` with the capture's full size - `--antialiasing` -- Enable anti-aliasing detection - **Exit codes**: 0 = pass, 1 = fail (above threshold), 2 = error - **Config**: Reads defaults from `.claude/pipeline.config.json` +### Responsive Screenshots (`check-responsive.sh`) +- **Purpose**: Capture a URL at every `visualDiff.breakpoints` width with Playwright (Phase 8.5 of the build pipelines) +- **Usage**: `./scripts/check-responsive.sh [url] [output-dir]`; from an app scaffolded beside the framework, `cd my-app && ../scripts/check-responsive.sh http://localhost:5173` +- **Where things go**: `@playwright/test` and its Chromium are installed in the directory the script runs from when that holds a `package.json`, otherwise in the Aurelius checkout; the screenshots go to `.claude/visual-qa/screenshots/responsive/` in the Aurelius checkout either way + ## Build Performance & Caching ### Incremental Build (`incremental-build.sh`) @@ -159,6 +165,7 @@ All scripts live in `scripts/` and are designed to run from the project root. - **Usage**: `./scripts/setup-project.sh my-app --next` or `--vite` - **What it does**: Copies template configs, installs dependencies, sets up testing - **`--vite` / `--react`**: The app becomes its own pnpm root, so the repo's lockfile stays untouched. ESLint replaces create-vite's oxlint, and a starter test ships so that `lint-and-format.sh`, `check-types.sh`, `run-tests.sh` and `check-accessibility.sh` pass on a fresh scaffold +- **Also for `--vite` / `--react`**: installs `@playwright/test` and points `playwright.config.ts` at Vite's port 5173 (`E2E_PORT` overrides it for one run; the browsers are a one-time `pnpm exec playwright install chromium`); ships `public/robots.txt` for the Lighthouse SEO floor; copies a Storybook 10 config to `.storybook/` without installing Storybook, prints the install command, and `generate-stories.sh` skips by design until it is installed ## Claude Code Configuration diff --git a/scripts/__tests__/check-responsive.test.js b/scripts/__tests__/check-responsive.test.js index 6ac9396..9bab80c 100644 --- a/scripts/__tests__/check-responsive.test.js +++ b/scripts/__tests__/check-responsive.test.js @@ -1,18 +1,24 @@ import { describe, it, expect } from "vitest"; import { execFileSync } from "child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SCRIPT = join(__dirname, "..", "check-responsive.sh"); +const REPO_ROOT = join(__dirname, "..", ".."); -function run(args = []) { +function run(args = [], { cwd, bin } = {}) { + const env = bin ? { ...process.env, PATH: `${bin}:${process.env.PATH}` } : process.env; try { const stdout = execFileSync("bash", [SCRIPT, ...args], { encoding: "utf-8", timeout: 15000, + cwd, + env, }); - return { stdout, exitCode: 0 }; + return { stdout, stderr: "", exitCode: 0 }; } catch (err) { return { stdout: err.stdout || "", @@ -32,3 +38,57 @@ describe("check-responsive.sh — help flag", () => { expect(result.stdout).toContain("breakpoints"); }); }); + +/** + * A pnpm stand-in: records the directory each call runs in and reports + * Playwright as not installed, so the script takes its install path. + */ +function fakePnpm(dir, log) { + const bin = join(dir, "bin"); + mkdirSync(bin, { recursive: true }); + writeFileSync( + join(bin, "pnpm"), + `#!/usr/bin/env bash +printf '%s :: %s\\n' "$PWD" "$*" >> "${log}" +case "$*" in + "exec playwright --version") exit 1 ;; +esac +exit 0 +`, + { mode: 0o755 }, + ); + return bin; +} + +/** + * An app scaffolded beside the framework (`./scripts/setup-project.sh my-app`) + * is its own pnpm root. Run from that directory, the script must install + * Playwright there, not in the Aurelius checkout (issue #145). + */ +describe("check-responsive.sh — Playwright goes where the app is", () => { + function scenario(withPackageJson) { + const dir = mkdtempSync(join(tmpdir(), "aurelius-responsive-")); + const app = join(dir, "my-app"); + mkdirSync(app); + if (withPackageJson) writeFileSync(join(app, "package.json"), '{ "name": "my-app" }\n'); + const log = join(dir, "pnpm.log"); + writeFileSync(log, ""); + const bin = fakePnpm(dir, log); + const result = run(["http://localhost:5173", join(dir, "out")], { cwd: app, bin }); + const calls = readFileSync(log, "utf-8").trim().split("\n").filter(Boolean); + rmSync(dir, { recursive: true, force: true }); + return { app, calls, result }; + } + + it("installs @playwright/test and its browser in the directory it was run from when that is an app", () => { + const { app, calls } = scenario(true); + expect(calls).toContain(`${app} :: add -D @playwright/test`); + expect(calls).toContain(`${app} :: exec playwright install chromium`); + expect(calls.some((c) => c.startsWith(`${REPO_ROOT} ::`))).toBe(false); + }); + + it("falls back to the Aurelius root when the current directory has no package.json", () => { + const { calls } = scenario(false); + expect(calls).toContain(`${REPO_ROOT} :: add -D @playwright/test`); + }); +}); diff --git a/scripts/__tests__/generate-stories.test.js b/scripts/__tests__/generate-stories.test.js index a6a1ed8..7077151 100644 --- a/scripts/__tests__/generate-stories.test.js +++ b/scripts/__tests__/generate-stories.test.js @@ -546,10 +546,11 @@ export function Alert({ message, severity }: AlertProps) { const content = readFileSync(mdxFile, "utf-8"); // MDX should contain standard Storybook doc blocks - expect(content).toContain("import { Meta"); - expect(content).toContain("import { Canvas"); - expect(content).toContain("import { Controls"); - expect(content).toContain("import { ArgTypes"); + for (const block of ["Meta", "Canvas", "Controls", "ArgTypes"]) { + expect(content).toMatch( + new RegExp(`import \\{[^}]*\\b${block}\\b[^}]*\\} from '@storybook/addon-docs/blocks'`), + ); + } expect(content).toContain("{label}; +} +`, + ); +} + +describe("generate-stories.js — Storybook 10 imports", () => { + it("imports Meta and StoryObj from the framework package named in .storybook/main.ts", () => { + const dir = createTmpDir(); + writeComponent(dir); + mkdirSync(join(dir, ".storybook")); + writeFileSync( + join(dir, ".storybook", "main.ts"), + `import type { StorybookConfig } from "@storybook/nextjs-vite"; + +const config: StorybookConfig = { + stories: ["../src/**/*.stories.@(ts|tsx)"], + framework: { name: "@storybook/nextjs-vite", options: {} }, +}; + +export default config; +`, + ); + const result = run(dir); + expect(result.exitCode).toBe(0); + const story = readFileSync(join(dir, "src", "components", "Badge.stories.tsx"), "utf-8"); + expect(story).toContain("import type { Meta, StoryObj } from '@storybook/nextjs-vite';"); + }); + + it("defaults to @storybook/react-vite and takes the doc blocks from @storybook/addon-docs", () => { + const dir = createTmpDir(); + writeComponent(dir); + const result = run(dir); + expect(result.exitCode).toBe(0); + const story = readFileSync(join(dir, "src", "components", "Badge.stories.tsx"), "utf-8"); + expect(story).toContain("import type { Meta, StoryObj } from '@storybook/react-vite';"); + const mdx = readFileSync(join(dir, "src", "components", "Badge.mdx"), "utf-8"); + expect(mdx).toContain("from '@storybook/addon-docs/blocks'"); + expect(mdx).not.toContain("@storybook/blocks"); + }); +}); + +describe("generate-stories.js — skipped by design when Storybook is not installed", () => { + const withoutStorybook = (dir) => + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "my-app", devDependencies: { vite: "^8.0.0", vitest: "^4.1.12" } }), + ); + + it("writes no story and says so in JSON", () => { + const dir = createTmpDir(); + writeComponent(dir); + withoutStorybook(dir); + const result = run(dir, ["--json"]); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + generated: 0, + status: "skipped", + reason: "storybook-not-installed", + }); + expect(existsSync(join(dir, "src", "components", "Badge.stories.tsx"))).toBe(false); + }); + + it("names the install command in the human-readable output", () => { + const dir = createTmpDir(); + writeComponent(dir); + withoutStorybook(dir); + const result = run(dir); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/skipped/i); + expect(result.stdout).toContain("pnpm add -D storybook"); + }); + + it("generates as usual when package.json declares a Storybook package", () => { + const dir = createTmpDir(); + writeComponent(dir); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "my-app", + devDependencies: { storybook: "^10.3.0", "@storybook/react-vite": "^10.3.0" }, + }), + ); + const result = run(dir); + expect(result.exitCode).toBe(0); + expect(existsSync(join(dir, "src", "components", "Badge.stories.tsx"))).toBe(true); + }); +}); diff --git a/scripts/__tests__/setup-project.test.js b/scripts/__tests__/setup-project.test.js index 6237d52..d5d4612 100644 --- a/scripts/__tests__/setup-project.test.js +++ b/scripts/__tests__/setup-project.test.js @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { execFileSync } from "child_process"; -import { readFileSync } from "fs"; +import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; @@ -135,3 +136,79 @@ describe("setup-project.sh", () => { expect(r.stdout).toMatch(/template:\s+templates\/vite/); }); }); + +/** + * Follow-through for a `/build-from-screenshot` run on a fresh --vite scaffold + * (issue #145): the Playwright config must point at the app's own dev server + * and `@playwright/test` must be there to run it; the Storybook config must + * name packages that exist for the scaffold's Vite; and the quality gate's SEO + * floor needs a robots.txt the design cannot supply. + */ +describe("setup-project.sh — E2E, Storybook and SEO follow-through (issue #145)", () => { + const script = readFileSync(SCRIPT, "utf-8"); + const tooling = () => { + const r = run(["my-app", "--vite", "--dry-run"]); + expect(r.exitCode).toBe(0); + return r.stdout + .match(/tooling:\s+(.*)/)[1] + .split(" ") + .map((dep) => dep.replace(/(?<=.)@.*$/, "")); + }; + + it("installs @playwright/test so the scaffolded Playwright config can run", () => { + expect(tooling()).toContain("@playwright/test"); + }); + + it("derives every Playwright URL from one port that the scaffold sets per framework", () => { + const config = readFileSync( + join(PROJECT_ROOT, "templates/shared/playwright.config.ts"), + "utf-8", + ); + expect(config).toMatch(/const PORT = Number\(process\.env\.E2E_PORT\) \|\| 3000;/); + expect(config).not.toMatch(/localhost:3000/); + expect(config.match(/\$\{PORT\}/g).length).toBeGreaterThanOrEqual(2); + }); + + it("points the Vite app's Playwright config at port 5173", () => { + // Run the script's own function on a copy of the template. + const fn = script.match(/^set_dev_server_port\(\) \{[\s\S]*?^\}/m)?.[0]; + expect(fn).toBeTruthy(); + const dir = mkdtempSync(join(tmpdir(), "aurelius-setup-")); + copyFileSync( + join(PROJECT_ROOT, "templates/shared/playwright.config.ts"), + join(dir, "playwright.config.ts"), + ); + execFileSync("bash", ["-c", `${fn}\nset_dev_server_port 5173`], { + cwd: dir, + encoding: "utf-8", + }); + const config = readFileSync(join(dir, "playwright.config.ts"), "utf-8"); + const leftover = existsSync(join(dir, "playwright.config.ts.bak")); + rmSync(dir, { recursive: true, force: true }); + expect(config).toContain("|| 5173;"); + expect(config).not.toContain("|| 3000;"); + expect(leftover).toBe(false); + expect(script).toMatch(/vite\|react\)[\s\S]*set_dev_server_port 5173/); + }); + + it("names only Storybook packages that exist for Storybook 10, and how to install them", () => { + const main = readFileSync(join(PROJECT_ROOT, "templates/shared/.storybook/main.ts"), "utf-8"); + const addons = [...main.match(/addons: \[([\s\S]*?)\]/)[1].matchAll(/"([^"]+)"/g)].map( + (m) => m[1], + ); + expect(addons.length).toBeGreaterThan(0); + expect(addons).not.toContain("@storybook/addon-essentials"); + expect(addons).not.toContain("@storybook/addon-interactions"); + expect(main).toContain('"@storybook/react-vite"'); + for (const pkg of ["storybook", "@storybook/react-vite", ...addons]) { + expect(script).toContain(`${pkg}@^10`); + } + }); + + it("ships a robots.txt for the SEO floor and copies it into the Vite app", () => { + const robots = readFileSync(join(PROJECT_ROOT, "templates/vite/public/robots.txt"), "utf-8"); + expect(robots).toMatch(/^User-agent: \*$/m); + expect(robots).toMatch(/^Allow: \/$/m); + expect(script).toMatch(/vite\/public\/robots\.txt/); + }); +}); diff --git a/scripts/check-responsive.sh b/scripts/check-responsive.sh index d6f121f..aaf8688 100755 --- a/scripts/check-responsive.sh +++ b/scripts/check-responsive.sh @@ -1,28 +1,38 @@ #!/usr/bin/env bash # check-responsive.sh — Capture screenshots at all breakpoints using Playwright # Exit codes: 0=success, 1=error (Playwright not available, capture failed) +# +# Acts on the app in the current directory when that holds a package.json (an +# app scaffolded beside the framework: `cd my-app && ../scripts/check-responsive.sh`), +# otherwise on the Aurelius checkout. Playwright is installed there; the +# screenshots go under the Aurelius checkout's .claude/visual-qa/ either way. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "$SCRIPT_DIR/lib/common.sh" -cd "$(common_project_root)" +FRAMEWORK_ROOT="$(common_project_root)" +APP_ROOT="$(common_app_root)" # --- Args --- URL="${1:-http://localhost:3000}" -OUTPUT_DIR="${2:-.claude/visual-qa/screenshots/responsive}" +OUTPUT_DIR="${2:-$FRAMEWORK_ROOT/.claude/visual-qa/screenshots/responsive}" # --- Help --- if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then echo "Usage: check-responsive.sh [url] [output-dir]" echo "" echo " url URL to capture (default: http://localhost:3000)" - echo " output-dir Screenshot output directory (default: .claude/visual-qa/screenshots/responsive)" + echo " output-dir Screenshot output directory (default: .claude/visual-qa/screenshots/responsive in the Aurelius checkout)" echo "" - echo "Reads breakpoints from .claude/pipeline.config.json visualDiff.breakpoints" + echo "Reads breakpoints from .claude/pipeline.config.json visualDiff.breakpoints." + echo "Playwright is installed in the current directory when it holds a package.json" + echo "(cd my-app && ../scripts/check-responsive.sh), otherwise in the Aurelius checkout." exit 0 fi +cd "$APP_ROOT" + echo "=== Responsive Screenshot Capture ===" echo "" @@ -35,6 +45,7 @@ if [[ "$BREAKPOINTS_JSON" == "{}" ]]; then fi echo "▸ URL: $URL" +echo "▸ App: $APP_ROOT" echo "▸ Output: $OUTPUT_DIR" echo "▸ Breakpoints: $BREAKPOINTS_JSON" echo "" @@ -42,17 +53,17 @@ echo "" # --- Ensure output directory exists --- mkdir -p "$OUTPUT_DIR" -# --- Ensure Playwright is available --- -if ! npx playwright --version &>/dev/null; then - echo " ▸ Installing @playwright/test..." +# --- Ensure Playwright is available in the app --- +if ! pnpm exec playwright --version >/dev/null 2>&1; then + echo " ▸ Installing @playwright/test in $APP_ROOT..." pnpm add -D @playwright/test 2>/dev/null || { - echo " ✗ Failed to install @playwright/test. Run 'pnpm add -D @playwright/test' manually." + echo " ✗ Failed to install @playwright/test. Run 'pnpm add -D @playwright/test' in $APP_ROOT." exit 1 } echo " ✓ @playwright/test installed" echo " ▸ Installing Playwright browsers..." - npx playwright install chromium 2>/dev/null || { - echo " ✗ Failed to install Playwright browsers. Run 'npx playwright install chromium' manually." + pnpm exec playwright install chromium 2>/dev/null || { + echo " ✗ Failed to install Playwright browsers. Run 'pnpm exec playwright install chromium' in $APP_ROOT." exit 1 } echo " ✓ Chromium browser installed" @@ -60,17 +71,21 @@ if ! npx playwright --version &>/dev/null; then fi # --- Generate temporary Playwright script --- -TEMP_SCRIPT=$(mktemp --suffix=.mjs) -common_track_tmpfile "$TEMP_SCRIPT" +# ESM needs the .mjs name, and `mktemp -d` is the portable way to get a fresh path for it. +TEMP_DIR=$(mktemp -d) +common_track_tmpfile "$TEMP_DIR" +TEMP_SCRIPT="$TEMP_DIR/capture.mjs" cat > "$TEMP_SCRIPT" <<'PLAYWRIGHT_EOF' -import { chromium } from 'playwright'; +import { createRequire } from 'module'; import { mkdirSync } from 'fs'; import { join } from 'path'; -const url = process.argv[2]; -const outputDir = process.argv[3]; -const breakpoints = JSON.parse(process.argv[4]); +const [url, outputDir, breakpointsJson, appRoot] = process.argv.slice(2); +// Resolve @playwright/test from the app, not from this script's temp directory. +const require = createRequire(join(appRoot, 'package.json')); +const { chromium } = require('@playwright/test'); +const breakpoints = JSON.parse(breakpointsJson); mkdirSync(outputDir, { recursive: true }); @@ -98,10 +113,10 @@ PLAYWRIGHT_EOF echo "▸ Capturing screenshots..." echo "" -node "$TEMP_SCRIPT" "$URL" "$OUTPUT_DIR" "$BREAKPOINTS_JSON" || { +node "$TEMP_SCRIPT" "$URL" "$OUTPUT_DIR" "$BREAKPOINTS_JSON" "$APP_ROOT" || { echo "" echo " ✗ Screenshot capture failed" - echo " Make sure the target URL is accessible and Playwright browsers are installed" + echo " Make sure the target URL is accessible and Playwright browsers are installed in $APP_ROOT" exit 1 } diff --git a/scripts/generate-stories.js b/scripts/generate-stories.js index 50b2fbd..a9c1c62 100644 --- a/scripts/generate-stories.js +++ b/scripts/generate-stories.js @@ -95,6 +95,47 @@ function loadConfig(configFilePath) { } } +// --------------------------------------------------------------------------- +// Storybook in the project +// --------------------------------------------------------------------------- + +const STORYBOOK_INSTALL = + "pnpm add -D storybook@^10 @storybook/react-vite@^10 @storybook/addon-docs@^10 @storybook/addon-a11y@^10"; + +/** + * Whether package.json declares a Storybook package: true, false, or null when + * there is no package.json to tell (the generator then proceeds). + */ +function storybookInstalled(cwd) { + const pkgPath = join(cwd, "package.json"); + if (!existsSync(pkgPath)) return null; + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }); + return deps.some((name) => name === "storybook" || name.startsWith("@storybook/")); + } catch { + return null; + } +} + +/** + * The Storybook framework package the project uses (`@storybook/react-vite`, + * `@storybook/nextjs-vite`, …), read from .storybook/main.*. Since Storybook 9, + * stories import their types from the framework package, so a story must name + * the one that is installed. Defaults to react-vite, the Vite template's. + */ +function storybookFramework(cwd) { + for (const name of ["main.ts", "main.mts", "main.js", "main.mjs", "main.cjs"]) { + const file = join(cwd, ".storybook", name); + if (!existsSync(file)) continue; + const match = readFileSync(file, "utf-8").match( + /framework:\s*(?:\{\s*name:\s*)?["'](@storybook\/[^"']+)["']/, + ); + if (match) return match[1]; + } + return "@storybook/react-vite"; +} + // --------------------------------------------------------------------------- // File scanning — recursively find .tsx files // --------------------------------------------------------------------------- @@ -458,7 +499,7 @@ function generateStoryContent( const breakpoints = (config.visualDiff && config.visualDiff.breakpoints) || {}; // Imports - lines.push("import type { Meta, StoryObj } from '@storybook/react';"); + lines.push(`import type { Meta, StoryObj } from '${config.storybookFramework}';`); if (isDefault) { lines.push(`import ${componentName} from './${componentBasename}';`); } else { @@ -590,10 +631,7 @@ function generateStoryContent( function generateMdxContent(componentName, componentBasename, description, variantNames) { const lines = []; - lines.push("import { Meta } from '@storybook/blocks';"); - lines.push("import { Canvas } from '@storybook/blocks';"); - lines.push("import { Controls } from '@storybook/blocks';"); - lines.push("import { ArgTypes } from '@storybook/blocks';"); + lines.push("import { Meta, Canvas, Controls, ArgTypes } from '@storybook/addon-docs/blocks';"); lines.push(`import * as Stories from './${componentBasename}.stories';`); lines.push(""); lines.push(""); @@ -641,8 +679,29 @@ function main() { process.exit(0); } + // Stories only compile when Storybook is installed (issue #145): a project + // whose package.json lists no Storybook package gets a skip, not stories that + // break tsc and the build. + if (storybookInstalled(cwd) === false) { + log("⊘ Storybook is not installed in this project; story generation skipped by design."); + log(` Install it (config in .storybook/): ${STORYBOOK_INSTALL}`); + if (jsonOutput) { + console.log( + JSON.stringify({ + generated: 0, + skipped: 0, + files: [], + status: "skipped", + reason: "storybook-not-installed", + }), + ); + } + process.exit(0); + } + // Load config const config = loadConfig(fullConfigPath); + config.storybookFramework = storybookFramework(cwd); const skipPatterns = config.storybook.skipPatterns || []; const generateMdxFlag = !noMdx && config.storybook.generateMdx !== false; diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index d50d04e..84a43e0 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -27,6 +27,19 @@ common_project_root() { (cd "$lib_dir/../.." && pwd) } +# The directory a script acts on. An app scaffolded beside the framework +# (`./scripts/setup-project.sh my-app`) is its own pnpm root and is worked on +# as `cd my-app && ../scripts/.sh`, so the current directory wins when it +# holds a package.json; otherwise the repository root. pipeline.config.json and +# the .claude/ artifacts stay at common_project_root either way. +common_app_root() { + if [[ -f "$PWD/package.json" ]]; then + pwd + else + common_project_root + fi +} + # --- Output helpers ------------------------------------------------------- # # These mirror the prefixes already in use. Keep them ASCII-safe-prefixed: diff --git a/scripts/setup-project.sh b/scripts/setup-project.sh index 2935dc9..d588657 100755 --- a/scripts/setup-project.sh +++ b/scripts/setup-project.sh @@ -186,6 +186,7 @@ case "$FRAMEWORK" in typescript@~6.0.2 vitest@^4.1.12 @vitest/coverage-v8@^4.1.12 @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom + @playwright/test ) ;; vue) @@ -222,6 +223,16 @@ create_vite_react_app() { cp "$TEMPLATES_DIR/vite/pnpm-workspace.yaml" "$PROJECT_NAME/pnpm-workspace.yaml" } +# The shared Playwright config reads the dev server port from E2E_PORT and +# falls back to Next.js's 3000. Point that fallback at the framework's port, so +# `pnpm exec playwright test` starts and reaches this app's own dev server. +set_dev_server_port() { + if [[ -f playwright.config.ts ]]; then + sed -i.bak "s/process.env.E2E_PORT) || 3000;/process.env.E2E_PORT) || $1;/" playwright.config.ts \ + && rm -f playwright.config.ts.bak + fi +} + # Create the project case "$FRAMEWORK" in next) @@ -365,8 +376,19 @@ case "$FRAMEWORK" in [[ -f src/test/setup.ts ]] || cp "$TEMPLATES_DIR/vite/src/test/setup.ts" src/test/setup.ts [[ -f src/App.test.tsx ]] || cp "$TEMPLATES_DIR/vite/src/App.test.tsx" src/App.test.tsx grep -qx 'coverage' .gitignore 2>/dev/null || echo 'coverage' >> .gitignore + # Vite serves on 5173; the shared Playwright config assumes Next.js's 3000 + set_dev_server_port 5173 + # Lighthouse's SEO audits want a robots.txt, which no design supplies + mkdir -p public + [[ -f public/robots.txt ]] || cp "$TEMPLATES_DIR/vite/public/robots.txt" public/robots.txt pnpm prettier --write . > /dev/null ;; + vue|svelte) + set_dev_server_port 5173 + ;; + astro) + set_dev_server_port 4321 + ;; esac echo "" @@ -393,6 +415,12 @@ case "$FRAMEWORK" in echo "Tailwind CSS:" echo " Add '@import \"tailwindcss\";' to your main CSS file." echo " Add the @tailwindcss/vite plugin to vite.config.ts." + echo "" + echo "E2E (playwright.config.ts points at the dev server on 5173):" + echo " pnpm exec playwright install chromium # one-time browser download" + echo "" + echo "Storybook (config in .storybook/, not installed by default):" + echo " pnpm add -D storybook@^10 @storybook/react-vite@^10 @storybook/addon-docs@^10 @storybook/addon-a11y@^10" ;; vue) echo " pnpm dev # Start development server (port 5173)" diff --git a/templates/README.md b/templates/README.md index daaa2f7..2745805 100644 --- a/templates/README.md +++ b/templates/README.md @@ -30,7 +30,8 @@ These configs apply to any React project regardless of framework: | `tailwind.config.ts` | Base Tailwind config with design token structure | | `tsconfig.json` | TypeScript strict mode with path aliases | | `vitest.config.template.ts.tpl` | Vitest config with jsdom, RTL setup, and coverage thresholds | -| `playwright.config.ts` | Shared cross-browser Playwright config (chromium, firefox, webkit, mobile) | +| `playwright.config.ts` | Shared cross-browser Playwright config (chromium, firefox, webkit, mobile); the dev server port comes from `E2E_PORT`, with a fallback `setup-project.sh` sets per framework (Vite 5173, Astro 4321, Next.js 3000) | +| `.storybook/` | Storybook 10 config for `@storybook/react-vite` with the docs and a11y addons; the scaffold copies it but does not install Storybook | | `css/cross-browser-reset.css` | Cross-browser CSS normalization (fonts, scrollbars, forms, focus) | ### Usage @@ -66,6 +67,7 @@ Copies shared templates plus Next.js-specific config. | File | Purpose | |------|---------| | `vite.config.ts.tpl` | Vite config with React plugin, path aliases, and build optimizations | +| `public/robots.txt` | Allows indexing; copied into the app because the Lighthouse SEO floor wants one and no design supplies it | ### Usage @@ -73,7 +75,7 @@ Copies shared templates plus Next.js-specific config. ./scripts/setup-project.sh my-app --vite ``` -Copies shared templates plus Vite-specific config, then swaps create-vite's oxlint for the ESLint setup in `shared/`. The app gets its own pnpm root (`pnpm-workspace.yaml`), `src/test/setup.ts` and a starter test, `src/App.test.tsx`, so the quality checks pass on a fresh scaffold. +Copies shared templates plus Vite-specific config, then swaps create-vite's oxlint for the ESLint setup in `shared/`. The app gets its own pnpm root (`pnpm-workspace.yaml`), `src/test/setup.ts` and a starter test, `src/App.test.tsx`, so the quality checks pass on a fresh scaffold. It also installs `@playwright/test` with `playwright.config.ts` pointed at Vite's port 5173, ships `public/robots.txt`, and copies the Storybook 10 config to `.storybook/` without installing Storybook (the install command is printed at the end). ## Astro Templates (`astro/`) diff --git a/templates/shared/.storybook/main.ts b/templates/shared/.storybook/main.ts index f36872c..b6cdff5 100644 --- a/templates/shared/.storybook/main.ts +++ b/templates/shared/.storybook/main.ts @@ -1,12 +1,11 @@ import type { StorybookConfig } from "@storybook/react-vite"; +// Storybook 10 (Vite 5 to 8). Controls, actions, viewport and interactions ship +// in Storybook's core since version 9; docs and a11y remain separate addons. +// Install: pnpm add -D storybook@^10 @storybook/react-vite@^10 @storybook/addon-docs@^10 @storybook/addon-a11y@^10 const config: StorybookConfig = { stories: ["../src/**/*.mdx", "../src/**/*.stories.@(ts|tsx)"], - addons: [ - "@storybook/addon-essentials", - "@storybook/addon-a11y", - "@storybook/addon-interactions", - ], + addons: ["@storybook/addon-docs", "@storybook/addon-a11y"], framework: { name: "@storybook/react-vite", options: {}, diff --git a/templates/shared/.storybook/preview.ts b/templates/shared/.storybook/preview.ts index b70ac93..f52f810 100644 --- a/templates/shared/.storybook/preview.ts +++ b/templates/shared/.storybook/preview.ts @@ -1,4 +1,4 @@ -import type { Preview } from "@storybook/react"; +import type { Preview } from "@storybook/react-vite"; import "../src/index.css"; const preview: Preview = { diff --git a/templates/shared/playwright.config.ts b/templates/shared/playwright.config.ts index 754db8e..7074527 100644 --- a/templates/shared/playwright.config.ts +++ b/templates/shared/playwright.config.ts @@ -17,6 +17,11 @@ import { defineConfig, devices } from "@playwright/test"; const CI = !!process.env.CI; +// The dev server the tests run against. setup-project.sh sets the fallback to +// the scaffolded framework's port (Vite and SvelteKit 5173, Astro 4321, +// Next.js 3000); E2E_PORT overrides it for one run. +const PORT = Number(process.env.E2E_PORT) || 3000; + export default defineConfig({ testDir: "./e2e", testMatch: "**/*.e2e.ts", @@ -28,7 +33,7 @@ export default defineConfig({ timeout: 30_000, use: { - baseURL: "http://localhost:3000", + baseURL: `http://localhost:${PORT}`, trace: "on-first-retry", screenshot: "only-on-failure", video: "on-first-retry", @@ -36,7 +41,7 @@ export default defineConfig({ webServer: { command: "pnpm dev", - url: "http://localhost:3000", + url: `http://localhost:${PORT}`, reuseExistingServer: !CI, timeout: 30_000, }, diff --git a/templates/sveltekit/.storybook/main.ts b/templates/sveltekit/.storybook/main.ts index 7039cb9..89e89c4 100644 --- a/templates/sveltekit/.storybook/main.ts +++ b/templates/sveltekit/.storybook/main.ts @@ -1,12 +1,11 @@ import type { StorybookConfig } from "@storybook/sveltekit"; +// Storybook 10: controls, actions, viewport and interactions ship in core; docs +// and a11y remain separate addons. +// Install: pnpm add -D storybook@^10 @storybook/sveltekit@^10 @storybook/addon-docs@^10 @storybook/addon-a11y@^10 const config: StorybookConfig = { stories: ["../src/**/*.mdx", "../src/**/*.stories.@(ts|svelte)"], - addons: [ - "@storybook/addon-essentials", - "@storybook/addon-a11y", - "@storybook/addon-interactions", - ], + addons: ["@storybook/addon-docs", "@storybook/addon-a11y"], framework: { name: "@storybook/sveltekit", options: {}, diff --git a/templates/sveltekit/.storybook/preview.ts b/templates/sveltekit/.storybook/preview.ts index 55610cc..1627a92 100644 --- a/templates/sveltekit/.storybook/preview.ts +++ b/templates/sveltekit/.storybook/preview.ts @@ -1,4 +1,4 @@ -import type { Preview } from "@storybook/svelte"; +import type { Preview } from "@storybook/sveltekit"; import "../src/app.css"; const preview: Preview = { diff --git a/templates/vite/public/robots.txt b/templates/vite/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/templates/vite/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/templates/vue/.storybook/main.ts b/templates/vue/.storybook/main.ts index 10babaf..528d09b 100644 --- a/templates/vue/.storybook/main.ts +++ b/templates/vue/.storybook/main.ts @@ -1,12 +1,11 @@ import type { StorybookConfig } from "@storybook/vue3-vite"; +// Storybook 10: controls, actions, viewport and interactions ship in core; docs +// and a11y remain separate addons. +// Install: pnpm add -D storybook@^10 @storybook/vue3-vite@^10 @storybook/addon-docs@^10 @storybook/addon-a11y@^10 const config: StorybookConfig = { stories: ["../src/**/*.mdx", "../src/**/*.stories.@(ts|vue)"], - addons: [ - "@storybook/addon-essentials", - "@storybook/addon-a11y", - "@storybook/addon-interactions", - ], + addons: ["@storybook/addon-docs", "@storybook/addon-a11y"], framework: { name: "@storybook/vue3-vite", options: {}, diff --git a/templates/vue/.storybook/preview.ts b/templates/vue/.storybook/preview.ts index 938c9d1..07e2403 100644 --- a/templates/vue/.storybook/preview.ts +++ b/templates/vue/.storybook/preview.ts @@ -1,4 +1,4 @@ -import type { Preview } from "@storybook/vue3"; +import type { Preview } from "@storybook/vue3-vite"; import "../src/assets/main.css"; const preview: Preview = { From 802b92af28f949d44a5cdf4cd8c183ca02d2c76b Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Sun, 8 Nov 2026 19:27:56 -0500 Subject: [PATCH 2/4] feat(visual-qa): crop and render width for partial source screenshots The screenshot in issue #145 was a crop starting about 180 px below the top of the page, 1265 px wide (a 1280 px window minus a scrollbar), so the component-lanes visual stage could only fail by construction and the run had to redo the bounded loop by hand with a cropped capture (item 5). visual-diff.js takes `--crop x,y,w,h`, compares only that region of the actual capture and reports `crop` with the capture's full size; a box that reaches outside the capture is a usage error (exit 2). A page reference in the component-lanes workflow may now be `{ path, renderWidth, crop }` as well as a path: the visual check captures at renderWidth and passes --crop, so a partial screenshot is diffed inside the workflow. Plain path references work as before. Refs #145 Co-Authored-By: Claude Fable 5.1 --- .claude/workflows/component-lanes.js | 21 ++++++- CLAUDE.md | 2 +- scripts/__tests__/component-lanes.test.js | 41 ++++++++++++++ scripts/__tests__/visual-diff.test.js | 68 +++++++++++++++++++++++ scripts/visual-diff.js | 55 +++++++++++++++++- 5 files changed, 182 insertions(+), 5 deletions(-) diff --git a/.claude/workflows/component-lanes.js b/.claude/workflows/component-lanes.js index b6b8d35..e071519 100644 --- a/.claude/workflows/component-lanes.js +++ b/.claude/workflows/component-lanes.js @@ -29,6 +29,9 @@ // buildSpec: '.claude/plans/build-spec.json', // components: [{ name, tier, dir, testFile, testSha256 }], // tier: ui 0, layout 1, sections 2, pages 3 // pages: [{ name, url, references: { '1440': '.claude/visual-qa/screenshots/figma/home-1440.png' } }], +// a reference may also be { path, renderWidth, crop: { x, y, width, height } }: the page is +// captured at renderWidth and only that region of the capture is diffed, for a source +// screenshot taken at a non-standard width or covering part of the page (issue #145) // maxBuildAttempts: 3, // maxVisualIterations: 5, // pipeline.config.json → iterationLoop // diffPassThreshold: 0.02, // fraction of pixels, as visual-diff.js reports @@ -148,12 +151,26 @@ function checkPrompt(c) { ].join('\n') } +// A reference is a path, or { path, renderWidth, crop } for a source screenshot +// taken at a non-standard width or covering only part of the page. +function reference(p, width) { + const r = p.references[width] + return typeof r === 'string' ? { path: r } : r || {} +} + function diagnosePrompt(p, round) { const widths = Object.keys(p.references || {}) + const steps = widths.map((w) => { + const r = reference(p, w) + const viewport = r.renderWidth || w + const file = `.claude/visual-qa/screenshots/chromium/${p.name}-${w}.png` + const crop = r.crop ? ` --crop ${r.crop.x || 0},${r.crop.y || 0},${r.crop.width},${r.crop.height}` : '' + const note = r.crop ? ' The reference covers only that region of the page, so the crop is part of the comparison.' : '' + return `- ${w}: capture ${p.url} at viewport width ${viewport} (full page) to ${file}, with Playwright (npx playwright screenshot --viewport-size=${viewport},900 --full-page ${p.url} ${file}) or the Chrome DevTools MCP, then run node scripts/visual-diff.js ${file} ${r.path} --threshold ${threshold} --json${crop}.${note}` + }) return [ `Visual check of the ${p.name} page, round ${round} of ${maxVisualIterations}. Diagnose only: do not edit any source file in this lane.`, - `For each width in ${widths.join(', ')}: capture ${p.url} at that viewport width (full page) to .claude/visual-qa/screenshots/chromium/${p.name}-.png, using Playwright (npx playwright screenshot --viewport-size=,900 --full-page ${p.url} ) or the Chrome DevTools MCP.`, - `Compare each capture with its reference: ${widths.map((w) => `${w} → ${p.references[w]}`).join('; ')}. Use node scripts/visual-diff.js --threshold ${threshold} --json and report mismatchPct as the fraction it prints.`, + `For each width, capture and compare as follows, and report mismatchPct as the fraction visual-diff.js prints:\n${steps.join('\n')}`, `For every width above ${pct(threshold)}, read the diff regions and name the component whose code causes each difference, with a precise fix that uses a lockfile token from ${lockfile}. Known components: ${components.map((c) => c.name).join(', ')}.`, ].join('\n\n') } diff --git a/CLAUDE.md b/CLAUDE.md index 6eacb04..9821084 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ node scripts/claude-code-conformance.js [--json] [--strict] # .claude/ against ./scripts/validate-pipeline-config.sh [--json] # pipeline.config.json against its schema # Visual QA -node scripts/visual-diff.js [--threshold 0.02] [--json] +node scripts/visual-diff.js [--threshold 0.02] [--json] [--crop x,y,w,h] node scripts/visual-diff.js --batch [--output-dir diffs/] ./scripts/check-dark-mode.sh # dark-theme capture and diff ./scripts/check-responsive.sh [url] [output-dir] # 5 breakpoints diff --git a/scripts/__tests__/component-lanes.test.js b/scripts/__tests__/component-lanes.test.js index dfaf3e1..cc64230 100644 --- a/scripts/__tests__/component-lanes.test.js +++ b/scripts/__tests__/component-lanes.test.js @@ -242,3 +242,44 @@ describe("component-lanes workflow", () => { await expect(run({ components: [] }, happy())).rejects.toThrow(/args\.components/); }); }); + +/** + * A reference may be a plain path or `{ path, renderWidth, crop }` (issue #145): + * a screenshot taken at a width that is no standard breakpoint, or one that + * covers only part of the page, is diffed by capturing at `renderWidth` and + * cropping the capture to `crop` before visual-diff.js compares the two. + */ +describe("component-lanes workflow — references with a render width and a crop box", () => { + it("tells the visual check to capture at the render width and crop the capture before diffing", async () => { + const pages = [ + { + name: "home", + url: "http://localhost:5173/", + references: { + 1280: { + path: "ref/home-1280.png", + renderWidth: 1265, + crop: { x: 0, y: 180, width: 1265, height: 2400 }, + }, + }, + }, + ]; + const { result, calls } = await run({ components: [comp("Hero", 2)], pages }, happy()); + const diag = calls.find((c) => c.label === "diff:home:r1"); + expect(diag.prompt).toContain("--viewport-size=1265,"); + expect(diag.prompt).toContain("--crop 0,180,1265,2400"); + expect(diag.prompt).toContain("ref/home-1280.png"); + expect(result.pages.home.status).toBe("PASS"); + }); + + it("keeps a plain path working: capture at the key's width, no crop", async () => { + const pages = [ + { name: "home", url: "http://localhost:5173/", references: { 1440: "ref/home.png" } }, + ]; + const { calls } = await run({ components: [comp("Hero", 2)], pages }, happy()); + const diag = calls.find((c) => c.label === "diff:home:r1"); + expect(diag.prompt).toContain("--viewport-size=1440,"); + expect(diag.prompt).toContain("ref/home.png"); + expect(diag.prompt).not.toContain("--crop"); + }); +}); diff --git a/scripts/__tests__/visual-diff.test.js b/scripts/__tests__/visual-diff.test.js index bdfc9ec..28a9e41 100644 --- a/scripts/__tests__/visual-diff.test.js +++ b/scripts/__tests__/visual-diff.test.js @@ -152,3 +152,71 @@ describe("visual-diff.js — responsive layout comparison", () => { expect(desktop.layoutAnalysis).toBeDefined(); }); }); + +// ============================================================================= +// Cropping a partial reference out of a full-page capture (issue #145) +// ============================================================================= + +function runDiffRaw(args) { + try { + const stdout = execFileSync("node", [SCRIPT, ...args], { + encoding: "utf-8", + timeout: 30000, + stdio: ["ignore", "pipe", "pipe"], + }); + return { status: 0, stdout, stderr: "" }; + } catch (err) { + return { status: err.status, stdout: err.stdout || "", stderr: err.stderr || "" }; + } +} + +describe("visual-diff.js — --crop for a reference that covers part of the page", () => { + // A 60x40 checkerboard reference, found inside a 100x120 capture at (20, 50). + const checker = (x, y) => + (Math.floor(x / 10) + Math.floor(y / 10)) % 2 ? [20, 40, 200, 255] : [230, 230, 230, 255]; + let reference; + let capture; + + beforeAll(() => { + reference = savePNG("crop-reference.png", createPNG(60, 40, checker)); + capture = savePNG( + "crop-capture.png", + createPNG(100, 120, (x, y) => + x >= 20 && x < 80 && y >= 50 && y < 90 ? checker(x - 20, y - 50) : [255, 255, 255, 255], + ), + ); + }); + + it("without --crop the whole capture is compared, so the sizes differ and the diff fails", () => { + const r = runDiff([capture, reference, "--json", "--threshold", "0.02"]); + expect(r.dimensions.actualSize).toBe("100x120"); + expect(r.pass).toBe(false); + }); + + it("--crop x,y,w,h compares only that region of the capture, at the reference's size", () => { + const r = runDiff([ + capture, + reference, + "--json", + "--threshold", + "0.02", + "--crop", + "20,50,60,40", + ]); + expect(r.crop).toMatchObject({ x: 20, y: 50, width: 60, height: 40 }); + expect(r.dimensions).toMatchObject({ + width: 60, + height: 40, + actualSize: "60x40", + expectedSize: "60x40", + }); + expect(r.mismatchPct).toBe(0); + expect(r.pass).toBe(true); + }); + + it("refuses a crop box that reaches outside the capture", () => { + const r = runDiffRaw([capture, reference, "--json", "--crop", "60,100,60,40"]); + expect(r.status).toBe(2); + expect(r.stderr).toMatch(/crop/i); + }); +}); diff --git a/scripts/visual-diff.js b/scripts/visual-diff.js index 170f53c..04d596c 100644 --- a/scripts/visual-diff.js +++ b/scripts/visual-diff.js @@ -3,7 +3,7 @@ * visual-diff.js — Pixel-level screenshot comparison using pixelmatch * * Usage: - * node scripts/visual-diff.js [--output ] [--threshold <0.02>] [--json] + * node scripts/visual-diff.js [--output ] [--threshold <0.02>] [--json] [--crop ] * node scripts/visual-diff.js --batch [--output-dir ] [--threshold <0.02>] * * Outputs: @@ -43,6 +43,7 @@ function parseArgs(args) { json: false, regionGrid: null, antialiasing: null, + crop: null, }; let i = 0; @@ -70,6 +71,9 @@ function parseArgs(args) { case "--antialiasing": parsed.antialiasing = args[++i] !== "false"; break; + case "--crop": + parsed.crop = args[++i]; + break; default: if (!parsed.actual) parsed.actual = arg; else if (!parsed.expected) parsed.expected = arg; @@ -93,6 +97,44 @@ function savePNG(filepath, png) { writeFileSync(filepath, buffer); } +/** `--crop x,y,width,height` → { x, y, width, height }, all non-negative integers. */ +function parseCrop(spec) { + const parts = String(spec) + .split(",") + .map((n) => Number(n)); + const [x, y, width, height] = parts; + const ok = + parts.length === 4 && + parts.every((n) => Number.isInteger(n) && n >= 0) && + width > 0 && + height > 0; + if (!ok) { + throw new Error(`--crop expects x,y,width,height as non-negative integers, got "${spec}"`); + } + return { x, y, width, height }; +} + +/** + * The region `box` of a PNG, as a new PNG. A reference that covers only part + * of the page (a screenshot that starts below the top, or one narrower than + * the viewport) is compared against this region of the full-page capture + * instead of the whole capture (issue #145). + */ +function cropPNG(src, box) { + const { x, y, width, height } = box; + if (x + width > src.width || y + height > src.height) { + throw new Error( + `crop ${x},${y},${width},${height} reaches outside the ${src.width}x${src.height} capture`, + ); + } + const out = new PNG({ width, height }); + for (let row = 0; row < height; row++) { + const from = ((y + row) * src.width + x) * 4; + src.data.copy(out.data, row * width * 4, from, from + width * 4); + } + return out; +} + /** * Analyze which regions of the image have the most differences. * Divides the image into a grid and reports mismatch per cell. @@ -462,7 +504,9 @@ function compareSingle(actualPath, expectedPath, options) { const layoutEnabled = vdConfig.layoutDriftAnalysis !== false; const subPixelMaxCluster = vdConfig.subPixelMaxClusterSize ?? 2; - const actual = loadPNG(actualPath); + const captured = loadPNG(actualPath); + const crop = options.crop ? parseCrop(options.crop) : null; + const actual = crop ? cropPNG(captured, crop) : captured; const expected = loadPNG(expectedPath); // Handle size mismatch by scaling to the larger dimensions @@ -547,6 +591,7 @@ function compareSingle(actualPath, expectedPath, options) { actualSize: `${actual.width}x${actual.height}`, expectedSize: `${expected.width}x${expected.height}`, }, + crop: crop ? { ...crop, captureSize: `${captured.width}x${captured.height}` } : null, totalPixels, diffPixels: numDiffPixels, mismatchPct: Math.round(mismatchPct * 10000) / 10000, @@ -684,6 +729,11 @@ function formatHumanReadable(result) { lines.push(`Expected: ${result.expected}`); if (result.diffImage) lines.push(`Diff: ${result.diffImage}`); lines.push(""); + if (result.crop) { + lines.push( + `Crop: ${result.crop.width}x${result.crop.height} at (${result.crop.x}, ${result.crop.y}) of the ${result.crop.captureSize} capture`, + ); + } lines.push(`Dimensions: ${result.dimensions.width}x${result.dimensions.height}`); if (result.dimensions.actualSize !== result.dimensions.expectedSize) { lines.push( @@ -785,6 +835,7 @@ if (!args.actual || !args.expected) { ); console.error(" --region-grid <4> Grid divisions for region analysis (default: 4)"); console.error(" --antialiasing Ignore antialiasing differences (default: true)"); + console.error(" --crop Compare only this region of the actual capture"); console.error(" --json Output JSON instead of human-readable"); process.exit(2); } From b2f7e420855326b9c8e048710b27527feda2a6db Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Sun, 8 Nov 2026 19:27:59 -0500 Subject: [PATCH 3/4] docs(skills): SEO at intake, Storybook skip, crop references, E2E port The pipeline skills describe what the scripts now do (issue #145): - screenshot-intake asks for a page title and a one-line description (Q7, with defaults from the vision pass) and writes them to pages[].seo; a screenshot taken at a non-standard width or covering part of the page gets a `reference` with renderWidth and crop. - build-from-screenshot passes `reference` into the component-lanes args as an object reference, writes the title, meta description and robots.txt in Phase 4 (the SEO floor applies to a screenshot build too; the report lists them as additions), and records Phase 4.5 as skipped when Storybook is not installed. - build-from-figma documents the object form of a reference and the Storybook skip; parallel-orchestration says to run Bash dispatches from the app directory when the app lives beside the framework. - storybook-story-generation and the skills README say Storybook 10 and the framework-package imports; e2e-test-generator explains the E2E_PORT fallback instead of hand-editing baseURL per framework. Refs #145 Co-Authored-By: Claude Fable 5.1 --- .claude/skills/README.md | 2 +- .claude/skills/build-from-figma/SKILL.md | 4 ++-- .claude/skills/build-from-screenshot/SKILL.md | 5 +++-- .claude/skills/e2e-test-generator/SKILL.md | 9 ++++++--- .../skills/parallel-orchestration/SKILL.md | 6 ++++-- .claude/skills/screenshot-intake/SKILL.md | 20 ++++++++++++++++++- .../storybook-story-generation/SKILL.md | 16 ++++++++++----- 7 files changed, 46 insertions(+), 16 deletions(-) diff --git a/.claude/skills/README.md b/.claude/skills/README.md index ec82532..b2c785c 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -150,7 +150,7 @@ These skills provide patterns and best practices. They trigger on relevant keywo #### 24. storybook-story-generation (Phase 4.5) -- **Purpose:** Auto-generates Storybook 8 (CSF3) `.stories.tsx` and `.mdx` docs from built React components using a `ts-morph` AST. Emits prop-aware `argTypes`/controls, a `Default` story seeded with destructured defaults, one variant story per string-literal-union value, `True`/`False` stories for booleans, and action args for `on*` callbacks. Wraps `scripts/generate-stories.sh`. Non-blocking. +- **Purpose:** Auto-generates Storybook 10 (CSF3) `.stories.tsx` and `.mdx` docs from built React components using a `ts-morph` AST. Emits prop-aware `argTypes`/controls, a `Default` story seeded with destructured defaults, one variant story per string-literal-union value, `True`/`False` stories for booleans, and action args for `on*` callbacks. Wraps `scripts/generate-stories.sh`. Non-blocking. - **Triggers:** Phase 4.5 of `/build-from-figma`, `/build-from-canva`, and `/build-from-screenshot` (after component build), "generate Storybook stories", "auto-generate stories", "add MDX docs" - **Output:** `src/components/**/*.stories.tsx` and `src/components/**/*.mdx` diff --git a/.claude/skills/build-from-figma/SKILL.md b/.claude/skills/build-from-figma/SKILL.md index 932c18d..f716398 100644 --- a/.claude/skills/build-from-figma/SKILL.md +++ b/.claude/skills/build-from-figma/SKILL.md @@ -173,7 +173,7 @@ Building components and fixing visual differences are per-item work, so they run "skipVisual": false } ``` - `tier` follows the component's `category` in the build spec: `ui` 0, `layout` 1, `sections` 2, `pages` 3. `maxVisualIterations` and `diffPassThreshold` come from `pipeline.config.json → iterationLoop`. Set `skipVisual` when the manifest excludes `visual-diff`. + `tier` follows the component's `category` in the build spec: `ui` 0, `layout` 1, `sections` 2, `pages` 3. `maxVisualIterations` and `diffPassThreshold` come from `pipeline.config.json → iterationLoop`. Set `skipVisual` when the manifest excludes `visual-diff`. A reference may also be an object, `{ "path", "renderWidth", "crop": { "x", "y", "width", "height" } }`, for a source screenshot taken at a non-standard width or covering only part of the page: the visual check then captures at `renderWidth` and passes `--crop` to `visual-diff.js` (the screenshot pipeline fills it from the build spec's `reference`). 3. **What it does:** each tier's components build in parallel lanes (the converter writes only inside its own directory; shared files such as barrels are applied once per tier), and a separate check reruns each component's tests and compares its test file's checksum. Then up to `maxVisualIterations` rounds: every page is diffed in parallel, the pass decision is made from the numbers, fixes are grouped so each component has one fixer per round, and every round re-checks every page. 4. **Record the result** in `pipeline-state.json`: phases 4 and 5, each component's status, each page's status and worst mismatch. A `TEST_TAMPERED` component changed its test file: restore that file from the Phase 3 output (`git checkout -- ` when it is committed) and mark the component `needs-review`. Leave the dev server running for the phases below. @@ -247,7 +247,7 @@ This phase: 3. Runs `pnpm build-storybook --quiet` as a smoke test (if Storybook is installed) 4. Skips components that already have story files -Non-blocking: if Storybook is not installed or generation fails, log a warning and continue. +Non-blocking. When the app's `package.json` lists no Storybook package, `generate-stories.sh` exits 0 with `status: skipped` and the install command, because stories without Storybook would break `tsc` and the build; record the phase as `skipped`. Any other failure logs a warning and the pipeline continues. ## Phase 5: Visual Verification (Automated Pixel Diff) diff --git a/.claude/skills/build-from-screenshot/SKILL.md b/.claude/skills/build-from-screenshot/SKILL.md index 3b0bd03..5c3bf9d 100644 --- a/.claude/skills/build-from-screenshot/SKILL.md +++ b/.claude/skills/build-from-screenshot/SKILL.md @@ -106,7 +106,7 @@ Identical to `/build-from-figma` Phase 3. After Phase 3 confirms RED, resolve the renderer manifest (`node scripts/renderer-registry.js resolve --json`) and run Phases 4-9 exactly as `/build-from-figma` describes (`.claude/skills/build-from-figma/SKILL.md` → Phases 4-9): the `component-lanes` workflow for Phases 4-5 when the Workflow tool is available, then `parallel-orchestration` for Phases 4.5-9. The screenshot differences: -- **Workflow args:** `"source": "screenshot"`; `converter` is `manifest.converter` (`figma-react-converter`, the generic React builder, for React renderers; `react-native-converter` for expo); `testCommand` is `manifest.commands.test`. The page `references` are the source captures in `.claude/visual-qa/screenshots/source/`, keyed by the width they were captured at. Set `skipVisual` when the manifest excludes `visual-diff` (expo). +- **Workflow args:** `"source": "screenshot"`; `converter` is `manifest.converter` (`figma-react-converter`, the generic React builder, for React renderers; `react-native-converter` for expo); `testCommand` is `manifest.commands.test`. The page `references` are the source captures in `.claude/visual-qa/screenshots/source/`, keyed by the width they were captured at. A page whose build-spec entry carries `reference` (a `renderWidth` and/or a `crop`, screenshot-intake Step 5) passes it through as an object, `"1280": { "path": "…", "renderWidth": 1265, "crop": { "x": 0, "y": 180, "width": 1265, "height": 2400 } }`, so the visual stage captures at that width and diffs only that region of the capture (issue #145). Set `skipVisual` when the manifest excludes `visual-diff` (expo). - **parallel-orchestration:** drop every phase in `manifest.phases.exclude` (expo excludes `visual-diff`, `dark-mode`, `cross-browser` and `responsive`), and also `cross-browser` for `appType: "chrome-extension"`. Context: pipeline source `"screenshot"`, test files `src/components/**/*.test.*`, reference screenshots `.claude/visual-qa/screenshots/source/`. `e2e-tests` follows `component-build` when `visual-diff` is excluded. Without the Workflow tool, invoke `parallel-orchestration` with all of `["component-build", "storybook", "visual-diff", "dark-mode", "e2e-tests", "cross-browser", "quality-gate", "responsive", "report"]` minus those exclusions. If `orchestration.enabled` is `false`, run the phases sequentially as documented below. @@ -135,12 +135,13 @@ This phase: 4. Uses source screenshots for layout/structure decisions 5. Generates components (at `manifest.component.dir` with `manifest.component.ext`) that satisfy the test files from Phase 3 6. Runs `manifest.commands.test` after each component batch to confirm GREEN +7. Sets each page's `` and `<meta name="description">` from `pages[].seo` in the build spec (`index.html` for Vite, the route's metadata for Next.js) and keeps `public/robots.txt`. The quality gate's SEO floor (`qualityGate.lighthouseThresholds.seo`, 90) applies to a screenshot build too; these come from the intake, not from the design, and the build report lists them as additions **Critical rule:** If tests fail, fix the component — never modify the test files. ## Phase 4.5: Storybook Generation (Non-Blocking) -Identical to `/build-from-figma` Phase 4.5. Skipped when the renderer has no browser story (e.g. expo). +Identical to `/build-from-figma` Phase 4.5. Skipped when the renderer has no browser story (e.g. expo), and skipped by design when the app's `package.json` lists no Storybook package: `generate-stories.sh` then exits 0 with `status: skipped` and the install command, and the phase is recorded as `skipped` in `pipeline-state.json`. Stories without Storybook installed would break `tsc` and the build. When the app is scaffolded beside the framework, run it from the app directory (`cd my-app && ../scripts/generate-stories.sh`). ```bash ./scripts/generate-stories.sh diff --git a/.claude/skills/e2e-test-generator/SKILL.md b/.claude/skills/e2e-test-generator/SKILL.md index 55f7366..065b533 100644 --- a/.claude/skills/e2e-test-generator/SKILL.md +++ b/.claude/skills/e2e-test-generator/SKILL.md @@ -44,9 +44,12 @@ Generate comprehensive Playwright E2E tests based on the build specification. Re Copy the shared Playwright config template and customize: 1. Copy `templates/shared/playwright.config.ts` → project root `playwright.config.ts` -2. Adjust `baseURL` and `webServer.command` based on framework: - - **Next.js:** `baseURL: "http://localhost:3000"`, `command: "pnpm dev"` - - **Vite:** `baseURL: "http://localhost:5173"`, `command: "pnpm dev"` +2. The config reads the dev server port from `E2E_PORT` and falls back to the port + `setup-project.sh` set for the framework (Vite and SvelteKit 5173, Astro 4321, + Next.js 3000); `baseURL` and `webServer.url` both derive from it, and + `webServer.command` is `pnpm dev`, which a scaffolded app resolves itself since it + is its own pnpm root. For an app not scaffolded by `setup-project.sh`, set the + fallback (`|| 3000`) to its port before the first run. 3. The template includes 5 browser projects by default: - Desktop: `chromium`, `firefox`, `webkit` - Mobile: `mobile-chrome` (Pixel 5), `mobile-safari` (iPhone 13) diff --git a/.claude/skills/parallel-orchestration/SKILL.md b/.claude/skills/parallel-orchestration/SKILL.md index da4c526..44aadcb 100644 --- a/.claude/skills/parallel-orchestration/SKILL.md +++ b/.claude/skills/parallel-orchestration/SKILL.md @@ -138,16 +138,18 @@ function hasResourceConflict(candidatePhase, runningPhases): ### Step 5: Phase Dispatch Table +When the app is scaffolded beside the framework (`./scripts/setup-project.sh my-app`), run each **Bash** dispatch from the app directory, `cd my-app && ../scripts/<name>.sh …`: `generate-stories.sh` reads that directory's `src/components` and `package.json`, and `check-responsive.sh` installs Playwright there. The artifacts still land under the Aurelius checkout's `.claude/`. + | Phase | Dispatch Method | |-------|----------------| | `component-build` | **Agent:** dispatch the converter named by the resolved renderer manifest. For React renderers the calling command supplies the source-appropriate converter (figma/screenshot → figma-react-converter via the figma-to-react-workflow skill, canva → canva-react-converter); otherwise dispatch `manifest.converter` (e.g. react-native-converter for expo, future vue-converter / svelte-converter) | -| `storybook` | **Bash:** `./scripts/generate-stories.sh` | +| `storybook` | **Bash:** `./scripts/generate-stories.sh` (exits 0 with `status: skipped` when the app's `package.json` lists no Storybook package; record the phase as skipped) | | `visual-diff` | **Agent:** invoke visual-qa-verification skill with pixel-diff loop (max iterations from `iterationLoop.maxVisualIterations`) | | `dark-mode` | **Bash:** `./scripts/check-dark-mode.sh http://localhost:3000` | | `e2e-tests` | **Agent:** invoke e2e-test-generator skill with flows from build-spec.json | | `cross-browser` | **Bash:** `./scripts/cross-browser-baseline.sh compare http://localhost:3000 --json` (diffs firefox/webkit against committed baselines with provenance checks — RFC 0002; skips with a capture hint when no baselines exist) | | `quality-gate` | **Parallel sub-dispatch:** run all `qualityGateSubtasks` concurrently (see below) | -| `responsive` | **Bash:** `./scripts/check-responsive.sh http://localhost:3000` | +| `responsive` | **Bash:** `./scripts/check-responsive.sh http://localhost:3000` (installs Playwright in the directory it runs from when that holds a `package.json`) | | `report` | **Agent:** generate `build-report.md` in `.claude/visual-qa/` from all collected phase results, timings, and diff images | ### Step 6: Quality Gate Sub-Parallelization diff --git a/.claude/skills/screenshot-intake/SKILL.md b/.claude/skills/screenshot-intake/SKILL.md index 4ddb3c3..9558627 100644 --- a/.claude/skills/screenshot-intake/SKILL.md +++ b/.claude/skills/screenshot-intake/SKILL.md @@ -117,6 +117,12 @@ If both MCPs fail or are unavailable: Preserve original filenames where unambiguous; otherwise rename: page-1-desktop.png, page-2-desktop.png, ... 3. Record original paths and dimensions in build-spec.json +4. When a file's width is none of the configured viewports, or the file does + not start at the top of the page, add a `reference` to its page (Step 5): + `renderWidth` is the file's pixel width (a 1280 px window with a 15 px + scrollbar gives 1265), `crop` the region of the full-page render the file + covers. Ask whether a file is a partial capture when the vision pass sees + no page header; `crop.y` may be left for visual round 1 to measure. ``` ### Step 1.1: Multi-Page Handling @@ -222,7 +228,7 @@ Store extracted data in working memory for Step 4 (questions) and Step 5 (build- Read .claude/pipeline.config.json (screenshot, appTypes sections). ``` -### Step 4: Ask Targeted Questions (Max 6) +### Step 4: Ask Targeted Questions (Max 7) Only ask what cannot be derived from captures or local scan. Use `AskUserQuestion` tool with multiple questions in one prompt where possible. @@ -259,6 +265,12 @@ Only ask what cannot be derived from captures or local scan. Use `AskUserQuestio > a) Standalone app (new project scaffold) > b) Integrate into the existing project at [detected path] +**Q7 — Page title and description (always, with defaults):** +> A `<title>` and a one-line meta description for each page, defaulting to the +> main heading and the first paragraph the vision pass extracted. The quality +> gate's Lighthouse SEO floor needs both, and a screenshot cannot supply them; +> Phase 4 writes them into the page and the build report lists them as additions. + ### Step 5: Generate build-spec.json Write `.claude/plans/build-spec.json`. The schema is consumed by `canva-token-inference` (Phase 2), `tdd-from-figma` (Phase 3), and the framework-specific converter agents (Phase 4). @@ -301,6 +313,12 @@ Write `.claude/plans/build-spec.json`. The schema is consumed by `canva-token-in "name": "Home", "route": "/", "screenshotPath": ".claude/visual-qa/screenshots/source/page-1-desktop.png", + "seo": { // Q7: written into <title> and <meta name="description"> in Phase 4 + "title": "Example: build faster with AI", + "description": "Ship production apps in days, not months." + }, + // Only for a screenshot taken at a non-standard width or covering part of the page: + // "reference": { "renderWidth": 1265, "crop": { "x": 0, "y": 180, "width": 1265, "height": 2400 } }, "sections": ["nav", "hero", "features", "pricing", "footer"] } ], diff --git a/.claude/skills/storybook-story-generation/SKILL.md b/.claude/skills/storybook-story-generation/SKILL.md index 130708b..02b57c0 100644 --- a/.claude/skills/storybook-story-generation/SKILL.md +++ b/.claude/skills/storybook-story-generation/SKILL.md @@ -9,7 +9,7 @@ allowed-tools: Bash(./scripts/generate-stories.sh *), Bash(pnpm build-storybook ## Purpose -Auto-generate Storybook 8 (CSF3) stories and MDX documentation from built React +Auto-generate Storybook 10 (CSF3) stories and MDX documentation from built React components. The generator parses each component's TypeScript source with a `ts-morph` AST — not regex — so it can reliably extract prop types, string-literal unions, default values, callback props, and JSDoc comments, then emit: @@ -24,7 +24,13 @@ unions, default values, callback props, and JSDoc comments, then emit: The skill wraps `scripts/generate-stories.sh` (a thin wrapper over `scripts/generate-stories.js`). It is a **non-blocking** step: a failure never -fails the build. +fails the build. When the project's `package.json` lists no Storybook package, +the script exits 0 with `status: skipped` (in `--json` mode) and prints the +install command, because stories without Storybook installed would break `tsc` +and the build; the pipelines record the phase as `skipped`. Generated stories +import their types from the framework package named in `.storybook/main.ts` +(`@storybook/react-vite` when none is found), as Storybook 9+ recommends, and +the MDX pages take their blocks from `@storybook/addon-docs/blocks`. ## When to Use @@ -103,7 +109,7 @@ Emit CSF3 with `title: 'Components/<path-relative-to-src>'`, `tags: ['autodocs'] `includeResponsiveViewports` is enabled. ```tsx -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; // the framework named in .storybook/main.ts import { Badge } from './Badge'; const meta: Meta<typeof Badge> = { @@ -126,12 +132,12 @@ export const Success: Story = { args: { variant: 'success' } }; ### Step 5: Generate `.mdx` (unless `--no-mdx` or `generateMdx: false`) -Emit an autodocs page importing `@storybook/blocks`, referencing the sibling +Emit an autodocs page importing `@storybook/addon-docs/blocks`, referencing the sibling `.stories`, and embedding the component's JSDoc description, the `Default` `Canvas`, `Controls`, a `Canvas` per variant, and the full `ArgTypes` table. ```mdx -import { Meta, Canvas, Controls, ArgTypes } from '@storybook/blocks'; +import { Meta, Canvas, Controls, ArgTypes } from '@storybook/addon-docs/blocks'; import * as Stories from './Badge.stories'; <Meta of={Stories} /> From 93ee33b255fecf9205179c891b96eca82ec043dc Mon Sep 17 00:00:00 2001 From: Paul Mulligan <paul@pmds.pull-list.net> Date: Sun, 8 Nov 2026 19:28:03 -0500 Subject: [PATCH 4/4] docs(agents): when Claude delegates; test-writer-fixer pulls harder Two plain "write tests for the X component" requests in issue #145 (item 7) were handled by the main session; no agent ran. The README, the quickstart and the agents guide promised automatic selection. The test-writer-fixer description now names the plain request, covers unit, integration and E2E tests, and says it must be used instead of writing tests in the main session. The three docs say that Claude Code delegates when a request matches an agent's description, may handle a small task itself, and that naming the agent makes the delegation certain; the quickstart example names it. Refs #145 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .claude/CUSTOM-AGENTS-GUIDE.md | 6 +++--- .claude/agents/test-writer-fixer.md | 2 +- README.md | 2 +- docs/onboarding/quickstart.md | 14 +++++++------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.claude/CUSTOM-AGENTS-GUIDE.md b/.claude/CUSTOM-AGENTS-GUIDE.md index 1fdf7a5..969c043 100644 --- a/.claude/CUSTOM-AGENTS-GUIDE.md +++ b/.claude/CUSTOM-AGENTS-GUIDE.md @@ -120,7 +120,7 @@ Agents are auto-selected by Claude Code based on task context, or you can reques ## How Agents Work -Agents are invoked through Claude Code's Agent tool. They are automatically selected based on task context: +Agents are invoked through Claude Code's Agent tool. Claude Code delegates when a request matches an agent's description, and may handle a small task itself instead; name the agent to make the delegation certain: ``` User: "Help me optimize this component's rendering" @@ -129,11 +129,11 @@ Claude: [Uses performance-benchmarker agent] User: "Convert this Figma design to React" Claude: [Uses figma-react-converter agent] -User: "Write tests for the auth hook" +User: "Use the test-writer-fixer agent to write tests for the auth hook" Claude: [Uses test-writer-fixer agent] ``` -You can also request a specific agent: +Naming the agent, as in the last example, is the sure way to get it: ``` User: "Use the accessibility-auditor agent to check this page" ``` diff --git a/.claude/agents/test-writer-fixer.md b/.claude/agents/test-writer-fixer.md index a363a19..0fe8677 100644 --- a/.claude/agents/test-writer-fixer.md +++ b/.claude/agents/test-writer-fixer.md @@ -1,6 +1,6 @@ --- name: test-writer-fixer -description: Use this agent when code changes have been made and you need to write new tests, run existing tests, analyze failures, and fix them while maintaining test integrity. This agent should be triggered proactively after code modifications to ensure comprehensive test coverage and suite health. +description: Use this agent for any request to write, add, extend, fix or run tests, including a plain "write tests for the Checklist component", and proactively after code changes to run the affected tests, analyze failures and repair them while keeping the tests honest. Covers unit, integration and E2E tests for components, hooks, modules, pages and flows. MUST BE USED instead of writing tests in the main session. color: cyan memory: local skills: diff --git a/README.md b/README.md index 309ec59..10f5b70 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ All thresholds and behavior are configurable in `.claude/pipeline.config.json`: ## 56 Custom Agents -Agents are auto-selected by Claude Code based on your task: +Claude Code delegates to an agent when your request matches its description, and may handle a small task itself instead. Naming the agent makes the delegation certain ("Use the test-writer-fixer agent to write tests for Checklist"): | Category | Count | Key Agents | |----------|-------|------------| diff --git a/docs/onboarding/quickstart.md b/docs/onboarding/quickstart.md index 172afac..56e3cba 100644 --- a/docs/onboarding/quickstart.md +++ b/docs/onboarding/quickstart.md @@ -108,25 +108,25 @@ Captures the page, analyzes it with vision, and builds a working app. Supports a ## 5. Using Claude Code Agents -When you work with Claude Code in this repository, **56 specialized agents** are available automatically. You do not need to invoke them manually -- Claude Code selects the right agent based on your task. +When you work with Claude Code in this repository, **56 specialized agents** are available. Claude Code delegates to one when your request matches the agent's description, and it may handle a small task itself instead (a plain "write tests for the Checklist component" is often done in the main session). Naming the agent makes the delegation certain. ### Examples ``` User: "Build a hero component with a CTA button" -Claude: [Uses frontend-developer agent] +Claude: [may use the frontend-developer agent] -User: "Write tests for the auth hook" -Claude: [Uses test-writer-fixer agent] +User: "Use the test-writer-fixer agent to write tests for the auth hook" +Claude: [uses the test-writer-fixer agent] User: "Check this page for accessibility issues" -Claude: [Uses accessibility-auditor agent] +Claude: [may use the accessibility-auditor agent] User: "Optimize the bundle size" -Claude: [Uses bundle-analyzer agent] +Claude: [may use the bundle-analyzer agent] User: "Create a PR for this feature" -Claude: [Uses commit-commands plugin: /commit-push-pr] +Claude: [uses the commit-commands plugin: /commit-push-pr] ``` See the full [Architecture Overview](architecture.md) for the complete agent catalog.