Skip to content
34 changes: 32 additions & 2 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 @@ -73,7 +91,12 @@ Read and follow `.claude/skills/design-to-code/PROMPT.md` for all code generatio
- 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`
7. **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
8. Note any difficulties NOT covered by existing rules as `uncoveredStruggles`
- **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
16 changes: 2 additions & 14 deletions src/agents/ablation/helpers.ts
Original file line number Diff line number Diff line change
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
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
22 changes: 13 additions & 9 deletions src/cli/commands/visual-compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getFigmaToken } from "../../core/engine/config-store.js";

const VisualCompareOptionsSchema = z.object({
figmaUrl: z.string().optional(),
figmaScreenshot: z.string().optional(),
token: z.string().optional(),
output: z.string().optional(),
width: z.union([z.string(), z.number()]).optional(),
Expand All @@ -21,7 +22,8 @@ export function registerVisualCompare(cli: CAC): void {
"visual-compare <codePath>",
"Compare rendered code against Figma screenshot (pixel-level similarity)"
)
.option("--figma-url <url>", "Figma URL with node-id (required)")
.option("--figma-url <url>", "Figma URL with node-id (required for API fetch)")
.option("--figma-screenshot <path>", "Local Figma screenshot file (skips API fetch)")
.option("--token <token>", "Figma API token (or use FIGMA_TOKEN env var)")
.option("--output <dir>", "Output directory for screenshots and diff (default: /tmp/canicode-visual-compare)")
.option("--width <px>", "Logical viewport width in CSS px (default: infer from Figma PNG ÷ export scale)")
Expand All @@ -38,20 +40,21 @@ export function registerVisualCompare(cli: CAC): void {
}
const options = parseResult.data;

if (!options.figmaUrl) {
console.error("Error: --figma-url is required");
if (!options.figmaUrl && !options.figmaScreenshot) {
console.error("Error: --figma-url or --figma-screenshot is required");
process.exitCode = 1; return;
}

// Warn if --figma-url has no node-id
if (!parseFigmaUrl(options.figmaUrl).nodeId) {
// When using --figma-screenshot, --figma-url is still needed for URL parsing
// but token is not required (no API fetch)
if (options.figmaUrl && !parseFigmaUrl(options.figmaUrl).nodeId) {
console.warn("Warning: --figma-url has no node-id. Results may be inaccurate for full files.");
console.warn("Tip: Add ?node-id=XXX to target a specific section.\n");
}

const token = options.token ?? getFigmaToken();
if (!token) {
console.error("Error: Figma token required. Use --token or set FIGMA_TOKEN env var.");
if (!token && !options.figmaScreenshot) {
console.error("Error: Figma token required. Use --token or set FIGMA_TOKEN env var (or use --figma-screenshot for local files).");
process.exitCode = 1; return;
}

Expand Down Expand Up @@ -82,11 +85,12 @@ export function registerVisualCompare(cli: CAC): void {
// Progress to stderr so stdout contains only valid JSON
console.error("Comparing...");
const result = await visualCompare({
figmaUrl: options.figmaUrl,
figmaToken: token,
figmaUrl: options.figmaUrl ?? "https://www.figma.com/design/local/file?node-id=0-0",
figmaToken: token ?? "",
codePath: resolve(codePath),
outputDir: options.output,
...(exportScale !== undefined ? { figmaExportScale: exportScale } : {}),
...(options.figmaScreenshot ? { figmaScreenshotPath: resolve(options.figmaScreenshot) } : {}),
...(hasViewportOverride
? {
viewport: {
Expand Down
Loading
Loading