Skip to content
36 changes: 33 additions & 3 deletions .claude/agents/calibration/converter.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,25 @@ Read and follow `.claude/skills/design-to-code/PROMPT.md` for all code generatio
```
This saves `figma.png`, `code.png`, and `diff.png` into the run directory.
Replace `:` with `-` in the nodeId for the URL.
5. Use similarity to determine overall difficulty (thresholds defined in `src/agents/orchestrator.ts` → `SIMILARITY_DIFFICULTY_THRESHOLDS`):
5. **Responsive comparison** (if expanded screenshot exists):

Look for `screenshot-*.png` in the fixture directory. Sort by width (number in filename).
If there are 2+ screenshots, the smallest is the original and the largest is the expanded viewport.

```bash
# Find expanded screenshot
ls <fixture-path>/screenshot-*.png | sort -t- -k2 -n
# Run responsive visual-compare with --figma-screenshot and --width
npx canicode visual-compare $RUN_DIR/output.html \
--figma-url "https://www.figma.com/design/<fileKey>/file?node-id=<rootNodeId>" \
--figma-screenshot <fixture-path>/screenshot-<largest>.png \
--width <largest-width> \
--output $RUN_DIR/responsive
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Record `responsiveSimilarity` from the result and calculate `responsiveDelta = similarity - responsiveSimilarity`.
If only 1 screenshot exists, skip responsive comparison and set both to `null`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
6. Use similarity to determine overall difficulty (thresholds defined in `src/agents/orchestrator.ts` → `SIMILARITY_DIFFICULTY_THRESHOLDS`):

| Similarity | Difficulty |
|-----------|-----------|
Expand All @@ -69,11 +87,16 @@ Read and follow `.claude/skills/design-to-code/PROMPT.md` for all code generatio
| 50-69% | hard |
| <50% | failed |

6. **MANDATORY — Rule Impact Assessment**: For EVERY rule ID in `nodeIssueSummaries[].flaggedRuleIds`, assess its actual impact on conversion. Read the analysis JSON, collect all unique `flaggedRuleIds`, and for each one write an entry in `ruleImpactAssessment`. This array MUST NOT be empty if there are flagged rules.
7. **MANDATORY — Rule Impact Assessment**: For EVERY rule ID in `nodeIssueSummaries[].flaggedRuleIds`, assess its actual impact on conversion. Read the analysis JSON, collect all unique `flaggedRuleIds`, and for each one write an entry in `ruleImpactAssessment`. This array MUST NOT be empty if there are flagged rules.
- Did this rule's issue actually make the conversion harder?
- What was its real impact on the final similarity score?
- Rate as: `easy` (no real difficulty), `moderate` (some guessing needed), `hard` (significant pixel loss), `failed` (could not reproduce)
7. Note any difficulties NOT covered by existing rules as `uncoveredStruggles`
8. **Code metrics**: After writing `output.html`, record these in conversion.json:
- `htmlBytes`: file size in bytes
- `htmlLines`: line count
- `cssClassCount`: unique CSS class selectors in `<style>` block
- `cssVariableCount`: unique CSS custom properties (`--*:`) in `<style>` block
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
9. Note any difficulties NOT covered by existing rules as `uncoveredStruggles`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that code metrics and responsive fields (except responsiveDelta) are not consumed

rg -n --type=ts -C3 'htmlBytes|htmlLines|cssClassCount|cssVariableCount|responsiveSimilarity|responsiveViewport' \
  -g '!*.test.ts' \
  -g '!*.md'

Repository: let-sunny/canicode

Length of output: 5457


🏁 Script executed:

# Check orchestrator.ts at the referenced lines to see what fields are extracted
find . -name "orchestrator.ts" -type f | head -5

Repository: let-sunny/canicode

Length of output: 90


🏁 Script executed:

# Also search for calibration converter implementation and how it's used
rg -l "calibration.*converter" --type=ts -g '!*.test.ts'

Repository: let-sunny/canicode

Length of output: 44


🏁 Script executed:

# Check what the converter agent actually does with these metrics
rg -n "conversion\.json" --type=ts -C2 -g '!*.test.ts' | head -50

Repository: let-sunny/canicode

Length of output: 2245


🏁 Script executed:

# Check orchestrator.ts at the referenced lines
sed -n '300,330p' src/agents/orchestrator.ts

Repository: let-sunny/canicode

Length of output: 866


🏁 Script executed:

# Check calibrate-debate.ts to see what fields it extracts from conversion.json
sed -n '28,80p' src/cli/commands/internal/calibrate-debate.ts

Repository: let-sunny/canicode

Length of output: 2013


🏁 Script executed:

# Also search for where these metrics (htmlBytes, htmlLines, etc.) are used after being read
rg -n "ruleImpactAssessment|uncoveredStruggles|conversion" src/cli/commands/internal/calibrate-debate.ts -A3 -B3

Repository: let-sunny/canicode

Length of output: 1700


🏁 Script executed:

# Read the converter.md file mentioned in the review
cat .claude/agents/calibration/converter.md | head -150

Repository: let-sunny/canicode

Length of output: 6743


🏁 Script executed:

# Search for actual converter agent implementation or execution
rg -l "converter" src/agents --type=ts -g '!*.test.ts' | head -10

Repository: let-sunny/canicode

Length of output: 94


🏁 Script executed:

# Look for where conversion.json is written to understand the schema
rg -n "conversion.json" src --type=ts -B5 -A5 | grep -A20 "writeFileSync.*conversion"

Repository: let-sunny/canicode

Length of output: 1761


🏁 Script executed:

# Verify responsiveSimilarity and responsiveViewport are extracted
rg -n "responsiveSimilarity|responsiveViewport" src/agents/orchestrator.ts -B2 -A2

Repository: let-sunny/canicode

Length of output: 44


🏁 Script executed:

# Double-check evaluation-agent contract for these fields
grep -A30 "responsiveSimilarity\|responsiveViewport" src/agents/contracts/evaluation-agent.ts

Repository: let-sunny/canicode

Length of output: 451


🏁 Script executed:

# Check for all responsive-related field usage in orchestrator
rg -n "responsive" src/agents/orchestrator.ts -i

Repository: let-sunny/canicode

Length of output: 281


🏁 Script executed:

# Check if responsiveViewport appears anywhere in codebase
rg -n "responsiveViewport" src --type=ts

Repository: let-sunny/canicode

Length of output: 44


🏁 Script executed:

# Check the full conversion.json schema comment in converter.md to see all fields
sed -n '114,155p' .claude/agents/calibration/converter.md

Repository: let-sunny/canicode

Length of output: 1380


Remove or use code metrics fields documented in step 8.

The instructions direct the converter agent to collect htmlBytes, htmlLines, cssClassCount, cssVariableCount, responsiveViewport, and responsiveSimilarity (lines 94-99 and the conversion.json schema). However, downstream agents extract only ruleImpactAssessment, uncoveredStruggles, and responsiveDelta from conversion.json. The six metrics above are never consumed and create unnecessary effort for the agent.

Either remove these fields from the documentation or integrate them into the evaluation pipeline if they serve a future purpose.

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 94-94: Ordered list item prefix
Expected: 9; Actual: 8; Style: 1/2/3

(MD029, ol-prefix)


[warning] 99-99: Ordered list item prefix
Expected: 10; Actual: 9; Style: 1/2/3

(MD029, ol-prefix)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/agents/calibration/converter.md around lines 94 - 99, The documented
code-metrics fields (htmlBytes, htmlLines, cssClassCount, cssVariableCount,
responsiveViewport, responsiveSimilarity) in step 8 and the conversion.json
schema are unused downstream (downstream agents only read ruleImpactAssessment,
uncoveredStruggles, responsiveDelta); either remove these six fields from the
converter.md step 8 and the conversion.json schema so the converter agent stops
collecting them, or update the evaluation pipeline/downstream extraction to
consume them (i.e., add mappings from
htmlBytes/htmlLines/cssClassCount/cssVariableCount/responsiveViewport/responsiveSimilarity
into the downstream outputs or ruleImpactAssessment/responsiveDelta), and ensure
conversion.json, step 8 text, and any schema references (conversion.json,
ruleImpactAssessment, uncoveredStruggles, responsiveDelta) are kept consistent.

- **Only include design-related issues** — problems in the Figma file structure, missing tokens, ambiguous layout, etc.
- **Exclude environment/tooling issues** — font CDN availability, screenshot DPI/retina scaling, browser rendering quirks, network issues, CI limitations. These are not design problems and create noise in rule discovery.

Expand All @@ -88,6 +111,13 @@ Write results to `$RUN_DIR/conversion.json`.
"rootNodeId": "562:9069",
"generatedCode": "// The full HTML page",
"similarity": 87,
"responsiveSimilarity": 72,
"responsiveDelta": 15,
"responsiveViewport": 1920,
"htmlBytes": 42000,
"htmlLines": 850,
"cssClassCount": 45,
"cssVariableCount": 12,
"difficulty": "moderate",
"notes": "Summary of the conversion experience",
"ruleImpactAssessment": [
Expand Down
7 changes: 6 additions & 1 deletion .claude/commands/calibrate-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,14 @@ ls $RUN_DIR/conversion.json $RUN_DIR/output.html

If `conversion.json` is missing, write it yourself from the Converter's returned summary.

**Record token usage**: The subagent result includes `total_tokens`, `tool_uses`, `duration_ms` in usage metadata. Read `conversion.json`, add these fields, and write back:
- `converterTokens`: total tokens consumed by the Converter subagent
- `converterToolUses`: number of tool calls
- `converterDurationMs`: execution time in milliseconds

Append to `$RUN_DIR/activity.jsonl`:
```json
{"step":"Converter","timestamp":"<ISO8601>","result":"similarity=<N>% difficulty=<level>","durationMs":<ms>}
{"step":"Converter","timestamp":"<ISO8601>","result":"similarity=<N>% difficulty=<level> tokens=<N>","durationMs":<ms>}
```

### Step 3 — Gap Analysis
Expand Down
23 changes: 4 additions & 19 deletions src/agents/ablation/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { resolve, join } from "node:path";
import Anthropic from "@anthropic-ai/sdk";

import { renderCodeScreenshot } from "../../core/engine/visual-compare.js";
import { compareScreenshots } from "../../core/engine/visual-compare-helpers.js";
import { compareScreenshots, inferExportScale } from "../../core/engine/visual-compare-helpers.js";

// --- Configuration ---

Expand Down Expand Up @@ -94,21 +94,9 @@ export function getResponseText(response: Anthropic.Message): string {
.join("\n");
}

// --- CSS metrics ---
// --- CSS metrics (re-export from core) ---

export function countCssClasses(html: string): number {
const styleMatch = html.match(/<style[\s\S]*?<\/style>/i);
if (!styleMatch) return 0;
const classes = styleMatch[0].match(/\.[a-zA-Z][\w-]*\s*[{,:]/g);
return new Set(classes?.map((c) => c.replace(/\s*[{,:]$/, ""))).size;
}

export function countCssVariables(html: string): number {
const styleMatch = html.match(/<style[\s\S]*?<\/style>/i);
if (!styleMatch) return 0;
const vars = styleMatch[0].match(/--[\w-]+\s*:/g);
return new Set(vars?.map((v) => v.replace(/\s*:$/, ""))).size;
}
export { countCssClasses, countCssVariables } from "../../core/engine/visual-compare-helpers.js";

// --- File operations ---

Expand Down Expand Up @@ -162,10 +150,7 @@ export async function renderAndCompare(
const { PNG } = await import("pngjs");
const figmaImage = PNG.sync.read(readFileSync(figmaScreenshotPath));
const figmaWidth = figmaImage.width;
// Figma save-fixture exports at @2x by default. 1920/768 condition screenshots are @1x.
// Detect: if width matches a known @1x size (1920, 768), use scale 1. Otherwise @2x.
const KNOWN_1X_WIDTHS = [1920, 768];
const exportScale = KNOWN_1X_WIDTHS.includes(figmaWidth) ? 1 : 2;
const exportScale = inferExportScale(figmaWidth);
const logicalW = Math.max(1, Math.round(figmaWidth / exportScale));
const logicalH = Math.max(1, Math.round(figmaImage.height / exportScale));

Expand Down
6 changes: 6 additions & 0 deletions src/agents/contracts/evaluation-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export interface EvaluationAgentInput {
}>;
}>;
ruleScores: Record<string, { score: number; severity: string }>;
/**
* Responsive viewport comparison delta (similarity - responsiveSimilarity).
* Positive = design breaks at expanded viewport. Used to evaluate responsive-critical rules.
* null/undefined = no responsive comparison available.
*/
responsiveDelta?: number | null | undefined;
}

export interface EvaluationAgentOutput {
Expand Down
124 changes: 124 additions & 0 deletions src/agents/evaluation-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,130 @@ describe("runEvaluationAgent", () => {
expect(result.validatedRules).toContain("rule-a");
});

it("overrides responsive-critical rule from validated to underscored when responsiveDelta is high", () => {
const input: EvaluationAgentInput = {
nodeIssueSummaries: [
{ nodeId: "node-1", nodePath: "Page > Frame", flaggedRuleIds: ["fixed-size-in-auto-layout"] },
],
conversionRecords: [
{
nodeId: "node-1",
nodePath: "Page > Frame",
difficulty: "easy",
ruleRelatedStruggles: [
{ ruleId: "fixed-size-in-auto-layout", description: "Looked fine", actualImpact: "easy" },
],
uncoveredStruggles: [],
},
],
ruleScores: {
"fixed-size-in-auto-layout": { score: -6, severity: "risk" },
},
responsiveDelta: 25,
};

const result = runEvaluationAgent(input);

const match = result.mismatches.find(m => m.ruleId === "fixed-size-in-auto-layout");
expect(match).toBeDefined();
// AI said "easy" but responsiveDelta=25 → hard → score -6 is underscored (expected -8 to -12)
expect(match!.type).toBe("underscored");
expect(match!.actualDifficulty).toBe("hard");
expect(match!.reasoning).toContain("responsive");
// Must NOT be in validatedRules (was validated before override, removed after)
expect(result.validatedRules).not.toContain("fixed-size-in-auto-layout");
});

it("keeps responsive-critical rule validated when responsiveDelta is low", () => {
const input: EvaluationAgentInput = {
nodeIssueSummaries: [
{ nodeId: "node-1", nodePath: "Page > Frame", flaggedRuleIds: ["missing-size-constraint"] },
],
conversionRecords: [
{
nodeId: "node-1",
nodePath: "Page > Frame",
difficulty: "easy",
ruleRelatedStruggles: [
{ ruleId: "missing-size-constraint", description: "Fine", actualImpact: "easy" },
],
uncoveredStruggles: [],
},
],
ruleScores: {
"missing-size-constraint": { score: -2, severity: "suggestion" },
},
responsiveDelta: 3,
};

const result = runEvaluationAgent(input);

const match = result.mismatches.find(m => m.ruleId === "missing-size-constraint");
expect(match).toBeDefined();
expect(match!.type).toBe("validated");
expect(match!.actualDifficulty).toBe("easy");
expect(result.validatedRules).toContain("missing-size-constraint");
});

it("does not override non-responsive-critical rules even with high responsiveDelta", () => {
const input: EvaluationAgentInput = {
nodeIssueSummaries: [
{ nodeId: "node-1", nodePath: "Page > Frame", flaggedRuleIds: ["raw-value"] },
],
conversionRecords: [
{
nodeId: "node-1",
nodePath: "Page > Frame",
difficulty: "easy",
ruleRelatedStruggles: [
{ ruleId: "raw-value", description: "Easy", actualImpact: "easy" },
],
uncoveredStruggles: [],
},
],
ruleScores: {
"raw-value": { score: -3, severity: "missing-info" },
},
responsiveDelta: 30,
};

const result = runEvaluationAgent(input);

const match = result.mismatches.find(m => m.ruleId === "raw-value");
expect(match).toBeDefined();
// raw-value is token-management, not responsive-critical — no override
expect(match!.type).toBe("validated");
});

it("treats negative responsiveDelta as easy", () => {
const input: EvaluationAgentInput = {
nodeIssueSummaries: [
{ nodeId: "node-1", nodePath: "Page > Frame", flaggedRuleIds: ["fixed-size-in-auto-layout"] },
],
conversionRecords: [
{
nodeId: "node-1",
nodePath: "Page > Frame",
difficulty: "easy",
ruleRelatedStruggles: [
{ ruleId: "fixed-size-in-auto-layout", description: "Fine", actualImpact: "easy" },
],
uncoveredStruggles: [],
},
],
ruleScores: {
"fixed-size-in-auto-layout": { score: -2, severity: "suggestion" },
},
responsiveDelta: -5,
};

const result = runEvaluationAgent(input);

const match = result.mismatches.find(m => m.ruleId === "fixed-size-in-auto-layout");
expect(match).toBeDefined();
expect(match!.actualDifficulty).toBe("easy");
});

it("returns empty mismatches and validatedRules for empty input", () => {
const input: EvaluationAgentInput = {
nodeIssueSummaries: [],
Expand Down
41 changes: 41 additions & 0 deletions src/agents/evaluation-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type {
} from "./contracts/evaluation-agent.js";
import type { Difficulty } from "./contracts/conversion-agent.js";
import type { Severity } from "../core/contracts/severity.js";
import type { RuleId } from "../core/contracts/rule.js";
import { RULE_ID_CATEGORY } from "../core/rules/rule-config.js";

/**
* Difficulty-to-score range mapping.
Expand Down Expand Up @@ -167,8 +169,47 @@ export function runEvaluationAgent(
}
}

// Override responsive-critical rule evaluations with measured responsiveDelta
if (input.responsiveDelta != null) {
const responsiveDifficulty = responsiveDeltaToDifficulty(input.responsiveDelta);
for (const mismatch of mismatches) {
if (!mismatch.ruleId) continue;
if (!(mismatch.ruleId in RULE_ID_CATEGORY)) continue;
const category = RULE_ID_CATEGORY[mismatch.ruleId as RuleId];
if (category !== "responsive-critical") continue;

const prevType = mismatch.type;
const newType = classifyFlaggedRule(mismatch.currentScore ?? 0, responsiveDifficulty);
mismatch.type = newType;
mismatch.actualDifficulty = responsiveDifficulty;
mismatch.reasoning = buildReasoning(newType, mismatch.ruleId, mismatch.currentScore, responsiveDifficulty)
+ ` (responsive: delta=${input.responsiveDelta}%p, overrides AI opinion "${prevType}")`;

if (newType === "validated") {
validatedRuleSet.add(mismatch.ruleId);
} else {
validatedRuleSet.delete(mismatch.ruleId);
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {
mismatches,
validatedRules: [...validatedRuleSet],
};
}

/**
* Map responsiveDelta to difficulty.
* Based on ablation Experiment 04: structure drops -32%p at different viewport.
* Higher delta = more responsive breakage = harder to implement.
*/
function responsiveDeltaToDifficulty(delta: number): Difficulty {
// Negative delta = expanded viewport matches better than original (unusual).
// Treat as easy — the design is not breaking at wider viewport.
const d = Math.max(0, delta);
if (d <= 5) return "easy"; // minimal responsive breakage
if (d <= 15) return "moderate"; // noticeable breakage
if (d <= 30) return "hard"; // severe breakage
return "failed"; // completely broken at expanded viewport
}
6 changes: 6 additions & 0 deletions src/agents/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ export function runCalibrationEvaluate(
conversionRecords = [];
}

// Extract responsive comparison data if available
const responsiveDelta = typeof conversionJson["responsiveDelta"] === "number"
? conversionJson["responsiveDelta"] as number
: null;

const evaluationOutput = runEvaluationAgent({
nodeIssueSummaries: analysisJson.nodeIssueSummaries.map((s) => ({
nodeId: s.nodeId,
Expand All @@ -312,6 +317,7 @@ export function runCalibrationEvaluate(
})),
conversionRecords,
ruleScores,
responsiveDelta,
});

// Load prior evidence if collecting
Expand Down
Loading
Loading