@cloverleaf/reference-impl 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +18 -0
  2. package/VERSION +1 -1
  3. package/config/affected-routes.json +6 -8
  4. package/config/qa-rules.json +3 -13
  5. package/config/ui-paths.json +6 -1
  6. package/config/ui-review.json +18 -0
  7. package/dist/affected-routes.mjs +1 -1
  8. package/dist/axe-dedupe.mjs +42 -0
  9. package/dist/cli.mjs +32 -1
  10. package/dist/feedback.mjs +1 -1
  11. package/dist/plugin-path.mjs +19 -0
  12. package/dist/qa-report.mjs +65 -0
  13. package/dist/qa-rules.mjs +1 -1
  14. package/dist/route-slug.mjs +23 -0
  15. package/dist/ui-paths.mjs +1 -1
  16. package/dist/ui-review-config.mjs +41 -0
  17. package/dist/visual-diff.mjs +62 -0
  18. package/install.sh +30 -44
  19. package/lib/affected-routes.ts +1 -1
  20. package/lib/axe-dedupe.ts +64 -0
  21. package/lib/cli.ts +32 -1
  22. package/lib/feedback.ts +8 -1
  23. package/lib/plugin-path.ts +21 -0
  24. package/lib/qa-report.ts +77 -0
  25. package/lib/qa-rules.ts +1 -1
  26. package/lib/route-slug.ts +21 -0
  27. package/lib/ui-paths.ts +1 -1
  28. package/lib/ui-review-config.ts +62 -0
  29. package/lib/visual-diff.ts +97 -0
  30. package/package.json +8 -3
  31. package/prompts/qa.md +21 -0
  32. package/prompts/ui-reviewer.md +90 -39
  33. package/skills/{cloverleaf-document.md → cloverleaf-document/SKILL.md} +2 -2
  34. package/skills/{cloverleaf-implement.md → cloverleaf-implement/SKILL.md} +3 -3
  35. package/skills/{cloverleaf-merge.md → cloverleaf-merge/SKILL.md} +26 -5
  36. package/skills/{cloverleaf-new-task.md → cloverleaf-new-task/SKILL.md} +20 -3
  37. package/skills/{cloverleaf-qa.md → cloverleaf-qa/SKILL.md} +26 -11
  38. package/skills/{cloverleaf-review.md → cloverleaf-review/SKILL.md} +18 -8
  39. package/skills/{cloverleaf-ui-review.md → cloverleaf-ui-review/SKILL.md} +37 -20
  40. /package/skills/{cloverleaf-run.md → cloverleaf-run/SKILL.md} +0 -0
package/lib/cli.ts CHANGED
@@ -12,6 +12,8 @@
12
12
  * write-feedback <repoRoot> <taskId> <envelopeJsonPath>
13
13
  * latest-feedback <repoRoot> <taskId>
14
14
  * emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]
15
+ * ui-review-config --repo-root <repoRoot>
16
+ * plugin-root
15
17
  */
16
18
 
17
19
  import { readFileSync } from 'node:fs';
@@ -25,6 +27,8 @@ import { matchesUiPaths } from './ui-paths.js';
25
27
  import { loadUiPathsConfig } from './ui-paths.js';
26
28
  import { computeAffectedRoutes } from './affected-routes.js';
27
29
  import { loadAffectedRoutesConfig } from './affected-routes.js';
30
+ import { loadUiReviewConfig } from './ui-review-config.js';
31
+ import { getPluginRoot } from './plugin-path.js';
28
32
  import type { FeedbackEnvelope } from './feedback.js';
29
33
 
30
34
  function die(msg: string, code = 1): never {
@@ -43,7 +47,9 @@ function usage(msg?: string): never {
43
47
  ' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]\n' +
44
48
  ' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
45
49
  ' latest-feedback <repoRoot> <taskId>\n' +
46
- ' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n'
50
+ ' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
51
+ ' ui-review-config --repo-root <repoRoot>\n' +
52
+ ' plugin-root\n'
47
53
  );
48
54
  process.exit(2);
49
55
  }
@@ -221,6 +227,31 @@ try {
221
227
  process.exit(0);
222
228
  }
223
229
 
230
+ case 'ui-review-config': {
231
+ const flags = rest.filter((a) => a.startsWith('--'));
232
+ const repoRootFlag = flags.find((f) => f.startsWith('--repo-root=') || f === '--repo-root');
233
+ let repoRoot: string | undefined;
234
+ if (repoRootFlag === '--repo-root') {
235
+ repoRoot = rest[rest.indexOf('--repo-root') + 1];
236
+ } else if (repoRootFlag) {
237
+ repoRoot = repoRootFlag.replace('--repo-root=', '');
238
+ } else {
239
+ repoRoot = rest.filter((a) => !a.startsWith('--'))[0];
240
+ }
241
+ if (!repoRoot) {
242
+ console.error('usage: ui-review-config --repo-root <repoRoot>');
243
+ process.exit(1);
244
+ }
245
+ const config = loadUiReviewConfig(repoRoot);
246
+ process.stdout.write(JSON.stringify(config, null, 2));
247
+ process.exit(0);
248
+ }
249
+
250
+ case 'plugin-root': {
251
+ process.stdout.write(getPluginRoot());
252
+ process.exit(0);
253
+ }
254
+
224
255
  default:
225
256
  usage(`Unknown command: ${command}`);
226
257
  }
package/lib/feedback.ts CHANGED
@@ -13,12 +13,19 @@ export interface FindingLocation {
13
13
  work_item_id?: { project: string; id: string };
14
14
  }
15
15
 
16
+ export interface Attachment {
17
+ label: string;
18
+ path: string;
19
+ }
20
+
16
21
  export interface Finding {
17
22
  severity: FindingSeverity;
18
23
  message: string;
19
24
  location?: FindingLocation;
20
25
  suggestion?: string;
21
26
  rule?: string;
27
+ attachments?: Attachment[];
28
+ metadata?: Record<string, unknown>;
22
29
  }
23
30
 
24
31
  export interface FeedbackEnvelope {
@@ -63,7 +70,7 @@ export function latestFeedback(repoRoot: string, taskId: string): FeedbackEnvelo
63
70
  export function allFeedback(repoRoot: string, taskId: string): FeedbackEnvelope[] {
64
71
  const dir = feedbackDir(repoRoot);
65
72
  if (!existsSync(dir)) return [];
66
- const re = new RegExp(`^${escapeRegex(taskId)}-r(\\d+)\\.json$`);
73
+ const re = new RegExp(`^${escapeRegex(taskId)}-[ruq](\\d+)\\.json$`);
67
74
  const entries = readdirSync(dir)
68
75
  .map((f) => ({ f, m: f.match(re) }))
69
76
  .filter((x): x is { f: string; m: RegExpMatchArray } => !!x.m)
@@ -0,0 +1,21 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { dirname, resolve } from 'node:path';
3
+
4
+ const here = dirname(fileURLToPath(import.meta.url));
5
+
6
+ /**
7
+ * Absolute path to the plugin root.
8
+ *
9
+ * At runtime, this module lives at <plugin-root>/lib/plugin-path.js (or .ts in dev),
10
+ * so the plugin root is the parent directory.
11
+ *
12
+ * Works under:
13
+ * - dev mode (repo source: <repo>/reference-impl/)
14
+ * - npm install (node_modules/@cloverleaf/reference-impl/)
15
+ * - claude plugin install cache (~/.claude/plugins/cache/cloverleaf-local/cloverleaf/0.4.1/)
16
+ * - legacy symlink into ~/.claude/plugins/cloverleaf/
17
+ * - claude --plugin-dir <path>
18
+ */
19
+ export function getPluginRoot(): string {
20
+ return resolve(here, '..');
21
+ }
@@ -0,0 +1,77 @@
1
+ export interface QaRunResult {
2
+ ruleId: string;
3
+ command: string;
4
+ cwd: string;
5
+ durationMs: number;
6
+ passed: boolean;
7
+ stdoutTail: string;
8
+ stderrTail: string;
9
+ }
10
+
11
+ function escape(s: string): string {
12
+ return s
13
+ .replace(/&/g, '&amp;')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;')
16
+ .replace(/"/g, '&quot;')
17
+ .replace(/'/g, '&#39;');
18
+ }
19
+
20
+ function renderRow(r: QaRunResult): string {
21
+ const status = r.passed ? 'PASS' : 'FAIL';
22
+ const statusClass = r.passed ? 'pass' : 'fail';
23
+ return `
24
+ <tr class="${statusClass}">
25
+ <td>${escape(r.ruleId)}</td>
26
+ <td><code>${escape(r.command)}</code></td>
27
+ <td>${escape(r.cwd)}</td>
28
+ <td>${r.durationMs}ms</td>
29
+ <td class="status">${status}</td>
30
+ </tr>
31
+ <tr class="detail ${statusClass}">
32
+ <td colspan="5">
33
+ ${r.stdoutTail ? `<details><summary>stdout (tail)</summary><pre>${escape(r.stdoutTail)}</pre></details>` : ''}
34
+ ${r.stderrTail ? `<details open><summary>stderr (tail)</summary><pre>${escape(r.stderrTail)}</pre></details>` : ''}
35
+ </td>
36
+ </tr>
37
+ `;
38
+ }
39
+
40
+ export function renderQaReport(runs: QaRunResult[]): string {
41
+ const empty = runs.length === 0
42
+ ? `<p class="empty">No runs / results.</p>`
43
+ : '';
44
+ const rows = runs.map(renderRow).join('');
45
+ return `<!DOCTYPE html>
46
+ <html lang="en">
47
+ <head>
48
+ <meta charset="utf-8">
49
+ <title>Cloverleaf QA Report</title>
50
+ <style>
51
+ body { font: 14px/1.4 system-ui, sans-serif; margin: 2rem; color: #111; }
52
+ table { width: 100%; border-collapse: collapse; }
53
+ th, td { padding: 0.5rem; border-bottom: 1px solid #ddd; text-align: left; vertical-align: top; }
54
+ .status { font-weight: 600; }
55
+ .pass .status { color: #0a7; }
56
+ .fail .status { color: #c33; }
57
+ tr.detail td { background: #fafafa; padding-top: 0; }
58
+ pre { overflow: auto; background: #f4f4f4; padding: 0.5rem; }
59
+ .empty { color: #888; }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <h1>Cloverleaf QA Report</h1>
64
+ ${empty}
65
+ ${runs.length > 0 ? `
66
+ <table>
67
+ <thead>
68
+ <tr><th>Rule</th><th>Command</th><th>CWD</th><th>Duration</th><th>Status</th></tr>
69
+ </thead>
70
+ <tbody>
71
+ ${rows}
72
+ </tbody>
73
+ </table>
74
+ ` : ''}
75
+ </body>
76
+ </html>`;
77
+ }
package/lib/qa-rules.ts CHANGED
@@ -12,7 +12,7 @@ export interface QaRule {
12
12
  command: string;
13
13
  }
14
14
 
15
- export function loadDefaultRules(): QaRule[] {
15
+ function loadDefaultRules(): QaRule[] {
16
16
  if (!existsSync(DEFAULT_CONFIG)) return [];
17
17
  const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as { rules?: QaRule[] };
18
18
  return Array.isArray(doc.rules) ? doc.rules : [];
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Turn a URL path into a filesystem-safe slug used in baseline/diff filenames.
3
+ * - "/" → "index"
4
+ * - "/faq/" → "faq"
5
+ * - "/guide/chapter-3/" → "guide-chapter-3"
6
+ * - "/docs/v1.2/getting started/" → "docs-v1-2-getting-started"
7
+ * Query/hash are stripped.
8
+ */
9
+ export function slugifyRoute(route: string): string {
10
+ const pathOnly = route.split(/[?#]/)[0];
11
+ if (pathOnly === '/' || pathOnly === '') return 'index';
12
+ const trimmed = pathOnly.replace(/^\/+|\/+$/g, '');
13
+ if (trimmed === '') return 'index';
14
+ const slugged = trimmed
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9/-]+/g, '-')
17
+ .replace(/\/+/g, '-')
18
+ .replace(/-+/g, '-')
19
+ .replace(/^-|-$/g, '');
20
+ return slugged || 'index';
21
+ }
package/lib/ui-paths.ts CHANGED
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  const here = dirname(fileURLToPath(import.meta.url));
6
6
  const DEFAULT_CONFIG = join(here, '..', 'config', 'ui-paths.json');
7
7
 
8
- export function loadDefaultPatterns(): string[] {
8
+ function loadDefaultPatterns(): string[] {
9
9
  if (!existsSync(DEFAULT_CONFIG)) return ['site/**'];
10
10
  const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as { patterns?: string[] };
11
11
  return Array.isArray(doc.patterns) ? doc.patterns : ['site/**'];
@@ -0,0 +1,62 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ const PACKAGE_DEFAULT = join(here, '..', 'config', 'ui-review.json');
7
+
8
+ export interface Viewport {
9
+ width: number;
10
+ height: number;
11
+ }
12
+
13
+ export interface UiReviewConfig {
14
+ viewports: Record<string, Viewport>;
15
+ visualDiff: {
16
+ enabled: boolean;
17
+ threshold: number;
18
+ maxDiffRatio: number;
19
+ mask: string[];
20
+ };
21
+ axe: {
22
+ viewports: string[];
23
+ dedupeBy: ('ruleId' | 'target')[];
24
+ ignored: Array<{ ruleId: string; target: string }>;
25
+ };
26
+ }
27
+
28
+ const HARDCODED_FALLBACK: UiReviewConfig = {
29
+ viewports: {
30
+ mobile: { width: 375, height: 667 },
31
+ tablet: { width: 768, height: 1024 },
32
+ desktop: { width: 1280, height: 800 },
33
+ },
34
+ visualDiff: { enabled: true, threshold: 0.1, maxDiffRatio: 0.01, mask: [] },
35
+ axe: { viewports: ['desktop'], dedupeBy: ['ruleId', 'target'], ignored: [] },
36
+ };
37
+
38
+ function readAsConfig(path: string): UiReviewConfig | null {
39
+ try {
40
+ const doc = JSON.parse(readFileSync(path, 'utf-8')) as UiReviewConfig;
41
+ // Back-compat: if ignored is missing from an older override, default it.
42
+ if (doc.axe && !('ignored' in doc.axe)) {
43
+ (doc.axe as UiReviewConfig['axe']).ignored = [];
44
+ }
45
+ return doc;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function loadUiReviewConfig(repoRoot: string): UiReviewConfig {
52
+ const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'ui-review.json');
53
+ if (existsSync(consumerPath)) {
54
+ const parsed = readAsConfig(consumerPath);
55
+ if (parsed) return parsed;
56
+ }
57
+ if (existsSync(PACKAGE_DEFAULT)) {
58
+ const parsed = readAsConfig(PACKAGE_DEFAULT);
59
+ if (parsed) return parsed;
60
+ }
61
+ return HARDCODED_FALLBACK;
62
+ }
@@ -0,0 +1,97 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import pixelmatch from 'pixelmatch';
4
+ import { PNG } from 'pngjs';
5
+
6
+ export type VisualDiffStatus = 'new-baseline' | 'match' | 'diff' | 'dimension-mismatch';
7
+
8
+ export interface VisualDiffResult {
9
+ status: VisualDiffStatus;
10
+ diffPixels: number;
11
+ diffRatio: number;
12
+ width: number;
13
+ height: number;
14
+ }
15
+
16
+ export interface CompareVisualArgs {
17
+ baselinePath: string;
18
+ candidateBuf: Buffer;
19
+ diffPath: string;
20
+ candidateOutPath: string;
21
+ threshold: number;
22
+ maxDiffRatio: number;
23
+ }
24
+
25
+ function ensureDir(path: string): void {
26
+ mkdirSync(dirname(path), { recursive: true });
27
+ }
28
+
29
+ function writeBaseline(baselinePath: string, buf: Buffer): void {
30
+ ensureDir(baselinePath);
31
+ writeFileSync(baselinePath, buf);
32
+ }
33
+
34
+ export function compareVisual(args: CompareVisualArgs): VisualDiffResult {
35
+ const candidatePng = PNG.sync.read(args.candidateBuf);
36
+
37
+ if (!existsSync(args.baselinePath)) {
38
+ writeBaseline(args.baselinePath, args.candidateBuf);
39
+ return {
40
+ status: 'new-baseline',
41
+ diffPixels: 0,
42
+ diffRatio: 0,
43
+ width: candidatePng.width,
44
+ height: candidatePng.height,
45
+ };
46
+ }
47
+
48
+ const baselineBuf = readFileSync(args.baselinePath);
49
+ const baselinePng = PNG.sync.read(baselineBuf);
50
+
51
+ if (baselinePng.width !== candidatePng.width || baselinePng.height !== candidatePng.height) {
52
+ writeBaseline(args.baselinePath, args.candidateBuf);
53
+ return {
54
+ status: 'dimension-mismatch',
55
+ diffPixels: 0,
56
+ diffRatio: 0,
57
+ width: candidatePng.width,
58
+ height: candidatePng.height,
59
+ };
60
+ }
61
+
62
+ const diffPng = new PNG({ width: candidatePng.width, height: candidatePng.height });
63
+ const diffPixels = pixelmatch(
64
+ baselinePng.data,
65
+ candidatePng.data,
66
+ diffPng.data,
67
+ candidatePng.width,
68
+ candidatePng.height,
69
+ { threshold: args.threshold },
70
+ );
71
+ const totalPixels = candidatePng.width * candidatePng.height;
72
+ const diffRatio = diffPixels / totalPixels;
73
+
74
+ if (diffRatio > args.maxDiffRatio) {
75
+ ensureDir(args.diffPath);
76
+ writeFileSync(args.diffPath, PNG.sync.write(diffPng));
77
+ ensureDir(args.candidateOutPath);
78
+ writeFileSync(args.candidateOutPath, args.candidateBuf);
79
+ writeBaseline(args.baselinePath, args.candidateBuf);
80
+ return {
81
+ status: 'diff',
82
+ diffPixels,
83
+ diffRatio,
84
+ width: candidatePng.width,
85
+ height: candidatePng.height,
86
+ };
87
+ }
88
+
89
+ writeBaseline(args.baselinePath, args.candidateBuf);
90
+ return {
91
+ status: 'match',
92
+ diffPixels,
93
+ diffRatio,
94
+ width: candidatePng.width,
95
+ height: candidatePng.height,
96
+ };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloverleaf/reference-impl",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Reference implementation of the Cloverleaf methodology as Claude Code skills. Implements the Tight Loop (Implementer + Reviewer).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  "reference-implementation"
24
24
  ],
25
25
  "files": [
26
+ ".claude-plugin",
26
27
  "skills",
27
28
  "lib",
28
29
  "prompts",
@@ -46,14 +47,18 @@
46
47
  "prepublishOnly": "npm test && npm run build"
47
48
  },
48
49
  "dependencies": {
49
- "@cloverleaf/standard": "^0.3.0",
50
+ "@cloverleaf/standard": "^0.4.0",
50
51
  "ajv": "^8.17.1",
51
52
  "ajv-formats": "^3.0.1",
53
+ "axe-core": "^4.10.0",
54
+ "pixelmatch": "^5.3.0",
52
55
  "playwright": "^1.47.0",
53
- "axe-core": "^4.10.0"
56
+ "pngjs": "^7.0.0"
54
57
  },
55
58
  "devDependencies": {
56
59
  "@types/node": "^22.0.0",
60
+ "@types/pixelmatch": "^5.2.6",
61
+ "@types/pngjs": "^6.0.5",
57
62
  "tsx": "^4.19.0",
58
63
  "typescript": "^5.5.0",
59
64
  "vitest": "^2.0.0"
package/prompts/qa.md CHANGED
@@ -57,6 +57,27 @@ The Standard's QA contract requires a `preview_uri`. You were passed the sentine
57
57
  - Use `git worktree`: do NOT `git checkout` in the main working directory.
58
58
  - Always teardown the worktree, even on error.
59
59
 
60
+ ## QA Report (v0.4)
61
+
62
+ After executing all matched QA rules, write an HTML report summarizing each run to `<repoRoot>/.cloverleaf/runs/{taskId}/qa/report.html` (substitute `{taskId}` with the `id` field from the task input, e.g., `{{task.id}}`).
63
+
64
+ Use `renderQaReport(runs)` from `lib/qa-report.ts` to produce the HTML. Ensure the directory exists first (`mkdir -p`).
65
+
66
+ In the feedback you emit, include the report as an attachment on a single info-level finding (or on whichever summary finding you already emit):
67
+
68
+ ```json
69
+ {
70
+ "severity": "info",
71
+ "rule": "qa-report",
72
+ "message": "QA report written",
73
+ "attachments": [
74
+ { "label": "report", "path": ".cloverleaf/runs/{taskId}/qa/report.html" }
75
+ ]
76
+ }
77
+ ```
78
+
79
+ This lets humans at final-gate inspect the full QA detail without grovelling through logs.
80
+
60
81
  ## Output
61
82
 
62
83
  Respond with exactly one JSON object and nothing else:
@@ -1,6 +1,6 @@
1
1
  # UI Reviewer Agent
2
2
 
3
- You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes for accessibility violations using axe-core in a headless Playwright chromium browser. You are read-only — you do not modify source code or tests.
3
+ You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes at multiple viewports for accessibility violations (axe-core) and visual regressions (pixelmatch) using a headless Playwright chromium browser. You are read-only for source code and tests but you DO write baseline/diff artifacts under `.cloverleaf/` on the feature branch.
4
4
 
5
5
  ## Input
6
6
 
@@ -11,20 +11,35 @@ You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes for acc
11
11
  - **Diff from base**: {{diff}}
12
12
  - **Preview port**: {{preview_port}} (an already-allocated free local port; use it for the dev server)
13
13
  - **Affected routes**: {{affected_routes}} — either a JSON array of route paths (e.g., `["/faq/"]`), or the string `"all"`, or `[]`
14
+ - **UI review config**: {{ui_review_config}} — the loaded `UiReviewConfig` object (viewports, visualDiff, axe) as JSON. The `viewports` array contains named entries such as `mobile`, `tablet`, and `desktop` with their respective `{ width, height }` dimensions.
14
15
 
15
- ## Scope (v0.3)
16
+ ## Paths
16
17
 
17
- - Accessibility only (axe-core). No visual diff, no responsive checks.
18
- - Single viewport: 1280×800.
19
- - Run axe ONLY on the pages listed in `{{affected_routes}}`.
20
- - If `{{affected_routes}}` is `"all"`: crawl up to 20 pages reachable from `/` via same-origin link discovery (v0.2 fallback behavior).
21
- - If `{{affected_routes}}` is `[]`: return `verdict: "pass"` with summary "No renderable routes affected, skipping axe." Do NOT start the preview server.
22
- - Otherwise: visit exactly the URLs listed. No link-discovery crawl.
23
- - Visual diff, viewports loop, and `visual_diff_uri` are deferred to v0.4.
18
+ You operate in two filesystem locations keep them straight:
19
+
20
+ - `<worktree>` the ephemeral worktree at `$TMPDIR` (set up in step 2 of the Runtime procedure). You run the dev server here and execute Playwright here.
21
+ - `<repoRoot>` — the main repository root at `{{repo_root}}` (always an absolute path). This is the ONLY location where baselines, diff PNGs, candidate PNGs, and artifacts are written.
22
+
23
+ **All `compareVisual` paths MUST be rooted at `{{repo_root}}`, NOT at `$TMPDIR`.**
24
+
25
+ The rationale: baselines on `{{repo_root}}/.cloverleaf/baselines/` get picked up by subsequent `git add` + `git commit` steps in the UI Reviewer, which run on the feature branch. The merge skill (v0.4.1+) then merges those commits to main via `git merge --no-ff`. Writing to the worktree's `.cloverleaf/` would strand the files and `git worktree remove --force` would discard them on teardown.
26
+
27
+ ## Scope (v0.4)
28
+
29
+ - **Accessibility (axe-core):** run at the viewports listed in `{{ui_review_config}}.axe.viewports`.
30
+ Apply the allowlist in `{{ui_review_config}}.axe.ignored` to drop pre-existing violations that the consumer has accepted (e.g., a11y debt being tracked separately).
31
+ Dedupe findings across viewports by the `{{ui_review_config}}.axe.dedupeBy` composite key (default `["ruleId", "target"]`).
32
+ Emit one finding per (ruleId, target) pair, with a `metadata.viewports` array aggregating the viewports where the violation was detected.
33
+ - **Visual diff (pixelmatch):** when `{{ui_review_config}}.visualDiff.enabled` is true, screenshot each route at each viewport in `{{ui_review_config}}.viewports`, compare to `.cloverleaf/baselines/{route-slug}-{viewport}.png`, emit `severity: "info"` findings with baseline/candidate/diff attachments when the diff ratio exceeds `maxDiffRatio`.
34
+ - Visual diffs are **informational**, never gating. A diff does not fail the review — it surfaces to the human final-gate reviewer.
35
+ - Route empty-set / "all" handling preserves v0.3 behavior:
36
+ - `{{affected_routes}}` is `[]` → `verdict: "pass"`, summary `"No renderable routes affected, skipping axe."`, do NOT start the preview server.
37
+ - `{{affected_routes}}` is `"all"` → crawl up to 20 pages reachable from `/` via same-origin link discovery (v0.2 fallback).
38
+ - otherwise → visit exactly the URLs listed.
24
39
 
25
40
  ## Playwright cache
26
41
 
27
- The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playwright` before you are invoked. Playwright resolves chromium from this shared cache, so `npm ci` in the worktree does NOT re-download ~300 MB of browser binaries. If the browser is missing, return `verdict: "escalate"` with a synthetic finding: `"Playwright chromium not installed. Run 'npx playwright install chromium' on this machine."`
42
+ The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playwright` before you are invoked. If the browser is missing, return `verdict: "escalate"` with a synthetic finding: `"Playwright chromium not installed. Run 'npx playwright install chromium' on this machine."`
28
43
 
29
44
  ## Runtime procedure
30
45
 
@@ -36,7 +51,7 @@ The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playw
36
51
  git worktree add "$TMPDIR" {{branch}}
37
52
  ```
38
53
 
39
- 3. For this repo, UI lives in `site/`. Install dependencies and start the dev server:
54
+ 3. For this repo, UI lives in `site/` (or another directory if ui-paths.json scopes it elsewhere). Install dependencies and start the dev server:
40
55
  ```bash
41
56
  cd "$TMPDIR/site"
42
57
  npm ci
@@ -47,47 +62,77 @@ The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playw
47
62
  4. Wait up to 30s for `http://localhost:{{preview_port}}/` to respond 200. If the server fails to start in 30s, kill it and return verdict `escalate`.
48
63
 
49
64
  5. Determine the site base path:
50
- 1. Check `<repoRoot>/.cloverleaf/config/astro-base.json`. Expected shape: `{ "base": "<path>" }`. If present, use the `base` field verbatim and skip to step 6. (Consumer override — checked before parsing astro config.)
51
- 2. Otherwise, attempt to locate and parse an astro config file (common locations: `site/astro.config.mjs`, `astro.config.mjs` at repo root, `apps/web/astro.config.mjs`). This is best-effort; the v0.3 behavior is preserved. Consumers with non-conventional layouts should supply `astro-base.json` rather than relying on parse.
65
+ 1. Check `{{repo_root}}/.cloverleaf/config/astro-base.json`. Expected shape: `{ "base": "<path>" }`. If present, use the `base` field verbatim and skip to step 6. (Consumer override — checked before parsing astro config.)
66
+ 2. Otherwise, attempt to locate and parse an astro config file (common locations: `site/astro.config.mjs`, `astro.config.mjs` at repo root, `apps/web/astro.config.mjs`). Best-effort fallback.
52
67
  3. If both fail, treat base as empty string.
53
68
 
54
- 6. For each route in `{{affected_routes}}` (or the crawl set, if `"all"`):
55
- - Construct URL `http://localhost:{{preview_port}}<base><route>`.
56
- - Navigate. If 404, retry at `http://localhost:{{preview_port}}<route>` (without base).
57
- - Inject and run axe-core:
58
- ```javascript
59
- import axe from 'axe-core';
60
- const results = await axe.run(document);
61
- ```
62
- - Collect violations.
63
-
64
- 7. Map violations to findings:
69
+ 6. **Visual-diff pass (when `visualDiff.enabled` is true):**
70
+ For each route in `{{affected_routes}}` (or the crawl set) × each viewport in `{{ui_review_config}}.viewports`:
71
+ - Set Playwright viewport to `{ width, height }` from the config.
72
+ - Apply mask CSS — inject a style that sets `visibility: hidden` on any selector in `visualDiff.mask`.
73
+ - Navigate to `http://localhost:{{preview_port}}<base><route>`. If 404, retry without the base.
74
+ - `page.screenshot({ fullPage: false })` → candidate PNG buffer.
75
+ - Compute slug for the route (lowercase, strip leading/trailing slashes, replace slashes with hyphens; `/` → `index`).
76
+ - Note: use `{{repo_root}}` (the absolute main-repo path), NOT `$TMPDIR` or the worktree. See the "Paths" section.
77
+ - Call `compareVisual` (from `lib/visual-diff.ts`) with:
78
+ - `baselinePath = {{repo_root}}/.cloverleaf/baselines/{slug}-{viewport}.png`
79
+ - `candidateBuf = <candidate PNG>`
80
+ - `diffPath = {{repo_root}}/.cloverleaf/runs/{taskId}/ui-review/diff-{slug}-{viewport}.png`
81
+ - `candidateOutPath = {{repo_root}}/.cloverleaf/runs/{taskId}/ui-review/candidate-{slug}-{viewport}.png`
82
+ - `threshold = visualDiff.threshold`
83
+ - `maxDiffRatio = visualDiff.maxDiffRatio`
84
+ - Map result to a finding:
85
+ - `new-baseline` → `severity: "info"`, `rule: "visual-diff"`, `message: "new baseline established for {route} @ {viewport}"`, `metadata: { route, viewport, status: "new-baseline" }`. No attachments.
86
+ - `dimension-mismatch` → `severity: "info"`, `rule: "visual-diff"`, `message: "baseline dimensions changed for {route} @ {viewport}; regenerated"`, `metadata: { route, viewport, status: "dimension-mismatch" }`.
87
+ - `diff` → `severity: "info"`, `rule: "visual-diff"`, `message: "visual diff: {route} @ {viewport} — {diffRatio*100}% pixels differ"`, `metadata: { route, viewport, diffRatio, status: "diff" }`, `attachments: [baseline, candidate, diff]`.
88
+ - `match` → no finding emitted.
89
+
90
+ 7. **Axe pass:**
91
+ For each viewport in `{{ui_review_config}}.axe.viewports`:
92
+ - Set Playwright viewport to `{ width, height }`.
93
+ - For each route in `{{affected_routes}}` (or crawl set):
94
+ - Navigate.
95
+ - Inject and run axe-core:
96
+ ```javascript
97
+ import axe from 'axe-core';
98
+ const results = await axe.run(document);
99
+ ```
100
+ - Collect each violation as a raw tuple: `{ viewport, ruleId, target, impact, message, helpUrl }` (from `axe.run` output).
101
+
102
+ 8. Dedupe raw axe findings via `dedupeAxeFindings(raws, {{ui_review_config}}.axe.dedupeBy, {{ui_review_config}}.axe.ignored)` (from `lib/axe-dedupe.ts`). The `ignored` parameter drops any finding whose `(ruleId, target)` exactly matches an allowlist entry BEFORE dedupe/grouping. Emit the returned `Finding[]`.
103
+
104
+ 9. Severity mapping (preserved from v0.3 via `dedupeAxeFindings`):
65
105
  - axe `impact: "critical"` → `severity: "blocker"`
66
106
  - axe `impact: "serious"` → `severity: "error"`
67
107
  - axe `impact: "moderate"` → `severity: "warning"`
68
108
  - axe `impact: "minor"` → `severity: "info"`
69
109
 
70
- 8. Compute verdict:
71
- - `pass` — zero findings with severity `blocker` or `error`
72
- - `bounce` — ≥1 finding with severity `blocker` or `error`
73
- - `escalate` — preview server failed to start, OR axe threw ≥3 consecutive times, OR Playwright chromium missing.
110
+ 10. Compute verdict (visual-diff findings are **never** considered for gating):
111
+ - `pass` — zero non-visual-diff findings with severity `blocker` or `error`
112
+ - `bounce` — ≥1 non-visual-diff finding with severity `blocker` or `error`
113
+ - `escalate` — preview server failed to start, OR axe threw ≥3 consecutive times, OR Playwright chromium missing.
74
114
 
75
- 9. Teardown:
76
- ```bash
77
- kill $SERVER_PID 2>/dev/null || true
78
- cd {{repo_root}}
79
- git worktree remove --force "$TMPDIR"
80
- ```
115
+ 11. Teardown:
116
+ ```bash
117
+ kill $SERVER_PID 2>/dev/null || true
118
+ cd {{repo_root}}
119
+ git worktree remove --force "$TMPDIR"
120
+ ```
81
121
 
82
122
  ## Tool constraints
83
123
 
84
- - Read-only: do NOT edit source files.
124
+ - Read-only for source files and tests.
125
+ - You MAY write under `{{repo_root}}/.cloverleaf/baselines/` and `{{repo_root}}/.cloverleaf/runs/{taskId}/ui-review/` on the feature branch — these are the baselines and artifacts.
85
126
  - Use `git worktree`: do NOT `git checkout` in the main working directory.
86
127
  - Always teardown the server and worktree, even on error.
87
128
 
88
129
  ## Output
89
130
 
90
- Respond with exactly one JSON object and nothing else. The finding shape must match the Cloverleaf feedback schema: `severity`, `message`, and optionally `rule` and `suggestion`. The `location` field is defined by the schema as an OBJECT with `{file, line?, work_item_id?}` — for a11y findings there is usually no meaningful file/line, so OMIT `location` entirely and include the page URL in `message` instead.
131
+ Respond with exactly one JSON object and nothing else. Finding shape must match the Cloverleaf 0.4.0 feedback schema:
132
+ - required: `severity`, `message`
133
+ - optional: `rule`, `suggestion`, `location`, `attachments`, `metadata`
134
+
135
+ For a11y findings there is usually no meaningful file/line, so OMIT `location` entirely.
91
136
 
92
137
  ```json
93
138
  {
@@ -96,11 +141,17 @@ Respond with exactly one JSON object and nothing else. The finding shape must ma
96
141
  "findings": [
97
142
  {
98
143
  "severity": "blocker" | "error" | "warning" | "info",
99
- "rule": "a11y.<rule-id>",
100
- "message": "<rule description include the page URL (e.g., 'at /guide/') in the message>"
144
+ "rule": "a11y.<rule-id>" | "visual-diff",
145
+ "message": "<description; include the page URL for a11y, route+viewport+diff for visual-diff>",
146
+ "metadata": { /* per §7/§8 above */ },
147
+ "attachments": [ /* for visual-diff with status="diff" */
148
+ { "label": "baseline", "path": ".cloverleaf/baselines/{slug}-{viewport}.png" },
149
+ { "label": "candidate", "path": ".cloverleaf/runs/{taskId}/ui-review/candidate-{slug}-{viewport}.png" },
150
+ { "label": "diff", "path": ".cloverleaf/runs/{taskId}/ui-review/diff-{slug}-{viewport}.png" }
151
+ ]
101
152
  }
102
153
  ]
103
154
  }
104
155
  ```
105
156
 
106
- If verdict is `pass`, `findings` may be empty or include only `warning`/`info`-level findings. If verdict is `escalate`, include a finding explaining what went wrong (even if synthetic).
157
+ If verdict is `pass`, `findings` may be empty or include only `warning`/`info`-level findings. If verdict is `escalate`, include a finding explaining what went wrong.