canicode 0.8.0 → 0.8.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.
@@ -1591,765 +1591,310 @@ function renderGaugeSvg(pct, size, strokeW, grade) {
1591
1591
  </svg>`;
1592
1592
  }
1593
1593
 
1594
- // src/core/report-html/index.ts
1595
- function generateHtmlReport(file, result, scores, options) {
1596
- const screenshotMap = new Map(
1597
- (options?.nodeScreenshots ?? []).map((ns) => [ns.nodeId, ns])
1598
- );
1599
- const figmaToken = options?.figmaToken;
1600
- const quickWins = getQuickWins(result.issues, 5);
1601
- const issuesByCategory = groupIssuesByCategory(result.issues);
1602
- return `<!DOCTYPE html>
1603
- <html lang="en" class="antialiased">
1604
- <head>
1605
- <meta charset="UTF-8">
1606
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1607
- <title>CanICode Report \u2014 ${esc(file.name)}</title>
1608
- <script src="https://cdn.tailwindcss.com"></script>
1609
- <link rel="preconnect" href="https://fonts.googleapis.com">
1610
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1611
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
1612
- <script>
1613
- tailwind.config = {
1614
- theme: {
1615
- extend: {
1616
- fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'] },
1617
- colors: {
1618
- border: 'hsl(240 5.9% 90%)',
1619
- ring: 'hsl(240 5.9% 10%)',
1620
- background: 'hsl(0 0% 100%)',
1621
- foreground: 'hsl(240 10% 3.9%)',
1622
- muted: { DEFAULT: 'hsl(240 4.8% 95.9%)', foreground: 'hsl(240 3.8% 46.1%)' },
1623
- card: { DEFAULT: 'hsl(0 0% 100%)', foreground: 'hsl(240 10% 3.9%)' },
1624
- },
1625
- borderRadius: { lg: '0.5rem', md: 'calc(0.5rem - 2px)', sm: 'calc(0.5rem - 4px)' },
1626
- }
1627
- }
1628
- }
1629
- </script>
1630
- <style>
1631
- details summary::-webkit-details-marker { display: none; }
1632
- details summary::marker { content: ""; }
1633
- details summary { list-style: none; }
1634
- .gauge-fill { transition: stroke-dashoffset 0.8s cubic-bezier(0.4,0,0.2,1); }
1635
- @media print {
1636
- .no-print { display: none !important; }
1637
- .topbar-print { position: static !important; background: white !important; color: hsl(240 10% 3.9%) !important; }
1638
- }
1639
- </style>
1640
- </head>
1641
- <body class="bg-muted font-sans text-foreground min-h-screen">
1642
-
1643
- <!-- Top Bar -->
1644
- <header class="topbar-print sticky top-0 z-50 bg-zinc-950 text-white border-b border-zinc-800">
1645
- <div class="max-w-[960px] mx-auto px-6 py-3 flex items-center gap-4">
1646
- <span class="font-semibold text-sm tracking-tight">CanICode</span>
1647
- <span class="text-zinc-400 text-sm truncate">${esc(file.name)}</span>
1648
- <span class="ml-auto text-zinc-500 text-xs no-print">${(/* @__PURE__ */ new Date()).toLocaleDateString()}</span>
1649
- </div>
1650
- </header>
1651
-
1652
- <main class="max-w-[960px] mx-auto px-6 pb-16">
1653
-
1654
- <!-- Overall Score -->
1655
- <section class="flex flex-col items-center pt-12 pb-6">
1656
- ${renderGaugeSvg(scores.overall.percentage, 200, 10, scores.overall.grade)}
1657
- <div class="mt-3 text-center">
1658
- <span class="text-lg font-semibold">${scores.overall.percentage}</span>
1659
- <span class="text-muted-foreground text-sm ml-1">/ 100</span>
1660
- </div>
1661
- <p class="text-muted-foreground text-sm mt-1">Overall Score</p>
1662
- </section>
1663
-
1664
- <!-- Category Gauges -->
1665
- <section class="bg-card border border-border rounded-lg shadow-sm p-6 mb-6">
1666
- <div class="grid grid-cols-3 sm:grid-cols-6 gap-4">
1667
- ${CATEGORIES.map((cat) => {
1668
- const cs = scores.byCategory[cat];
1669
- const desc = CATEGORY_DESCRIPTIONS[cat];
1670
- return ` <a href="#cat-${cat}" class="flex flex-col items-center group relative cursor-pointer no-underline text-foreground hover:opacity-80 transition-opacity">
1671
- ${renderGaugeSvg(cs.percentage, 100, 7)}
1672
- <span class="text-xs font-medium mt-2.5 text-center leading-tight">${CATEGORY_LABELS[cat]}</span>
1673
- <span class="text-[11px] text-muted-foreground">${cs.issueCount} issues</span>
1674
- <div class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 hidden group-hover:block bg-zinc-900 text-white text-xs px-3 py-2 rounded-md whitespace-nowrap z-10 shadow-lg pointer-events-none">
1675
- ${esc(desc)}
1676
- <div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-zinc-900"></div>
1677
- </div>
1678
- </a>`;
1679
- }).join("\n")}
1680
- </div>
1681
- </section>
1682
-
1683
- <!-- Issue Summary -->
1684
- <section class="bg-card border border-border rounded-lg shadow-sm p-4 mb-6">
1685
- <div class="flex flex-wrap items-center justify-center gap-6">
1686
- ${renderSummaryDot("bg-red-500", scores.summary.blocking, "Blocking")}
1687
- ${renderSummaryDot("bg-amber-500", scores.summary.risk, "Risk")}
1688
- ${renderSummaryDot("bg-zinc-400", scores.summary.missingInfo, "Missing Info")}
1689
- ${renderSummaryDot("bg-green-500", scores.summary.suggestion, "Suggestion")}
1690
- <div class="border-l border-border pl-6 flex items-center gap-2">
1691
- <span class="text-xl font-bold tracking-tight">${scores.summary.totalIssues}</span>
1692
- <span class="text-sm text-muted-foreground">Total</span>
1693
- </div>
1694
- </div>
1695
- </section>
1696
-
1697
- ${quickWins.length > 0 ? renderOpportunities(quickWins, file.fileKey) : ""}
1698
-
1699
- <!-- Categories -->
1700
- <div class="space-y-3">
1701
- ${CATEGORIES.map((cat) => renderCategory(cat, scores, issuesByCategory.get(cat) ?? [], file.fileKey, screenshotMap, figmaToken)).join("\n")}
1702
- </div>
1703
-
1704
- <!-- Footer -->
1705
- <footer class="mt-12 pt-6 border-t border-border text-center">
1706
- <p class="text-sm text-muted-foreground">Generated by <span class="font-semibold text-foreground">CanICode</span></p>
1707
- <p class="text-xs text-muted-foreground/60 mt-1">${(/* @__PURE__ */ new Date()).toLocaleString()} \xB7 ${result.nodeCount} nodes \xB7 Max depth ${result.maxDepth}</p>
1708
- </footer>
1709
-
1710
- </main>
1711
-
1712
- ${figmaToken ? ` <script>
1713
- const FIGMA_TOKEN = '${figmaToken}';
1714
- async function postComment(btn) {
1715
- const fileKey = btn.dataset.fileKey;
1716
- const nodeId = btn.dataset.nodeId.replace(/-/g, ':');
1717
- const rule = btn.dataset.rule;
1718
- const message = btn.dataset.message;
1719
- const path = btn.dataset.path;
1720
- const fix = btn.dataset.fix;
1721
- const why = btn.dataset.why;
1722
- const impact = btn.dataset.impact;
1723
-
1724
- const commentBody = '[CanICode] ' + rule + '\\n\\nFix: ' + fix + '\\nWhy: ' + why + '\\nImpact: ' + impact + '\\n\\n' + message + '\\nNode: ' + path;
1725
-
1726
- btn.disabled = true;
1727
- btn.textContent = 'Sending...';
1594
+ // package.json
1595
+ var version = "0.8.1";
1596
+ var AnalysisNodeTypeSchema = z.enum([
1597
+ "DOCUMENT",
1598
+ "CANVAS",
1599
+ "FRAME",
1600
+ "GROUP",
1601
+ "SECTION",
1602
+ "COMPONENT",
1603
+ "COMPONENT_SET",
1604
+ "INSTANCE",
1605
+ "RECTANGLE",
1606
+ "ELLIPSE",
1607
+ "VECTOR",
1608
+ "TEXT",
1609
+ "LINE",
1610
+ "BOOLEAN_OPERATION",
1611
+ "STAR",
1612
+ "REGULAR_POLYGON",
1613
+ "SLICE",
1614
+ "STICKY",
1615
+ "SHAPE_WITH_TEXT",
1616
+ "CONNECTOR",
1617
+ "WIDGET",
1618
+ "EMBED",
1619
+ "LINK_UNFURL",
1620
+ "TABLE",
1621
+ "TABLE_CELL"
1622
+ ]);
1623
+ var LayoutModeSchema = z.enum(["NONE", "HORIZONTAL", "VERTICAL"]);
1624
+ var LayoutAlignSchema = z.enum(["MIN", "CENTER", "MAX", "STRETCH", "INHERIT"]);
1625
+ var LayoutPositioningSchema = z.enum(["AUTO", "ABSOLUTE"]);
1626
+ var BaseAnalysisNodeSchema = z.object({
1627
+ // Basic identification
1628
+ id: z.string(),
1629
+ name: z.string(),
1630
+ type: AnalysisNodeTypeSchema,
1631
+ visible: z.boolean().default(true),
1632
+ // Layout analysis
1633
+ layoutMode: LayoutModeSchema.optional(),
1634
+ layoutAlign: LayoutAlignSchema.optional(),
1635
+ layoutPositioning: LayoutPositioningSchema.optional(),
1636
+ layoutSizingHorizontal: z.enum(["FIXED", "HUG", "FILL"]).optional(),
1637
+ layoutSizingVertical: z.enum(["FIXED", "HUG", "FILL"]).optional(),
1638
+ primaryAxisAlignItems: z.string().optional(),
1639
+ counterAxisAlignItems: z.string().optional(),
1640
+ itemSpacing: z.number().optional(),
1641
+ paddingLeft: z.number().optional(),
1642
+ paddingRight: z.number().optional(),
1643
+ paddingTop: z.number().optional(),
1644
+ paddingBottom: z.number().optional(),
1645
+ // Size/position analysis
1646
+ absoluteBoundingBox: z.object({
1647
+ x: z.number(),
1648
+ y: z.number(),
1649
+ width: z.number(),
1650
+ height: z.number()
1651
+ }).nullable().optional(),
1652
+ // Component analysis
1653
+ componentId: z.string().optional(),
1654
+ componentPropertyDefinitions: z.record(z.string(), z.unknown()).optional(),
1655
+ componentProperties: z.record(z.string(), z.unknown()).optional(),
1656
+ // Style/token analysis
1657
+ styles: z.record(z.string(), z.string()).optional(),
1658
+ fills: z.array(z.unknown()).optional(),
1659
+ strokes: z.array(z.unknown()).optional(),
1660
+ effects: z.array(z.unknown()).optional(),
1661
+ // Variable binding analysis (design tokens)
1662
+ boundVariables: z.record(z.string(), z.unknown()).optional(),
1663
+ // Text analysis
1664
+ characters: z.string().optional(),
1665
+ style: z.record(z.string(), z.unknown()).optional(),
1666
+ // Handoff analysis
1667
+ devStatus: z.object({
1668
+ type: z.enum(["NONE", "READY_FOR_DEV", "COMPLETED"]),
1669
+ description: z.string().optional()
1670
+ }).optional(),
1671
+ // Naming analysis metadata
1672
+ isAsset: z.boolean().optional()
1673
+ });
1674
+ var AnalysisNodeSchema = BaseAnalysisNodeSchema.extend({
1675
+ children: z.lazy(() => AnalysisNodeSchema.array().optional())
1676
+ });
1677
+ z.object({
1678
+ fileKey: z.string(),
1679
+ name: z.string(),
1680
+ lastModified: z.string(),
1681
+ version: z.string(),
1682
+ document: AnalysisNodeSchema,
1683
+ components: z.record(
1684
+ z.string(),
1685
+ z.object({
1686
+ key: z.string(),
1687
+ name: z.string(),
1688
+ description: z.string()
1689
+ })
1690
+ ),
1691
+ styles: z.record(
1692
+ z.string(),
1693
+ z.object({
1694
+ key: z.string(),
1695
+ name: z.string(),
1696
+ styleType: z.string()
1697
+ })
1698
+ )
1699
+ });
1700
+ var IssueSchema = z.object({
1701
+ nodeId: z.string(),
1702
+ nodePath: z.string(),
1703
+ figmaDeepLink: z.string().url(),
1704
+ ruleId: z.string(),
1705
+ message: z.string(),
1706
+ severity: SeveritySchema
1707
+ });
1708
+ var CategoryScoreSchema = z.object({
1709
+ category: CategorySchema,
1710
+ score: z.number().min(0).max(100),
1711
+ maxScore: z.number().min(0).max(100),
1712
+ issueCount: z.object({
1713
+ error: z.number().int().min(0),
1714
+ warning: z.number().int().min(0),
1715
+ info: z.number().int().min(0)
1716
+ })
1717
+ });
1718
+ var ReportMetadataSchema = z.object({
1719
+ fileKey: z.string(),
1720
+ fileName: z.string(),
1721
+ analyzedAt: z.string().datetime(),
1722
+ version: z.string()
1723
+ });
1724
+ z.object({
1725
+ metadata: ReportMetadataSchema,
1726
+ totalScore: z.number().min(0).max(100),
1727
+ categoryScores: z.array(CategoryScoreSchema),
1728
+ issues: z.array(IssueSchema),
1729
+ summary: z.object({
1730
+ totalNodes: z.number().int().min(0),
1731
+ analyzedNodes: z.number().int().min(0),
1732
+ errorCount: z.number().int().min(0),
1733
+ warningCount: z.number().int().min(0),
1734
+ infoCount: z.number().int().min(0)
1735
+ })
1736
+ });
1728
1737
 
1729
- try {
1730
- const res = await fetch('https://api.figma.com/v1/files/' + fileKey + '/comments', {
1731
- method: 'POST',
1732
- headers: { 'X-FIGMA-TOKEN': FIGMA_TOKEN, 'Content-Type': 'application/json' },
1733
- body: JSON.stringify({ message: commentBody, client_meta: { node_id: nodeId, node_offset: { x: 0, y: 0 } } }),
1734
- });
1735
- if (!res.ok) throw new Error(await res.text());
1736
- btn.textContent = 'Sent \\u2713';
1737
- btn.classList.remove('hover:bg-muted');
1738
- btn.classList.add('text-green-600', 'border-green-500/30');
1739
- } catch (e) {
1740
- btn.textContent = 'Failed \\u2717';
1741
- btn.classList.remove('hover:bg-muted');
1742
- btn.classList.add('text-red-600', 'border-red-500/30');
1743
- btn.disabled = false;
1744
- console.error('Failed to post Figma comment:', e);
1745
- }
1746
- }
1747
- </script>` : ""}
1748
- </body>
1749
- </html>`;
1750
- }
1751
- function renderSummaryDot(dotClass, count, label) {
1752
- return `<div class="flex items-center gap-2">
1753
- <span class="w-2.5 h-2.5 rounded-full ${dotClass}"></span>
1754
- <span class="text-lg font-bold tracking-tight">${count}</span>
1755
- <span class="text-sm text-muted-foreground">${label}</span>
1756
- </div>`;
1738
+ // src/core/rules/excluded-names.ts
1739
+ var EXCLUDED_NAME_PATTERN = /(badge|close|dismiss|overlay|float|fab|dot|indicator|corner|decoration|tag|status|notification|icon|ico|image|asset|filter|dim|dimmed|bg|background|logo|avatar|divider|separator|nav|navigation|gnb|header|footer|sidebar|toolbar|modal|dialog|popup|toast|tooltip|dropdown|menu|sticky|spinner|loader|cursor|cta|chatbot|thumb|thumbnail|tabbar|tab-bar|statusbar|status-bar)/i;
1740
+ function isExcludedName(name) {
1741
+ return EXCLUDED_NAME_PATTERN.test(name);
1757
1742
  }
1758
- function renderOpportunities(issues, fileKey) {
1759
- const maxAbs = issues.reduce((m, i) => Math.max(m, Math.abs(i.calculatedScore)), 1);
1760
- return `
1761
- <!-- Opportunities -->
1762
- <section class="bg-card border border-border rounded-lg shadow-sm mb-6 overflow-hidden">
1763
- <div class="px-6 py-4 border-b border-border">
1764
- <h2 class="text-sm font-semibold flex items-center gap-2">
1765
- <span class="w-2 h-2 rounded-full bg-red-500"></span>
1766
- Opportunities
1767
- </h2>
1768
- <p class="text-xs text-muted-foreground mt-1">Top blocking issues \u2014 fix these first for the biggest improvement.</p>
1769
- </div>
1770
- <div class="divide-y divide-border">
1771
- ${issues.map((issue) => {
1772
- const def = issue.rule.definition;
1773
- const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
1774
- const barW = Math.round(Math.abs(issue.calculatedScore) / maxAbs * 100);
1775
- return ` <div class="px-6 py-3 flex items-center gap-4 hover:bg-muted/50 transition-colors">
1776
- <div class="flex-1 min-w-0">
1777
- <div class="text-sm font-medium truncate">${esc(def.name)}</div>
1778
- <div class="text-xs text-muted-foreground truncate mt-0.5">${esc(issue.violation.message)}</div>
1779
- </div>
1780
- <div class="w-32 flex items-center gap-2 shrink-0">
1781
- <div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
1782
- <div class="h-full bg-red-500 rounded-full" style="width:${barW}%"></div>
1783
- </div>
1784
- <span class="text-xs font-medium text-red-600 w-12 text-right">${issue.calculatedScore}</span>
1785
- </div>
1786
- <a href="${link}" target="_blank" rel="noopener" class="text-xs text-muted-foreground hover:text-foreground shrink-0 no-print">Figma \u2192</a>
1787
- </div>`;
1788
- }).join("\n")}
1789
- </div>
1790
- </section>`;
1743
+
1744
+ // src/core/rules/layout/index.ts
1745
+ function isContainerNode(node) {
1746
+ return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
1791
1747
  }
1792
- function renderCategory(cat, scores, issues, fileKey, screenshotMap, figmaToken) {
1793
- const cs = scores.byCategory[cat];
1794
- const hasProblems = issues.some((i) => i.config.severity === "blocking" || i.config.severity === "risk");
1795
- const bySeverity = /* @__PURE__ */ new Map();
1796
- for (const sev of SEVERITY_ORDER) bySeverity.set(sev, []);
1797
- for (const issue of issues) bySeverity.get(issue.config.severity)?.push(issue);
1798
- return `
1799
- <details id="cat-${cat}" class="bg-card border border-border rounded-lg shadow-sm overflow-hidden group"${hasProblems ? " open" : ""}>
1800
- <summary class="px-5 py-3.5 flex items-center gap-3 cursor-pointer hover:bg-muted/50 transition-colors select-none">
1801
- <span class="inline-flex items-center justify-center w-10 h-6 rounded-md text-xs font-bold border ${scoreBadgeStyle(cs.percentage)}">${cs.percentage}</span>
1802
- <div class="flex-1 min-w-0">
1803
- <div class="text-sm font-semibold">${CATEGORY_LABELS[cat]}</div>
1804
- <div class="text-xs text-muted-foreground">${esc(CATEGORY_DESCRIPTIONS[cat])}</div>
1805
- </div>
1806
- <span class="text-xs text-muted-foreground">${cs.issueCount} issues</span>
1807
- <svg class="w-4 h-4 text-muted-foreground transition-transform group-open:rotate-180 shrink-0 no-print" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M19 9l-7 7-7-7"/></svg>
1808
- </summary>
1809
- <div class="border-t border-border">
1810
- ${issues.length === 0 ? ' <div class="px-5 py-4 text-sm text-green-600 font-medium">No issues found</div>' : SEVERITY_ORDER.filter((sev) => (bySeverity.get(sev)?.length ?? 0) > 0).map((sev) => renderSeverityGroup(sev, bySeverity.get(sev) ?? [], fileKey, screenshotMap, figmaToken)).join("\n")}
1811
- </div>
1812
- </details>`;
1748
+ function hasAutoLayout(node) {
1749
+ return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
1813
1750
  }
1814
- function renderSeverityGroup(sev, issues, fileKey, screenshotMap, figmaToken) {
1815
- return ` <div class="px-5 py-3">
1816
- <div class="flex items-center gap-2 mb-2">
1817
- <span class="w-2 h-2 rounded-full ${severityDot(sev)}"></span>
1818
- <span class="text-xs font-semibold uppercase tracking-wider">${SEVERITY_LABELS[sev]}</span>
1819
- <span class="text-xs text-muted-foreground ml-auto">${issues.length}</span>
1820
- </div>
1821
- <div class="space-y-1">
1822
- ${issues.map((issue) => renderIssueRow(issue, fileKey, screenshotMap, figmaToken)).join("\n")}
1823
- </div>
1824
- </div>`;
1751
+ function hasTextContent(node) {
1752
+ return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
1825
1753
  }
1826
- function renderIssueRow(issue, fileKey, screenshotMap, figmaToken) {
1827
- const sev = issue.config.severity;
1828
- const def = issue.rule.definition;
1829
- const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
1830
- const screenshot = screenshotMap.get(issue.violation.nodeId);
1831
- const screenshotHtml = screenshot ? `<div class="mt-3"><a href="${link}" target="_blank" rel="noopener"><img src="data:image/png;base64,${screenshot.screenshotBase64}" alt="${esc(screenshot.nodePath)}" class="max-w-[240px] border border-border rounded-md"></a></div>` : "";
1832
- return ` <details class="border border-border rounded-md overflow-hidden">
1833
- <summary class="flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer hover:bg-muted/50 transition-colors">
1834
- <span class="w-1.5 h-1.5 rounded-full ${severityDot(sev)} shrink-0"></span>
1835
- <span class="font-medium shrink-0">${esc(def.name)}</span>
1836
- <span class="text-muted-foreground truncate text-xs flex-1">${esc(issue.violation.message)}</span>
1837
- <span class="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded border ${severityBadge(sev)} shrink-0">${issue.calculatedScore}</span>
1838
- </summary>
1839
- <div class="px-3 py-3 bg-muted/30 border-t border-border text-sm space-y-2">
1840
- <div class="font-mono text-xs text-muted-foreground break-all">${esc(issue.violation.nodePath)}</div>
1841
- <div class="text-muted-foreground leading-relaxed space-y-1">
1842
- <p><span class="font-medium text-foreground">Why:</span> ${esc(def.why)}</p>
1843
- <p><span class="font-medium text-foreground">Impact:</span> ${esc(def.impact)}</p>
1844
- <p><span class="font-medium text-foreground">Fix:</span> ${esc(def.fix)}</p>
1845
- </div>${screenshotHtml}
1846
- <div class="flex items-center gap-2 mt-1 no-print">
1847
- <a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium border border-border rounded-md hover:bg-muted transition-colors">Open in Figma <span>\u2192</span></a>${figmaToken ? `
1848
- <button onclick="postComment(this)" data-file-key="${esc(fileKey)}" data-node-id="${esc(issue.violation.nodeId)}" data-rule="${esc(def.name)}" data-message="${esc(issue.violation.message)}" data-path="${esc(issue.violation.nodePath)}" data-fix="${esc(def.fix)}" data-why="${esc(def.why)}" data-impact="${esc(def.impact)}" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium border border-border rounded-md hover:bg-muted transition-colors cursor-pointer">Comment on Figma</button>` : ""}
1849
- </div>
1850
- </div>
1851
- </details>`;
1852
- }
1853
- function getQuickWins(issues, limit) {
1854
- return issues.filter((issue) => issue.config.severity === "blocking").sort((a, b) => a.calculatedScore - b.calculatedScore).slice(0, limit);
1855
- }
1856
- function groupIssuesByCategory(issues) {
1857
- const grouped = /* @__PURE__ */ new Map();
1858
- for (const category of CATEGORIES) grouped.set(category, []);
1859
- for (const issue of issues) grouped.get(issue.rule.definition.category).push(issue);
1860
- return grouped;
1861
- }
1862
- var esc = escapeHtml;
1863
- var RuleOverrideSchema = z.object({
1864
- score: z.number().int().max(0).optional(),
1865
- severity: SeveritySchema.optional(),
1866
- enabled: z.boolean().optional()
1867
- });
1868
- var ConfigFileSchema = z.object({
1869
- excludeNodeTypes: z.array(z.string()).optional(),
1870
- excludeNodeNames: z.array(z.string()).optional(),
1871
- gridBase: z.number().int().positive().optional(),
1872
- colorTolerance: z.number().int().positive().optional(),
1873
- rules: z.record(z.string(), RuleOverrideSchema).optional()
1754
+ var noAutoLayoutDef = {
1755
+ id: "no-auto-layout",
1756
+ name: "No Auto Layout",
1757
+ category: "layout",
1758
+ why: "Frames without Auto Layout require manual positioning for every element",
1759
+ impact: "Layout breaks on content changes, harder to maintain and scale",
1760
+ fix: "Apply Auto Layout to the frame with appropriate direction and spacing"
1761
+ };
1762
+ var noAutoLayoutCheck = (node, context) => {
1763
+ if (node.type !== "FRAME") return null;
1764
+ if (hasAutoLayout(node)) return null;
1765
+ if (!node.children || node.children.length === 0) return null;
1766
+ return {
1767
+ ruleId: noAutoLayoutDef.id,
1768
+ nodeId: node.id,
1769
+ nodePath: context.path.join(" > "),
1770
+ message: `Frame "${node.name}" has no Auto Layout`
1771
+ };
1772
+ };
1773
+ defineRule({
1774
+ definition: noAutoLayoutDef,
1775
+ check: noAutoLayoutCheck
1874
1776
  });
1875
- async function loadConfigFile(filePath) {
1876
- const absPath = resolve(filePath);
1877
- const raw = await readFile(absPath, "utf-8");
1878
- const parsed = JSON.parse(raw);
1879
- return ConfigFileSchema.parse(parsed);
1880
- }
1881
- function mergeConfigs(base, overrides) {
1882
- const merged = { ...base };
1883
- if (overrides.gridBase !== void 0) {
1884
- for (const [id, config2] of Object.entries(merged)) {
1885
- if (config2.options && "gridBase" in config2.options) {
1886
- merged[id] = {
1887
- ...config2,
1888
- options: { ...config2.options, gridBase: overrides.gridBase }
1889
- };
1890
- }
1891
- }
1892
- }
1893
- if (overrides.colorTolerance !== void 0) {
1894
- for (const [id, config2] of Object.entries(merged)) {
1895
- if (config2.options && "tolerance" in config2.options) {
1896
- merged[id] = {
1897
- ...config2,
1898
- options: { ...config2.options, tolerance: overrides.colorTolerance }
1899
- };
1900
- }
1901
- }
1902
- }
1903
- if (overrides.rules) {
1904
- for (const [ruleId, override] of Object.entries(overrides.rules)) {
1905
- const existing = merged[ruleId];
1906
- if (existing) {
1907
- merged[ruleId] = {
1908
- ...existing,
1909
- ...override.score !== void 0 && { score: override.score },
1910
- ...override.severity !== void 0 && { severity: override.severity },
1911
- ...override.enabled !== void 0 && { enabled: override.enabled }
1912
- };
1913
- }
1914
- }
1915
- }
1916
- return merged;
1777
+ var absolutePositionInAutoLayoutDef = {
1778
+ id: "absolute-position-in-auto-layout",
1779
+ name: "Absolute Position in Auto Layout",
1780
+ category: "layout",
1781
+ why: "Absolute positioning inside Auto Layout breaks the automatic flow",
1782
+ impact: "Element will not respond to sibling changes, may overlap unexpectedly",
1783
+ fix: "Remove absolute positioning or use proper Auto Layout alignment"
1784
+ };
1785
+ function isSmallRelativeToParent(node, parent) {
1786
+ const nodeBB = node.absoluteBoundingBox;
1787
+ const parentBB = parent.absoluteBoundingBox;
1788
+ if (!nodeBB || !parentBB) return false;
1789
+ if (parentBB.width === 0 || parentBB.height === 0) return false;
1790
+ const widthRatio = nodeBB.width / parentBB.width;
1791
+ const heightRatio = nodeBB.height / parentBB.height;
1792
+ return widthRatio < 0.25 && heightRatio < 0.25;
1917
1793
  }
1918
- var MatchConditionSchema = z.object({
1919
- // Node type conditions
1920
- type: z.array(z.string()).optional(),
1921
- notType: z.array(z.string()).optional(),
1922
- // Name conditions (case-insensitive, substring match)
1923
- nameContains: z.string().optional(),
1924
- nameNotContains: z.string().optional(),
1925
- namePattern: z.string().optional(),
1926
- // Size conditions
1927
- minWidth: z.number().optional(),
1928
- maxWidth: z.number().optional(),
1929
- minHeight: z.number().optional(),
1930
- maxHeight: z.number().optional(),
1931
- // Layout conditions
1932
- hasAutoLayout: z.boolean().optional(),
1933
- hasChildren: z.boolean().optional(),
1934
- minChildren: z.number().optional(),
1935
- maxChildren: z.number().optional(),
1936
- // Component conditions
1937
- isComponent: z.boolean().optional(),
1938
- isInstance: z.boolean().optional(),
1939
- hasComponentId: z.boolean().optional(),
1940
- // Visibility
1941
- isVisible: z.boolean().optional(),
1942
- // Fill/style conditions
1943
- hasFills: z.boolean().optional(),
1944
- hasStrokes: z.boolean().optional(),
1945
- hasEffects: z.boolean().optional(),
1946
- // Depth condition
1947
- minDepth: z.number().optional(),
1948
- maxDepth: z.number().optional()
1949
- });
1950
- var CustomRuleSchema = z.object({
1951
- id: z.string(),
1952
- category: CategorySchema,
1953
- severity: SeveritySchema,
1954
- score: z.number().int().max(0),
1955
- match: MatchConditionSchema,
1956
- message: z.string().optional(),
1957
- why: z.string(),
1958
- impact: z.string(),
1959
- fix: z.string(),
1960
- // Backward compat: silently ignore the old prompt field
1961
- prompt: z.string().optional()
1794
+ var absolutePositionInAutoLayoutCheck = (node, context) => {
1795
+ if (!context.parent) return null;
1796
+ if (!hasAutoLayout(context.parent)) return null;
1797
+ if (node.layoutPositioning !== "ABSOLUTE") return null;
1798
+ if (node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION" || node.type === "LINE" || node.type === "ELLIPSE" || node.type === "STAR" || node.type === "REGULAR_POLYGON") return null;
1799
+ if (isExcludedName(node.name)) return null;
1800
+ if (isSmallRelativeToParent(node, context.parent)) return null;
1801
+ if (context.parent.type === "COMPONENT") return null;
1802
+ return {
1803
+ ruleId: absolutePositionInAutoLayoutDef.id,
1804
+ nodeId: node.id,
1805
+ nodePath: context.path.join(" > "),
1806
+ message: `"${node.name}" uses absolute positioning inside Auto Layout parent "${context.parent.name}". If intentional (badge, overlay, close button), rename to badge-*, overlay-*, close-* to suppress this warning.`
1807
+ };
1808
+ };
1809
+ defineRule({
1810
+ definition: absolutePositionInAutoLayoutDef,
1811
+ check: absolutePositionInAutoLayoutCheck
1962
1812
  });
1963
- var CustomRulesFileSchema = z.array(CustomRuleSchema);
1964
-
1965
- // src/core/rules/custom/custom-rule-loader.ts
1966
- async function loadCustomRules(filePath) {
1967
- const absPath = resolve(filePath);
1968
- const raw = await readFile(absPath, "utf-8");
1969
- const parsed = JSON.parse(raw);
1970
- const customRules = CustomRulesFileSchema.parse(parsed);
1971
- const rules = [];
1972
- const configs = {};
1973
- for (const cr of customRules) {
1974
- if (!cr.match) continue;
1975
- rules.push(toRule(cr));
1976
- configs[cr.id] = {
1977
- severity: cr.severity,
1978
- score: cr.score,
1979
- enabled: true
1980
- };
1813
+ var fixedWidthInResponsiveContextDef = {
1814
+ id: "fixed-width-in-responsive-context",
1815
+ name: "Fixed Width in Responsive Context",
1816
+ category: "layout",
1817
+ why: "Fixed width inside Auto Layout prevents responsive behavior",
1818
+ impact: "Content will not adapt to container size changes",
1819
+ fix: "Use 'Fill' or 'Hug' instead of fixed width"
1820
+ };
1821
+ var fixedWidthInResponsiveContextCheck = (node, context) => {
1822
+ if (!context.parent) return null;
1823
+ if (!hasAutoLayout(context.parent)) return null;
1824
+ if (!isContainerNode(node)) return null;
1825
+ if (node.layoutSizingHorizontal) {
1826
+ if (node.layoutSizingHorizontal !== "FIXED") return null;
1827
+ } else {
1828
+ if (node.layoutAlign === "STRETCH") return null;
1829
+ if (!node.absoluteBoundingBox) return null;
1830
+ if (node.layoutAlign !== "INHERIT") return null;
1981
1831
  }
1982
- return { rules, configs };
1983
- }
1984
- function toRule(cr) {
1832
+ if (isExcludedName(node.name)) return null;
1985
1833
  return {
1986
- definition: {
1987
- id: cr.id,
1988
- name: cr.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
1989
- category: cr.category,
1990
- why: cr.why,
1991
- impact: cr.impact,
1992
- fix: cr.fix
1993
- },
1994
- check: createPatternCheck(cr)
1834
+ ruleId: fixedWidthInResponsiveContextDef.id,
1835
+ nodeId: node.id,
1836
+ nodePath: context.path.join(" > "),
1837
+ message: `"${node.name}" has fixed width inside Auto Layout`
1995
1838
  };
1996
- }
1997
- function createPatternCheck(cr) {
1998
- return (node, context) => {
1999
- if (node.type === "DOCUMENT" || node.type === "CANVAS") return null;
2000
- const match = cr.match;
2001
- if (match.type && !match.type.includes(node.type)) return null;
2002
- if (match.notType && match.notType.includes(node.type)) return null;
2003
- if (match.nameContains !== void 0 && !node.name.toLowerCase().includes(match.nameContains.toLowerCase())) return null;
2004
- if (match.nameNotContains !== void 0 && node.name.toLowerCase().includes(match.nameNotContains.toLowerCase())) return null;
2005
- if (match.namePattern !== void 0 && !new RegExp(match.namePattern, "i").test(node.name)) return null;
2006
- const bbox = node.absoluteBoundingBox;
2007
- if (match.minWidth !== void 0 && (!bbox || bbox.width < match.minWidth)) return null;
2008
- if (match.maxWidth !== void 0 && (!bbox || bbox.width > match.maxWidth)) return null;
2009
- if (match.minHeight !== void 0 && (!bbox || bbox.height < match.minHeight)) return null;
2010
- if (match.maxHeight !== void 0 && (!bbox || bbox.height > match.maxHeight)) return null;
2011
- if (match.hasAutoLayout === true && !node.layoutMode) return null;
2012
- if (match.hasAutoLayout === false && node.layoutMode) return null;
2013
- if (match.hasChildren === true && (!node.children || node.children.length === 0)) return null;
2014
- if (match.hasChildren === false && node.children && node.children.length > 0) return null;
2015
- if (match.minChildren !== void 0 && (!node.children || node.children.length < match.minChildren)) return null;
2016
- if (match.maxChildren !== void 0 && node.children && node.children.length > match.maxChildren) return null;
2017
- if (match.isComponent === true && node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
2018
- if (match.isComponent === false && (node.type === "COMPONENT" || node.type === "COMPONENT_SET")) return null;
2019
- if (match.isInstance === true && node.type !== "INSTANCE") return null;
2020
- if (match.isInstance === false && node.type === "INSTANCE") return null;
2021
- if (match.hasComponentId === true && !node.componentId) return null;
2022
- if (match.hasComponentId === false && node.componentId) return null;
2023
- if (match.isVisible === true && !node.visible) return null;
2024
- if (match.isVisible === false && node.visible) return null;
2025
- if (match.hasFills === true && (!node.fills || node.fills.length === 0)) return null;
2026
- if (match.hasFills === false && node.fills && node.fills.length > 0) return null;
2027
- if (match.hasStrokes === true && (!node.strokes || node.strokes.length === 0)) return null;
2028
- if (match.hasStrokes === false && node.strokes && node.strokes.length > 0) return null;
2029
- if (match.hasEffects === true && (!node.effects || node.effects.length === 0)) return null;
2030
- if (match.hasEffects === false && node.effects && node.effects.length > 0) return null;
2031
- if (match.minDepth !== void 0 && context.depth < match.minDepth) return null;
2032
- if (match.maxDepth !== void 0 && context.depth > match.maxDepth) return null;
2033
- const msg = (cr.message ?? `"${node.name}" matched custom rule "${cr.id}"`).replace(/\{name\}/g, node.name).replace(/\{type\}/g, node.type);
1839
+ };
1840
+ defineRule({
1841
+ definition: fixedWidthInResponsiveContextDef,
1842
+ check: fixedWidthInResponsiveContextCheck
1843
+ });
1844
+ var missingResponsiveBehaviorDef = {
1845
+ id: "missing-responsive-behavior",
1846
+ name: "Missing Responsive Behavior",
1847
+ category: "layout",
1848
+ why: "Elements without constraints won't adapt to different screen sizes",
1849
+ impact: "Layout will break or look wrong on different devices",
1850
+ fix: "Set appropriate constraints (left/right, top/bottom, scale, etc.)"
1851
+ };
1852
+ var missingResponsiveBehaviorCheck = (node, context) => {
1853
+ if (!isContainerNode(node)) return null;
1854
+ if (context.parent && hasAutoLayout(context.parent)) return null;
1855
+ if (context.depth < 2) return null;
1856
+ if (!hasAutoLayout(node) && !node.layoutAlign) {
2034
1857
  return {
2035
- ruleId: cr.id,
1858
+ ruleId: missingResponsiveBehaviorDef.id,
2036
1859
  nodeId: node.id,
2037
1860
  nodePath: context.path.join(" > "),
2038
- message: msg
1861
+ message: `"${node.name}" has no responsive behavior configured`
2039
1862
  };
2040
- };
2041
- }
2042
-
2043
- // src/core/monitoring/events.ts
2044
- var EVENT_PREFIX = "cic_";
2045
- var EVENTS = {
2046
- // Analysis
2047
- ANALYSIS_STARTED: `${EVENT_PREFIX}analysis_started`,
2048
- ANALYSIS_COMPLETED: `${EVENT_PREFIX}analysis_completed`,
2049
- ANALYSIS_FAILED: `${EVENT_PREFIX}analysis_failed`,
2050
- // Report
2051
- REPORT_GENERATED: `${EVENT_PREFIX}report_generated`,
2052
- COMMENT_POSTED: `${EVENT_PREFIX}comment_posted`,
2053
- COMMENT_FAILED: `${EVENT_PREFIX}comment_failed`,
2054
- // MCP
2055
- MCP_TOOL_CALLED: `${EVENT_PREFIX}mcp_tool_called`,
2056
- // CLI
2057
- CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
2058
- CLI_INIT: `${EVENT_PREFIX}cli_init`
2059
- };
2060
-
2061
- // src/core/monitoring/capture.ts
2062
- var monitoringEnabled = false;
2063
- var posthogApiKey;
2064
- var sentryDsn;
2065
- var distinctId = "anonymous";
2066
- var environment = "unknown";
2067
- var version = "unknown";
2068
- var commonProps = {};
2069
- function uuid4() {
2070
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
2071
- const r = Math.random() * 16 | 0;
2072
- const v = c === "x" ? r : r & 3 | 8;
2073
- return v.toString(16);
2074
- });
2075
- }
2076
- function parseSentryDsn(dsn) {
2077
- try {
2078
- const url = new URL(dsn);
2079
- const key = url.username;
2080
- const projectId = url.pathname.slice(1);
2081
- const host = url.protocol + "//" + url.host;
2082
- if (!key || !projectId) return null;
2083
- return { key, host, projectId };
2084
- } catch {
2085
- return null;
2086
- }
2087
- }
2088
- function initCapture(config2) {
2089
- if (config2.enabled === false) return;
2090
- if (!config2.posthogApiKey && !config2.sentryDsn) return;
2091
- monitoringEnabled = true;
2092
- posthogApiKey = config2.posthogApiKey;
2093
- sentryDsn = config2.sentryDsn;
2094
- distinctId = config2.distinctId ?? "anonymous";
2095
- environment = config2.environment ?? "unknown";
2096
- version = config2.version ?? "unknown";
2097
- commonProps = {
2098
- _sdk: "canicode",
2099
- _sdk_version: version,
2100
- _env: environment
2101
- };
2102
- }
2103
- function captureEvent(event, properties) {
2104
- if (!monitoringEnabled || !posthogApiKey) return;
2105
- try {
2106
- fetch("https://us.i.posthog.com/i/v0/e/", {
2107
- method: "POST",
2108
- headers: { "Content-Type": "application/json" },
2109
- body: JSON.stringify({
2110
- api_key: posthogApiKey,
2111
- event,
2112
- distinct_id: distinctId,
2113
- properties: { ...commonProps, ...properties },
2114
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2115
- })
2116
- }).catch(() => {
2117
- });
2118
- } catch {
2119
- }
2120
- }
2121
- function captureError(error, context) {
2122
- if (!monitoringEnabled) return;
2123
- if (sentryDsn) {
2124
- const parsed = parseSentryDsn(sentryDsn);
2125
- if (parsed) {
2126
- try {
2127
- const eventId = uuid4();
2128
- const envelope = [
2129
- JSON.stringify({ event_id: eventId, sent_at: (/* @__PURE__ */ new Date()).toISOString(), dsn: sentryDsn }),
2130
- JSON.stringify({ type: "event", content_type: "application/json" }),
2131
- JSON.stringify({
2132
- event_id: eventId,
2133
- exception: { values: [{ type: error.name, value: error.message }] },
2134
- platform: "node",
2135
- environment,
2136
- release: `canicode@${version}`,
2137
- timestamp: Date.now() / 1e3,
2138
- extra: context
2139
- })
2140
- ].join("\n");
2141
- fetch(`${parsed.host}/api/${parsed.projectId}/envelope/`, {
2142
- method: "POST",
2143
- headers: {
2144
- "Content-Type": "application/x-sentry-envelope",
2145
- "X-Sentry-Auth": `Sentry sentry_version=7, sentry_key=${parsed.key}`
2146
- },
2147
- body: envelope
2148
- }).catch(() => {
2149
- });
2150
- } catch {
2151
- }
2152
- }
2153
- }
2154
- captureEvent("cic_error", { error: error.message, ...context });
2155
- }
2156
- function shutdownCapture() {
2157
- monitoringEnabled = false;
2158
- posthogApiKey = void 0;
2159
- sentryDsn = void 0;
2160
- distinctId = "anonymous";
2161
- environment = "unknown";
2162
- version = "unknown";
2163
- commonProps = {};
2164
- }
2165
-
2166
- // src/core/monitoring/index.ts
2167
- function initMonitoring(config2) {
2168
- initCapture(config2);
2169
- }
2170
- function trackEvent(event, properties) {
2171
- try {
2172
- captureEvent(event, properties);
2173
- } catch {
2174
- }
2175
- }
2176
- function trackError(error, context) {
2177
- try {
2178
- captureError(error, context);
2179
- } catch {
2180
- }
2181
- }
2182
- function shutdownMonitoring() {
2183
- try {
2184
- shutdownCapture();
2185
- } catch {
2186
1863
  }
2187
- }
2188
-
2189
- // src/core/monitoring/keys.ts
2190
- var POSTHOG_API_KEY = "phc_rBFeG140KqJLpUnlpYDEFgdMM6JozZeqQsf9twXf5Dq" ;
2191
- var SENTRY_DSN = "https://80a836a8300b25f17ef5bbf23afb5b3a@o4511080656207872.ingest.us.sentry.io/4511080661319680" ;
2192
-
2193
- // src/core/rules/excluded-names.ts
2194
- var EXCLUDED_NAME_PATTERN = /(badge|close|dismiss|overlay|float|fab|dot|indicator|corner|decoration|tag|status|notification|icon|ico|image|asset|filter|dim|dimmed|bg|background|logo|avatar|divider|separator|nav|navigation|gnb|header|footer|sidebar|toolbar|modal|dialog|popup|toast|tooltip|dropdown|menu|sticky|spinner|loader|cursor|cta|chatbot|thumb|thumbnail|tabbar|tab-bar|statusbar|status-bar)/i;
2195
- function isExcludedName(name) {
2196
- return EXCLUDED_NAME_PATTERN.test(name);
2197
- }
2198
-
2199
- // src/core/rules/layout/index.ts
2200
- function isContainerNode(node) {
2201
- return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
2202
- }
2203
- function hasAutoLayout(node) {
2204
- return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
2205
- }
2206
- function hasTextContent(node) {
2207
- return node.type === "TEXT" || (node.children?.some((c) => c.type === "TEXT") ?? false);
2208
- }
2209
- var noAutoLayoutDef = {
2210
- id: "no-auto-layout",
2211
- name: "No Auto Layout",
1864
+ return null;
1865
+ };
1866
+ defineRule({
1867
+ definition: missingResponsiveBehaviorDef,
1868
+ check: missingResponsiveBehaviorCheck
1869
+ });
1870
+ var groupUsageDef = {
1871
+ id: "group-usage",
1872
+ name: "Group Usage",
2212
1873
  category: "layout",
2213
- why: "Frames without Auto Layout require manual positioning for every element",
2214
- impact: "Layout breaks on content changes, harder to maintain and scale",
2215
- fix: "Apply Auto Layout to the frame with appropriate direction and spacing"
1874
+ why: "Groups don't support Auto Layout and have limited layout control",
1875
+ impact: "Harder to maintain consistent spacing and alignment",
1876
+ fix: "Convert Group to Frame and apply Auto Layout"
2216
1877
  };
2217
- var noAutoLayoutCheck = (node, context) => {
2218
- if (node.type !== "FRAME") return null;
2219
- if (hasAutoLayout(node)) return null;
2220
- if (!node.children || node.children.length === 0) return null;
1878
+ var groupUsageCheck = (node, context) => {
1879
+ if (node.type !== "GROUP") return null;
2221
1880
  return {
2222
- ruleId: noAutoLayoutDef.id,
1881
+ ruleId: groupUsageDef.id,
2223
1882
  nodeId: node.id,
2224
1883
  nodePath: context.path.join(" > "),
2225
- message: `Frame "${node.name}" has no Auto Layout`
1884
+ message: `"${node.name}" is a Group - consider converting to Frame with Auto Layout`
2226
1885
  };
2227
1886
  };
2228
1887
  defineRule({
2229
- definition: noAutoLayoutDef,
2230
- check: noAutoLayoutCheck
1888
+ definition: groupUsageDef,
1889
+ check: groupUsageCheck
2231
1890
  });
2232
- var absolutePositionInAutoLayoutDef = {
2233
- id: "absolute-position-in-auto-layout",
2234
- name: "Absolute Position in Auto Layout",
1891
+ var fixedSizeInAutoLayoutDef = {
1892
+ id: "fixed-size-in-auto-layout",
1893
+ name: "Fixed Size in Auto Layout",
2235
1894
  category: "layout",
2236
- why: "Absolute positioning inside Auto Layout breaks the automatic flow",
2237
- impact: "Element will not respond to sibling changes, may overlap unexpectedly",
2238
- fix: "Remove absolute positioning or use proper Auto Layout alignment"
2239
- };
2240
- function isSmallRelativeToParent(node, parent) {
2241
- const nodeBB = node.absoluteBoundingBox;
2242
- const parentBB = parent.absoluteBoundingBox;
2243
- if (!nodeBB || !parentBB) return false;
2244
- if (parentBB.width === 0 || parentBB.height === 0) return false;
2245
- const widthRatio = nodeBB.width / parentBB.width;
2246
- const heightRatio = nodeBB.height / parentBB.height;
2247
- return widthRatio < 0.25 && heightRatio < 0.25;
2248
- }
2249
- var absolutePositionInAutoLayoutCheck = (node, context) => {
2250
- if (!context.parent) return null;
2251
- if (!hasAutoLayout(context.parent)) return null;
2252
- if (node.layoutPositioning !== "ABSOLUTE") return null;
2253
- if (node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION" || node.type === "LINE" || node.type === "ELLIPSE" || node.type === "STAR" || node.type === "REGULAR_POLYGON") return null;
2254
- if (isExcludedName(node.name)) return null;
2255
- if (isSmallRelativeToParent(node, context.parent)) return null;
2256
- if (context.parent.type === "COMPONENT") return null;
2257
- return {
2258
- ruleId: absolutePositionInAutoLayoutDef.id,
2259
- nodeId: node.id,
2260
- nodePath: context.path.join(" > "),
2261
- message: `"${node.name}" uses absolute positioning inside Auto Layout parent "${context.parent.name}". If intentional (badge, overlay, close button), rename to badge-*, overlay-*, close-* to suppress this warning.`
2262
- };
2263
- };
2264
- defineRule({
2265
- definition: absolutePositionInAutoLayoutDef,
2266
- check: absolutePositionInAutoLayoutCheck
2267
- });
2268
- var fixedWidthInResponsiveContextDef = {
2269
- id: "fixed-width-in-responsive-context",
2270
- name: "Fixed Width in Responsive Context",
2271
- category: "layout",
2272
- why: "Fixed width inside Auto Layout prevents responsive behavior",
2273
- impact: "Content will not adapt to container size changes",
2274
- fix: "Use 'Fill' or 'Hug' instead of fixed width"
2275
- };
2276
- var fixedWidthInResponsiveContextCheck = (node, context) => {
2277
- if (!context.parent) return null;
2278
- if (!hasAutoLayout(context.parent)) return null;
2279
- if (!isContainerNode(node)) return null;
2280
- if (node.layoutSizingHorizontal) {
2281
- if (node.layoutSizingHorizontal !== "FIXED") return null;
2282
- } else {
2283
- if (node.layoutAlign === "STRETCH") return null;
2284
- if (!node.absoluteBoundingBox) return null;
2285
- if (node.layoutAlign !== "INHERIT") return null;
2286
- }
2287
- if (isExcludedName(node.name)) return null;
2288
- return {
2289
- ruleId: fixedWidthInResponsiveContextDef.id,
2290
- nodeId: node.id,
2291
- nodePath: context.path.join(" > "),
2292
- message: `"${node.name}" has fixed width inside Auto Layout`
2293
- };
2294
- };
2295
- defineRule({
2296
- definition: fixedWidthInResponsiveContextDef,
2297
- check: fixedWidthInResponsiveContextCheck
2298
- });
2299
- var missingResponsiveBehaviorDef = {
2300
- id: "missing-responsive-behavior",
2301
- name: "Missing Responsive Behavior",
2302
- category: "layout",
2303
- why: "Elements without constraints won't adapt to different screen sizes",
2304
- impact: "Layout will break or look wrong on different devices",
2305
- fix: "Set appropriate constraints (left/right, top/bottom, scale, etc.)"
2306
- };
2307
- var missingResponsiveBehaviorCheck = (node, context) => {
2308
- if (!isContainerNode(node)) return null;
2309
- if (context.parent && hasAutoLayout(context.parent)) return null;
2310
- if (context.depth < 2) return null;
2311
- if (!hasAutoLayout(node) && !node.layoutAlign) {
2312
- return {
2313
- ruleId: missingResponsiveBehaviorDef.id,
2314
- nodeId: node.id,
2315
- nodePath: context.path.join(" > "),
2316
- message: `"${node.name}" has no responsive behavior configured`
2317
- };
2318
- }
2319
- return null;
2320
- };
2321
- defineRule({
2322
- definition: missingResponsiveBehaviorDef,
2323
- check: missingResponsiveBehaviorCheck
2324
- });
2325
- var groupUsageDef = {
2326
- id: "group-usage",
2327
- name: "Group Usage",
2328
- category: "layout",
2329
- why: "Groups don't support Auto Layout and have limited layout control",
2330
- impact: "Harder to maintain consistent spacing and alignment",
2331
- fix: "Convert Group to Frame and apply Auto Layout"
2332
- };
2333
- var groupUsageCheck = (node, context) => {
2334
- if (node.type !== "GROUP") return null;
2335
- return {
2336
- ruleId: groupUsageDef.id,
2337
- nodeId: node.id,
2338
- nodePath: context.path.join(" > "),
2339
- message: `"${node.name}" is a Group - consider converting to Frame with Auto Layout`
2340
- };
2341
- };
2342
- defineRule({
2343
- definition: groupUsageDef,
2344
- check: groupUsageCheck
2345
- });
2346
- var fixedSizeInAutoLayoutDef = {
2347
- id: "fixed-size-in-auto-layout",
2348
- name: "Fixed Size in Auto Layout",
2349
- category: "layout",
2350
- why: "Fixed sizes inside Auto Layout limit flexibility",
2351
- impact: "Element won't adapt to content or container changes",
2352
- fix: "Consider using 'Hug' for content-driven sizing"
1895
+ why: "Fixed sizes inside Auto Layout limit flexibility",
1896
+ impact: "Element won't adapt to content or container changes",
1897
+ fix: "Consider using 'Hug' for content-driven sizing"
2353
1898
  };
2354
1899
  var fixedSizeInAutoLayoutCheck = (node, context) => {
2355
1900
  if (!context.parent) return null;
@@ -3130,237 +2675,919 @@ var zIndexDependentLayoutCheck = (node, context) => {
3130
2675
  }
3131
2676
  }
3132
2677
  }
3133
- if (significantOverlapCount > 0) {
3134
- return {
3135
- ruleId: zIndexDependentLayoutDef.id,
3136
- nodeId: node.id,
3137
- nodePath: context.path.join(" > "),
3138
- message: `"${node.name}" uses layer stacking for layout (${significantOverlapCount} overlaps)`
3139
- };
2678
+ if (significantOverlapCount > 0) {
2679
+ return {
2680
+ ruleId: zIndexDependentLayoutDef.id,
2681
+ nodeId: node.id,
2682
+ nodePath: context.path.join(" > "),
2683
+ message: `"${node.name}" uses layer stacking for layout (${significantOverlapCount} overlaps)`
2684
+ };
2685
+ }
2686
+ return null;
2687
+ };
2688
+ defineRule({
2689
+ definition: zIndexDependentLayoutDef,
2690
+ check: zIndexDependentLayoutCheck
2691
+ });
2692
+ var missingLayoutHintDef = {
2693
+ id: "missing-layout-hint",
2694
+ name: "Missing Layout Hint",
2695
+ category: "ai-readability",
2696
+ why: "Complex nesting without Auto Layout makes structure unpredictable",
2697
+ impact: "AI may generate incorrect code due to ambiguous relationships",
2698
+ fix: "Add Auto Layout or simplify the nesting structure"
2699
+ };
2700
+ var missingLayoutHintCheck = (node, context) => {
2701
+ if (!isContainerNode2(node)) return null;
2702
+ if (hasAutoLayout2(node)) return null;
2703
+ if (!node.children || node.children.length === 0) return null;
2704
+ const nestedContainers = node.children.filter((c) => isContainerNode2(c));
2705
+ if (nestedContainers.length >= 2) {
2706
+ const withoutLayout = nestedContainers.filter((c) => !hasAutoLayout2(c));
2707
+ if (withoutLayout.length >= 2) {
2708
+ return {
2709
+ ruleId: missingLayoutHintDef.id,
2710
+ nodeId: node.id,
2711
+ nodePath: context.path.join(" > "),
2712
+ message: `"${node.name}" has ${withoutLayout.length} nested containers without layout hints`
2713
+ };
2714
+ }
2715
+ }
2716
+ return null;
2717
+ };
2718
+ defineRule({
2719
+ definition: missingLayoutHintDef,
2720
+ check: missingLayoutHintCheck
2721
+ });
2722
+ var invisibleLayerDef = {
2723
+ id: "invisible-layer",
2724
+ name: "Invisible Layer",
2725
+ category: "ai-readability",
2726
+ why: "Hidden layers add noise and may confuse analysis tools",
2727
+ impact: "Exported code may include unnecessary elements",
2728
+ fix: "Delete hidden layers or move them to a separate 'archive' page"
2729
+ };
2730
+ var invisibleLayerCheck = (node, context) => {
2731
+ if (node.visible !== false) return null;
2732
+ if (context.parent?.visible === false) return null;
2733
+ return {
2734
+ ruleId: invisibleLayerDef.id,
2735
+ nodeId: node.id,
2736
+ nodePath: context.path.join(" > "),
2737
+ message: `"${node.name}" is hidden - consider removing if not needed`
2738
+ };
2739
+ };
2740
+ defineRule({
2741
+ definition: invisibleLayerDef,
2742
+ check: invisibleLayerCheck
2743
+ });
2744
+ var emptyFrameDef = {
2745
+ id: "empty-frame",
2746
+ name: "Empty Frame",
2747
+ category: "ai-readability",
2748
+ why: "Empty frames add noise and may indicate incomplete design",
2749
+ impact: "Generates unnecessary wrapper elements in code",
2750
+ fix: "Remove the frame or add content"
2751
+ };
2752
+ var emptyFrameCheck = (node, context) => {
2753
+ if (node.type !== "FRAME") return null;
2754
+ if (node.children && node.children.length > 0) return null;
2755
+ if (node.absoluteBoundingBox) {
2756
+ const { width, height } = node.absoluteBoundingBox;
2757
+ if (width <= 48 && height <= 48) return null;
2758
+ }
2759
+ return {
2760
+ ruleId: emptyFrameDef.id,
2761
+ nodeId: node.id,
2762
+ nodePath: context.path.join(" > "),
2763
+ message: `"${node.name}" is an empty frame`
2764
+ };
2765
+ };
2766
+ defineRule({
2767
+ definition: emptyFrameDef,
2768
+ check: emptyFrameCheck
2769
+ });
2770
+
2771
+ // src/core/rules/handoff-risk/index.ts
2772
+ function hasAutoLayout3(node) {
2773
+ return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
2774
+ }
2775
+ function isContainerNode3(node) {
2776
+ return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
2777
+ }
2778
+ function isTextNode(node) {
2779
+ return node.type === "TEXT";
2780
+ }
2781
+ function isImageNode(node) {
2782
+ if (node.type === "RECTANGLE" && node.fills) {
2783
+ for (const fill of node.fills) {
2784
+ const fillObj = fill;
2785
+ if (fillObj["type"] === "IMAGE") return true;
2786
+ }
2787
+ }
2788
+ return false;
2789
+ }
2790
+ var hardcodeRiskDef = {
2791
+ id: "hardcode-risk",
2792
+ name: "Hardcode Risk",
2793
+ category: "handoff-risk",
2794
+ why: "Absolute positioning with fixed values creates inflexible layouts",
2795
+ impact: "Layout will break when content changes or on different screens",
2796
+ fix: "Use Auto Layout with relative positioning"
2797
+ };
2798
+ var hardcodeRiskCheck = (node, context) => {
2799
+ if (!isContainerNode3(node)) return null;
2800
+ if (node.layoutPositioning !== "ABSOLUTE") return null;
2801
+ if (context.parent && hasAutoLayout3(context.parent)) {
2802
+ return {
2803
+ ruleId: hardcodeRiskDef.id,
2804
+ nodeId: node.id,
2805
+ nodePath: context.path.join(" > "),
2806
+ message: `"${node.name}" uses absolute positioning with fixed values`
2807
+ };
2808
+ }
2809
+ return null;
2810
+ };
2811
+ defineRule({
2812
+ definition: hardcodeRiskDef,
2813
+ check: hardcodeRiskCheck
2814
+ });
2815
+ var textTruncationUnhandledDef = {
2816
+ id: "text-truncation-unhandled",
2817
+ name: "Text Truncation Unhandled",
2818
+ category: "handoff-risk",
2819
+ why: "Text nodes without truncation handling may overflow",
2820
+ impact: "Long text will break the layout",
2821
+ fix: "Set text truncation (ellipsis) or ensure container can grow"
2822
+ };
2823
+ var textTruncationUnhandledCheck = (node, context) => {
2824
+ if (!isTextNode(node)) return null;
2825
+ if (!context.parent) return null;
2826
+ if (!hasAutoLayout3(context.parent)) return null;
2827
+ if (node.absoluteBoundingBox) {
2828
+ const { width } = node.absoluteBoundingBox;
2829
+ if (node.characters && node.characters.length > 50 && width < 300) {
2830
+ return {
2831
+ ruleId: textTruncationUnhandledDef.id,
2832
+ nodeId: node.id,
2833
+ nodePath: context.path.join(" > "),
2834
+ message: `"${node.name}" may need text truncation handling`
2835
+ };
2836
+ }
2837
+ }
2838
+ return null;
2839
+ };
2840
+ defineRule({
2841
+ definition: textTruncationUnhandledDef,
2842
+ check: textTruncationUnhandledCheck
2843
+ });
2844
+ var imageNoPlaceholderDef = {
2845
+ id: "image-no-placeholder",
2846
+ name: "Image No Placeholder",
2847
+ category: "handoff-risk",
2848
+ why: "Images without placeholder state may cause layout shifts",
2849
+ impact: "Poor user experience during image loading",
2850
+ fix: "Define a placeholder state or background color"
2851
+ };
2852
+ var imageNoPlaceholderCheck = (node, context) => {
2853
+ if (!isImageNode(node)) return null;
2854
+ if (node.fills && Array.isArray(node.fills) && node.fills.length === 1) {
2855
+ const fill = node.fills[0];
2856
+ if (fill["type"] === "IMAGE") {
2857
+ return {
2858
+ ruleId: imageNoPlaceholderDef.id,
2859
+ nodeId: node.id,
2860
+ nodePath: context.path.join(" > "),
2861
+ message: `"${node.name}" image has no placeholder fill`
2862
+ };
2863
+ }
2864
+ }
2865
+ return null;
2866
+ };
2867
+ defineRule({
2868
+ definition: imageNoPlaceholderDef,
2869
+ check: imageNoPlaceholderCheck
2870
+ });
2871
+ var prototypeLinkInDesignDef = {
2872
+ id: "prototype-link-in-design",
2873
+ name: "Prototype Link in Design",
2874
+ category: "handoff-risk",
2875
+ why: "Prototype connections may affect how the design is interpreted",
2876
+ impact: "Developers may misunderstand which elements should be interactive",
2877
+ fix: "Document interactions separately or use clear naming"
2878
+ };
2879
+ var prototypeLinkInDesignCheck = (_node, _context) => {
2880
+ return null;
2881
+ };
2882
+ defineRule({
2883
+ definition: prototypeLinkInDesignDef,
2884
+ check: prototypeLinkInDesignCheck
2885
+ });
2886
+ var noDevStatusDef = {
2887
+ id: "no-dev-status",
2888
+ name: "No Dev Status",
2889
+ category: "handoff-risk",
2890
+ why: "Without dev status, developers cannot know if a design is ready",
2891
+ impact: "May implement designs that are still in progress",
2892
+ fix: "Mark frames as 'Ready for Dev' or 'Completed' when appropriate"
2893
+ };
2894
+ var noDevStatusCheck = (node, context) => {
2895
+ if (node.type !== "FRAME") return null;
2896
+ if (context.depth > 1) return null;
2897
+ if (node.devStatus) return null;
2898
+ return {
2899
+ ruleId: noDevStatusDef.id,
2900
+ nodeId: node.id,
2901
+ nodePath: context.path.join(" > "),
2902
+ message: `"${node.name}" has no dev status set`
2903
+ };
2904
+ };
2905
+ defineRule({
2906
+ definition: noDevStatusDef,
2907
+ check: noDevStatusCheck
2908
+ });
2909
+ var SamplingStrategySchema = z.enum(["all", "top-issues", "random"]);
2910
+ z.enum([
2911
+ "pending",
2912
+ "analyzing",
2913
+ "converting",
2914
+ "evaluating",
2915
+ "tuning",
2916
+ "completed",
2917
+ "failed"
2918
+ ]);
2919
+ z.object({
2920
+ input: z.string(),
2921
+ token: z.string().optional(),
2922
+ targetNodeId: z.string().optional(),
2923
+ maxConversionNodes: z.number().int().positive().default(20),
2924
+ samplingStrategy: SamplingStrategySchema.default("top-issues"),
2925
+ outputPath: z.string().default("logs/calibration/calibration-report.md")
2926
+ });
2927
+ z.object({
2928
+ nodeId: z.string(),
2929
+ nodePath: z.string(),
2930
+ totalScore: z.number(),
2931
+ issueCount: z.number(),
2932
+ flaggedRuleIds: z.array(z.string()),
2933
+ severities: z.array(z.string())
2934
+ });
2935
+ var DifficultySchema = z.enum(["easy", "moderate", "hard", "failed"]);
2936
+ var RuleRelatedStruggleSchema = z.object({
2937
+ ruleId: z.string(),
2938
+ description: z.string(),
2939
+ actualImpact: DifficultySchema
2940
+ });
2941
+ var UncoveredStruggleSchema = z.object({
2942
+ description: z.string(),
2943
+ suggestedCategory: z.string(),
2944
+ estimatedImpact: DifficultySchema
2945
+ });
2946
+ z.object({
2947
+ nodeId: z.string(),
2948
+ nodePath: z.string(),
2949
+ generatedCode: z.string(),
2950
+ difficulty: DifficultySchema,
2951
+ notes: z.string(),
2952
+ ruleRelatedStruggles: z.array(RuleRelatedStruggleSchema),
2953
+ uncoveredStruggles: z.array(UncoveredStruggleSchema),
2954
+ durationMs: z.number()
2955
+ });
2956
+ var MismatchTypeSchema = z.enum([
2957
+ "overscored",
2958
+ "underscored",
2959
+ "missing-rule",
2960
+ "validated"
2961
+ ]);
2962
+ z.object({
2963
+ type: MismatchTypeSchema,
2964
+ nodeId: z.string(),
2965
+ nodePath: z.string(),
2966
+ ruleId: z.string().optional(),
2967
+ currentScore: z.number().optional(),
2968
+ currentSeverity: SeveritySchema.optional(),
2969
+ actualDifficulty: DifficultySchema,
2970
+ reasoning: z.string()
2971
+ });
2972
+ var ConfidenceSchema = z.enum(["high", "medium", "low"]);
2973
+ z.object({
2974
+ ruleId: z.string(),
2975
+ currentScore: z.number(),
2976
+ proposedScore: z.number(),
2977
+ currentSeverity: SeveritySchema,
2978
+ proposedSeverity: SeveritySchema.optional(),
2979
+ reasoning: z.string(),
2980
+ confidence: ConfidenceSchema,
2981
+ supportingCases: z.number()
2982
+ });
2983
+ z.object({
2984
+ suggestedId: z.string(),
2985
+ category: z.string(),
2986
+ description: z.string(),
2987
+ suggestedSeverity: SeveritySchema,
2988
+ suggestedScore: z.number(),
2989
+ reasoning: z.string(),
2990
+ supportingCases: z.number()
2991
+ });
2992
+
2993
+ // src/core/report-html/index.ts
2994
+ function generateHtmlReport(file, result, scores, options) {
2995
+ const screenshotMap = new Map(
2996
+ (options?.nodeScreenshots ?? []).map((ns) => [ns.nodeId, ns])
2997
+ );
2998
+ const figmaToken = options?.figmaToken;
2999
+ const quickWins = getQuickWins(result.issues, 5);
3000
+ const issuesByCategory = groupIssuesByCategory(result.issues);
3001
+ return `<!DOCTYPE html>
3002
+ <html lang="en" class="antialiased">
3003
+ <head>
3004
+ <meta charset="UTF-8">
3005
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
3006
+ <title>CanICode Report \u2014 ${esc(file.name)}</title>
3007
+ <script src="https://cdn.tailwindcss.com"></script>
3008
+ <link rel="preconnect" href="https://fonts.googleapis.com">
3009
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
3010
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
3011
+ <script>
3012
+ tailwind.config = {
3013
+ theme: {
3014
+ extend: {
3015
+ fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'] },
3016
+ colors: {
3017
+ border: 'hsl(240 5.9% 90%)',
3018
+ ring: 'hsl(240 5.9% 10%)',
3019
+ background: 'hsl(0 0% 100%)',
3020
+ foreground: 'hsl(240 10% 3.9%)',
3021
+ muted: { DEFAULT: 'hsl(240 4.8% 95.9%)', foreground: 'hsl(240 3.8% 46.1%)' },
3022
+ card: { DEFAULT: 'hsl(0 0% 100%)', foreground: 'hsl(240 10% 3.9%)' },
3023
+ },
3024
+ borderRadius: { lg: '0.5rem', md: 'calc(0.5rem - 2px)', sm: 'calc(0.5rem - 4px)' },
3025
+ }
3026
+ }
3027
+ }
3028
+ </script>
3029
+ <style>
3030
+ details summary::-webkit-details-marker { display: none; }
3031
+ details summary::marker { content: ""; }
3032
+ details summary { list-style: none; }
3033
+ .gauge-fill { transition: stroke-dashoffset 0.8s cubic-bezier(0.4,0,0.2,1); }
3034
+ @media print {
3035
+ .no-print { display: none !important; }
3036
+ .topbar-print { position: static !important; background: white !important; color: hsl(240 10% 3.9%) !important; }
3037
+ }
3038
+ </style>
3039
+ </head>
3040
+ <body class="bg-muted font-sans text-foreground min-h-screen">
3041
+
3042
+ <!-- Top Bar -->
3043
+ <header class="topbar-print sticky top-0 z-50 bg-zinc-950 text-white border-b border-zinc-800">
3044
+ <div class="max-w-[960px] mx-auto px-6 py-3 flex items-center gap-4">
3045
+ <span class="font-semibold text-sm tracking-tight">CanICode</span>
3046
+ <span class="text-zinc-400 text-sm truncate">${esc(file.name)}</span>
3047
+ <span class="ml-auto text-zinc-500 text-xs no-print">${(/* @__PURE__ */ new Date()).toLocaleDateString()}</span>
3048
+ </div>
3049
+ </header>
3050
+
3051
+ <main class="max-w-[960px] mx-auto px-6 pb-16">
3052
+
3053
+ <!-- Overall Score -->
3054
+ <section class="flex flex-col items-center pt-12 pb-6">
3055
+ ${renderGaugeSvg(scores.overall.percentage, 200, 10, scores.overall.grade)}
3056
+ <div class="mt-3 text-center">
3057
+ <span class="text-lg font-semibold">${scores.overall.percentage}</span>
3058
+ <span class="text-muted-foreground text-sm ml-1">/ 100</span>
3059
+ </div>
3060
+ <p class="text-muted-foreground text-sm mt-1">Overall Score</p>
3061
+ </section>
3062
+
3063
+ <!-- Category Gauges -->
3064
+ <section class="bg-card border border-border rounded-lg shadow-sm p-6 mb-6">
3065
+ <div class="grid grid-cols-3 sm:grid-cols-6 gap-4">
3066
+ ${CATEGORIES.map((cat) => {
3067
+ const cs = scores.byCategory[cat];
3068
+ const desc = CATEGORY_DESCRIPTIONS[cat];
3069
+ return ` <a href="#cat-${cat}" class="flex flex-col items-center group relative cursor-pointer no-underline text-foreground hover:opacity-80 transition-opacity">
3070
+ ${renderGaugeSvg(cs.percentage, 100, 7)}
3071
+ <span class="text-xs font-medium mt-2.5 text-center leading-tight">${CATEGORY_LABELS[cat]}</span>
3072
+ <span class="text-[11px] text-muted-foreground">${cs.issueCount} issues</span>
3073
+ <div class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 hidden group-hover:block bg-zinc-900 text-white text-xs px-3 py-2 rounded-md whitespace-nowrap z-10 shadow-lg pointer-events-none">
3074
+ ${esc(desc)}
3075
+ <div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-zinc-900"></div>
3076
+ </div>
3077
+ </a>`;
3078
+ }).join("\n")}
3079
+ </div>
3080
+ </section>
3081
+
3082
+ <!-- Issue Summary -->
3083
+ <section class="bg-card border border-border rounded-lg shadow-sm p-4 mb-6">
3084
+ <div class="flex flex-wrap items-center justify-center gap-6">
3085
+ ${renderSummaryDot("bg-red-500", scores.summary.blocking, "Blocking")}
3086
+ ${renderSummaryDot("bg-amber-500", scores.summary.risk, "Risk")}
3087
+ ${renderSummaryDot("bg-zinc-400", scores.summary.missingInfo, "Missing Info")}
3088
+ ${renderSummaryDot("bg-green-500", scores.summary.suggestion, "Suggestion")}
3089
+ <div class="border-l border-border pl-6 flex items-center gap-2">
3090
+ <span class="text-xl font-bold tracking-tight">${scores.summary.totalIssues}</span>
3091
+ <span class="text-sm text-muted-foreground">Total</span>
3092
+ </div>
3093
+ </div>
3094
+ </section>
3095
+
3096
+ ${quickWins.length > 0 ? renderOpportunities(quickWins, file.fileKey) : ""}
3097
+
3098
+ <!-- Categories -->
3099
+ <div class="space-y-3">
3100
+ ${CATEGORIES.map((cat) => renderCategory(cat, scores, issuesByCategory.get(cat) ?? [], file.fileKey, screenshotMap, figmaToken)).join("\n")}
3101
+ </div>
3102
+
3103
+ <!-- Footer -->
3104
+ <footer class="mt-12 pt-6 border-t border-border text-center">
3105
+ <p class="text-sm text-muted-foreground">Generated by <span class="font-semibold text-foreground">CanICode</span> <span class="text-muted-foreground/60">v${version}</span></p>
3106
+ <p class="text-xs text-muted-foreground/60 mt-1">${(/* @__PURE__ */ new Date()).toLocaleString()} \xB7 ${result.nodeCount} nodes \xB7 Max depth ${result.maxDepth}</p>
3107
+ </footer>
3108
+
3109
+ </main>
3110
+
3111
+ ${figmaToken ? ` <script>
3112
+ const FIGMA_TOKEN = '${figmaToken}';
3113
+ async function postComment(btn) {
3114
+ const fileKey = btn.dataset.fileKey;
3115
+ const nodeId = btn.dataset.nodeId.replace(/-/g, ':');
3116
+ const rule = btn.dataset.rule;
3117
+ const message = btn.dataset.message;
3118
+ const path = btn.dataset.path;
3119
+ const fix = btn.dataset.fix;
3120
+ const why = btn.dataset.why;
3121
+ const impact = btn.dataset.impact;
3122
+
3123
+ const commentBody = '[CanICode] ' + rule + '\\n\\nFix: ' + fix + '\\nWhy: ' + why + '\\nImpact: ' + impact + '\\n\\n' + message + '\\nNode: ' + path;
3124
+
3125
+ btn.disabled = true;
3126
+ btn.textContent = 'Sending...';
3127
+
3128
+ try {
3129
+ const res = await fetch('https://api.figma.com/v1/files/' + fileKey + '/comments', {
3130
+ method: 'POST',
3131
+ headers: { 'X-FIGMA-TOKEN': FIGMA_TOKEN, 'Content-Type': 'application/json' },
3132
+ body: JSON.stringify({ message: commentBody, client_meta: { node_id: nodeId, node_offset: { x: 0, y: 0 } } }),
3133
+ });
3134
+ if (!res.ok) throw new Error(await res.text());
3135
+ btn.textContent = 'Sent \\u2713';
3136
+ btn.classList.remove('hover:bg-muted');
3137
+ btn.classList.add('text-green-600', 'border-green-500/30');
3138
+ } catch (e) {
3139
+ btn.textContent = 'Failed \\u2717';
3140
+ btn.classList.remove('hover:bg-muted');
3141
+ btn.classList.add('text-red-600', 'border-red-500/30');
3142
+ btn.disabled = false;
3143
+ console.error('Failed to post Figma comment:', e);
3144
+ }
3145
+ }
3146
+ </script>` : ""}
3147
+ </body>
3148
+ </html>`;
3149
+ }
3150
+ function renderSummaryDot(dotClass, count, label) {
3151
+ return `<div class="flex items-center gap-2">
3152
+ <span class="w-2.5 h-2.5 rounded-full ${dotClass}"></span>
3153
+ <span class="text-lg font-bold tracking-tight">${count}</span>
3154
+ <span class="text-sm text-muted-foreground">${label}</span>
3155
+ </div>`;
3156
+ }
3157
+ function renderOpportunities(issues, fileKey) {
3158
+ const maxAbs = issues.reduce((m, i) => Math.max(m, Math.abs(i.calculatedScore)), 1);
3159
+ return `
3160
+ <!-- Opportunities -->
3161
+ <section class="bg-card border border-border rounded-lg shadow-sm mb-6 overflow-hidden">
3162
+ <div class="px-6 py-4 border-b border-border">
3163
+ <h2 class="text-sm font-semibold flex items-center gap-2">
3164
+ <span class="w-2 h-2 rounded-full bg-red-500"></span>
3165
+ Opportunities
3166
+ </h2>
3167
+ <p class="text-xs text-muted-foreground mt-1">Top blocking issues \u2014 fix these first for the biggest improvement.</p>
3168
+ </div>
3169
+ <div class="divide-y divide-border">
3170
+ ${issues.map((issue) => {
3171
+ const def = issue.rule.definition;
3172
+ const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
3173
+ const barW = Math.round(Math.abs(issue.calculatedScore) / maxAbs * 100);
3174
+ return ` <div class="px-6 py-3 flex items-center gap-4 hover:bg-muted/50 transition-colors">
3175
+ <div class="flex-1 min-w-0">
3176
+ <div class="text-sm font-medium truncate">${esc(def.name)}</div>
3177
+ <div class="text-xs text-muted-foreground truncate mt-0.5">${esc(issue.violation.message)}</div>
3178
+ </div>
3179
+ <div class="w-32 flex items-center gap-2 shrink-0">
3180
+ <div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
3181
+ <div class="h-full bg-red-500 rounded-full" style="width:${barW}%"></div>
3182
+ </div>
3183
+ <span class="text-xs font-medium text-red-600 w-12 text-right">${issue.calculatedScore}</span>
3184
+ </div>
3185
+ <a href="${link}" target="_blank" rel="noopener" class="text-xs text-muted-foreground hover:text-foreground shrink-0 no-print">Figma \u2192</a>
3186
+ </div>`;
3187
+ }).join("\n")}
3188
+ </div>
3189
+ </section>`;
3190
+ }
3191
+ function renderCategory(cat, scores, issues, fileKey, screenshotMap, figmaToken) {
3192
+ const cs = scores.byCategory[cat];
3193
+ const hasProblems = issues.some((i) => i.config.severity === "blocking" || i.config.severity === "risk");
3194
+ const bySeverity = /* @__PURE__ */ new Map();
3195
+ for (const sev of SEVERITY_ORDER) bySeverity.set(sev, []);
3196
+ for (const issue of issues) bySeverity.get(issue.config.severity)?.push(issue);
3197
+ return `
3198
+ <details id="cat-${cat}" class="bg-card border border-border rounded-lg shadow-sm overflow-hidden group"${hasProblems ? " open" : ""}>
3199
+ <summary class="px-5 py-3.5 flex items-center gap-3 cursor-pointer hover:bg-muted/50 transition-colors select-none">
3200
+ <span class="inline-flex items-center justify-center w-10 h-6 rounded-md text-xs font-bold border ${scoreBadgeStyle(cs.percentage)}">${cs.percentage}</span>
3201
+ <div class="flex-1 min-w-0">
3202
+ <div class="text-sm font-semibold">${CATEGORY_LABELS[cat]}</div>
3203
+ <div class="text-xs text-muted-foreground">${esc(CATEGORY_DESCRIPTIONS[cat])}</div>
3204
+ </div>
3205
+ <span class="text-xs text-muted-foreground">${cs.issueCount} issues</span>
3206
+ <svg class="w-4 h-4 text-muted-foreground transition-transform group-open:rotate-180 shrink-0 no-print" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M19 9l-7 7-7-7"/></svg>
3207
+ </summary>
3208
+ <div class="border-t border-border">
3209
+ ${issues.length === 0 ? ' <div class="px-5 py-4 text-sm text-green-600 font-medium">No issues found</div>' : SEVERITY_ORDER.filter((sev) => (bySeverity.get(sev)?.length ?? 0) > 0).map((sev) => renderSeverityGroup(sev, bySeverity.get(sev) ?? [], fileKey, screenshotMap, figmaToken)).join("\n")}
3210
+ </div>
3211
+ </details>`;
3212
+ }
3213
+ function renderSeverityGroup(sev, issues, fileKey, screenshotMap, figmaToken) {
3214
+ return ` <div class="px-5 py-3">
3215
+ <div class="flex items-center gap-2 mb-2">
3216
+ <span class="w-2 h-2 rounded-full ${severityDot(sev)}"></span>
3217
+ <span class="text-xs font-semibold uppercase tracking-wider">${SEVERITY_LABELS[sev]}</span>
3218
+ <span class="text-xs text-muted-foreground ml-auto">${issues.length}</span>
3219
+ </div>
3220
+ <div class="space-y-1">
3221
+ ${issues.map((issue) => renderIssueRow(issue, fileKey, screenshotMap, figmaToken)).join("\n")}
3222
+ </div>
3223
+ </div>`;
3224
+ }
3225
+ function renderIssueRow(issue, fileKey, screenshotMap, figmaToken) {
3226
+ const sev = issue.config.severity;
3227
+ const def = issue.rule.definition;
3228
+ const link = buildFigmaDeepLink(fileKey, issue.violation.nodeId);
3229
+ const screenshot = screenshotMap.get(issue.violation.nodeId);
3230
+ const screenshotHtml = screenshot ? `<div class="mt-3"><a href="${link}" target="_blank" rel="noopener"><img src="data:image/png;base64,${screenshot.screenshotBase64}" alt="${esc(screenshot.nodePath)}" class="max-w-[240px] border border-border rounded-md"></a></div>` : "";
3231
+ return ` <details class="border border-border rounded-md overflow-hidden">
3232
+ <summary class="flex items-center gap-2.5 px-3 py-2 text-sm cursor-pointer hover:bg-muted/50 transition-colors">
3233
+ <span class="w-1.5 h-1.5 rounded-full ${severityDot(sev)} shrink-0"></span>
3234
+ <span class="font-medium shrink-0">${esc(def.name)}</span>
3235
+ <span class="text-muted-foreground truncate text-xs flex-1">${esc(issue.violation.message)}</span>
3236
+ <span class="inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded border ${severityBadge(sev)} shrink-0">${issue.calculatedScore}</span>
3237
+ </summary>
3238
+ <div class="px-3 py-3 bg-muted/30 border-t border-border text-sm space-y-2">
3239
+ <div class="font-mono text-xs text-muted-foreground break-all">${esc(issue.violation.nodePath)}</div>
3240
+ <div class="text-muted-foreground leading-relaxed space-y-1">
3241
+ <p><span class="font-medium text-foreground">Why:</span> ${esc(def.why)}</p>
3242
+ <p><span class="font-medium text-foreground">Impact:</span> ${esc(def.impact)}</p>
3243
+ <p><span class="font-medium text-foreground">Fix:</span> ${esc(def.fix)}</p>
3244
+ </div>${screenshotHtml}
3245
+ <div class="flex items-center gap-2 mt-1 no-print">
3246
+ <a href="${link}" target="_blank" rel="noopener" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium border border-border rounded-md hover:bg-muted transition-colors">Open in Figma <span>\u2192</span></a>${figmaToken ? `
3247
+ <button onclick="postComment(this)" data-file-key="${esc(fileKey)}" data-node-id="${esc(issue.violation.nodeId)}" data-rule="${esc(def.name)}" data-message="${esc(issue.violation.message)}" data-path="${esc(issue.violation.nodePath)}" data-fix="${esc(def.fix)}" data-why="${esc(def.why)}" data-impact="${esc(def.impact)}" class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium border border-border rounded-md hover:bg-muted transition-colors cursor-pointer">Comment on Figma</button>` : ""}
3248
+ </div>
3249
+ </div>
3250
+ </details>`;
3251
+ }
3252
+ function getQuickWins(issues, limit) {
3253
+ return issues.filter((issue) => issue.config.severity === "blocking").sort((a, b) => a.calculatedScore - b.calculatedScore).slice(0, limit);
3254
+ }
3255
+ function groupIssuesByCategory(issues) {
3256
+ const grouped = /* @__PURE__ */ new Map();
3257
+ for (const category of CATEGORIES) grouped.set(category, []);
3258
+ for (const issue of issues) grouped.get(issue.rule.definition.category).push(issue);
3259
+ return grouped;
3260
+ }
3261
+ var esc = escapeHtml;
3262
+ var RuleOverrideSchema = z.object({
3263
+ score: z.number().int().max(0).optional(),
3264
+ severity: SeveritySchema.optional(),
3265
+ enabled: z.boolean().optional()
3266
+ });
3267
+ var ConfigFileSchema = z.object({
3268
+ excludeNodeTypes: z.array(z.string()).optional(),
3269
+ excludeNodeNames: z.array(z.string()).optional(),
3270
+ gridBase: z.number().int().positive().optional(),
3271
+ colorTolerance: z.number().int().positive().optional(),
3272
+ rules: z.record(z.string(), RuleOverrideSchema).optional()
3273
+ });
3274
+ async function loadConfigFile(filePath) {
3275
+ const absPath = resolve(filePath);
3276
+ const raw = await readFile(absPath, "utf-8");
3277
+ const parsed = JSON.parse(raw);
3278
+ return ConfigFileSchema.parse(parsed);
3279
+ }
3280
+ function mergeConfigs(base, overrides) {
3281
+ const merged = { ...base };
3282
+ if (overrides.gridBase !== void 0) {
3283
+ for (const [id, config2] of Object.entries(merged)) {
3284
+ if (config2.options && "gridBase" in config2.options) {
3285
+ merged[id] = {
3286
+ ...config2,
3287
+ options: { ...config2.options, gridBase: overrides.gridBase }
3288
+ };
3289
+ }
3290
+ }
3291
+ }
3292
+ if (overrides.colorTolerance !== void 0) {
3293
+ for (const [id, config2] of Object.entries(merged)) {
3294
+ if (config2.options && "tolerance" in config2.options) {
3295
+ merged[id] = {
3296
+ ...config2,
3297
+ options: { ...config2.options, tolerance: overrides.colorTolerance }
3298
+ };
3299
+ }
3300
+ }
3140
3301
  }
3141
- return null;
3142
- };
3143
- defineRule({
3144
- definition: zIndexDependentLayoutDef,
3145
- check: zIndexDependentLayoutCheck
3146
- });
3147
- var missingLayoutHintDef = {
3148
- id: "missing-layout-hint",
3149
- name: "Missing Layout Hint",
3150
- category: "ai-readability",
3151
- why: "Complex nesting without Auto Layout makes structure unpredictable",
3152
- impact: "AI may generate incorrect code due to ambiguous relationships",
3153
- fix: "Add Auto Layout or simplify the nesting structure"
3154
- };
3155
- var missingLayoutHintCheck = (node, context) => {
3156
- if (!isContainerNode2(node)) return null;
3157
- if (hasAutoLayout2(node)) return null;
3158
- if (!node.children || node.children.length === 0) return null;
3159
- const nestedContainers = node.children.filter((c) => isContainerNode2(c));
3160
- if (nestedContainers.length >= 2) {
3161
- const withoutLayout = nestedContainers.filter((c) => !hasAutoLayout2(c));
3162
- if (withoutLayout.length >= 2) {
3163
- return {
3164
- ruleId: missingLayoutHintDef.id,
3165
- nodeId: node.id,
3166
- nodePath: context.path.join(" > "),
3167
- message: `"${node.name}" has ${withoutLayout.length} nested containers without layout hints`
3168
- };
3302
+ if (overrides.rules) {
3303
+ for (const [ruleId, override] of Object.entries(overrides.rules)) {
3304
+ const existing = merged[ruleId];
3305
+ if (existing) {
3306
+ merged[ruleId] = {
3307
+ ...existing,
3308
+ ...override.score !== void 0 && { score: override.score },
3309
+ ...override.severity !== void 0 && { severity: override.severity },
3310
+ ...override.enabled !== void 0 && { enabled: override.enabled }
3311
+ };
3312
+ }
3169
3313
  }
3170
3314
  }
3171
- return null;
3172
- };
3173
- defineRule({
3174
- definition: missingLayoutHintDef,
3175
- check: missingLayoutHintCheck
3315
+ return merged;
3316
+ }
3317
+ var MatchConditionSchema = z.object({
3318
+ // Node type conditions
3319
+ type: z.array(z.string()).optional(),
3320
+ notType: z.array(z.string()).optional(),
3321
+ // Name conditions (case-insensitive, substring match)
3322
+ nameContains: z.string().optional(),
3323
+ nameNotContains: z.string().optional(),
3324
+ namePattern: z.string().optional(),
3325
+ // Size conditions
3326
+ minWidth: z.number().optional(),
3327
+ maxWidth: z.number().optional(),
3328
+ minHeight: z.number().optional(),
3329
+ maxHeight: z.number().optional(),
3330
+ // Layout conditions
3331
+ hasAutoLayout: z.boolean().optional(),
3332
+ hasChildren: z.boolean().optional(),
3333
+ minChildren: z.number().optional(),
3334
+ maxChildren: z.number().optional(),
3335
+ // Component conditions
3336
+ isComponent: z.boolean().optional(),
3337
+ isInstance: z.boolean().optional(),
3338
+ hasComponentId: z.boolean().optional(),
3339
+ // Visibility
3340
+ isVisible: z.boolean().optional(),
3341
+ // Fill/style conditions
3342
+ hasFills: z.boolean().optional(),
3343
+ hasStrokes: z.boolean().optional(),
3344
+ hasEffects: z.boolean().optional(),
3345
+ // Depth condition
3346
+ minDepth: z.number().optional(),
3347
+ maxDepth: z.number().optional()
3176
3348
  });
3177
- var invisibleLayerDef = {
3178
- id: "invisible-layer",
3179
- name: "Invisible Layer",
3180
- category: "ai-readability",
3181
- why: "Hidden layers add noise and may confuse analysis tools",
3182
- impact: "Exported code may include unnecessary elements",
3183
- fix: "Delete hidden layers or move them to a separate 'archive' page"
3184
- };
3185
- var invisibleLayerCheck = (node, context) => {
3186
- if (node.visible !== false) return null;
3187
- if (context.parent?.visible === false) return null;
3188
- return {
3189
- ruleId: invisibleLayerDef.id,
3190
- nodeId: node.id,
3191
- nodePath: context.path.join(" > "),
3192
- message: `"${node.name}" is hidden - consider removing if not needed`
3193
- };
3194
- };
3195
- defineRule({
3196
- definition: invisibleLayerDef,
3197
- check: invisibleLayerCheck
3349
+ var CustomRuleSchema = z.object({
3350
+ id: z.string(),
3351
+ category: CategorySchema,
3352
+ severity: SeveritySchema,
3353
+ score: z.number().int().max(0),
3354
+ match: MatchConditionSchema,
3355
+ message: z.string().optional(),
3356
+ why: z.string(),
3357
+ impact: z.string(),
3358
+ fix: z.string(),
3359
+ // Backward compat: silently ignore the old prompt field
3360
+ prompt: z.string().optional()
3198
3361
  });
3199
- var emptyFrameDef = {
3200
- id: "empty-frame",
3201
- name: "Empty Frame",
3202
- category: "ai-readability",
3203
- why: "Empty frames add noise and may indicate incomplete design",
3204
- impact: "Generates unnecessary wrapper elements in code",
3205
- fix: "Remove the frame or add content"
3206
- };
3207
- var emptyFrameCheck = (node, context) => {
3208
- if (node.type !== "FRAME") return null;
3209
- if (node.children && node.children.length > 0) return null;
3210
- if (node.absoluteBoundingBox) {
3211
- const { width, height } = node.absoluteBoundingBox;
3212
- if (width <= 48 && height <= 48) return null;
3362
+ var CustomRulesFileSchema = z.array(CustomRuleSchema);
3363
+
3364
+ // src/core/rules/custom/custom-rule-loader.ts
3365
+ async function loadCustomRules(filePath) {
3366
+ const absPath = resolve(filePath);
3367
+ const raw = await readFile(absPath, "utf-8");
3368
+ const parsed = JSON.parse(raw);
3369
+ const customRules = CustomRulesFileSchema.parse(parsed);
3370
+ const rules = [];
3371
+ const configs = {};
3372
+ for (const cr of customRules) {
3373
+ if (!cr.match) continue;
3374
+ rules.push(toRule(cr));
3375
+ configs[cr.id] = {
3376
+ severity: cr.severity,
3377
+ score: cr.score,
3378
+ enabled: true
3379
+ };
3213
3380
  }
3381
+ return { rules, configs };
3382
+ }
3383
+ function toRule(cr) {
3214
3384
  return {
3215
- ruleId: emptyFrameDef.id,
3216
- nodeId: node.id,
3217
- nodePath: context.path.join(" > "),
3218
- message: `"${node.name}" is an empty frame`
3385
+ definition: {
3386
+ id: cr.id,
3387
+ name: cr.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
3388
+ category: cr.category,
3389
+ why: cr.why,
3390
+ impact: cr.impact,
3391
+ fix: cr.fix
3392
+ },
3393
+ check: createPatternCheck(cr)
3394
+ };
3395
+ }
3396
+ function createPatternCheck(cr) {
3397
+ return (node, context) => {
3398
+ if (node.type === "DOCUMENT" || node.type === "CANVAS") return null;
3399
+ const match = cr.match;
3400
+ if (match.type && !match.type.includes(node.type)) return null;
3401
+ if (match.notType && match.notType.includes(node.type)) return null;
3402
+ if (match.nameContains !== void 0 && !node.name.toLowerCase().includes(match.nameContains.toLowerCase())) return null;
3403
+ if (match.nameNotContains !== void 0 && node.name.toLowerCase().includes(match.nameNotContains.toLowerCase())) return null;
3404
+ if (match.namePattern !== void 0 && !new RegExp(match.namePattern, "i").test(node.name)) return null;
3405
+ const bbox = node.absoluteBoundingBox;
3406
+ if (match.minWidth !== void 0 && (!bbox || bbox.width < match.minWidth)) return null;
3407
+ if (match.maxWidth !== void 0 && (!bbox || bbox.width > match.maxWidth)) return null;
3408
+ if (match.minHeight !== void 0 && (!bbox || bbox.height < match.minHeight)) return null;
3409
+ if (match.maxHeight !== void 0 && (!bbox || bbox.height > match.maxHeight)) return null;
3410
+ if (match.hasAutoLayout === true && !node.layoutMode) return null;
3411
+ if (match.hasAutoLayout === false && node.layoutMode) return null;
3412
+ if (match.hasChildren === true && (!node.children || node.children.length === 0)) return null;
3413
+ if (match.hasChildren === false && node.children && node.children.length > 0) return null;
3414
+ if (match.minChildren !== void 0 && (!node.children || node.children.length < match.minChildren)) return null;
3415
+ if (match.maxChildren !== void 0 && node.children && node.children.length > match.maxChildren) return null;
3416
+ if (match.isComponent === true && node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") return null;
3417
+ if (match.isComponent === false && (node.type === "COMPONENT" || node.type === "COMPONENT_SET")) return null;
3418
+ if (match.isInstance === true && node.type !== "INSTANCE") return null;
3419
+ if (match.isInstance === false && node.type === "INSTANCE") return null;
3420
+ if (match.hasComponentId === true && !node.componentId) return null;
3421
+ if (match.hasComponentId === false && node.componentId) return null;
3422
+ if (match.isVisible === true && !node.visible) return null;
3423
+ if (match.isVisible === false && node.visible) return null;
3424
+ if (match.hasFills === true && (!node.fills || node.fills.length === 0)) return null;
3425
+ if (match.hasFills === false && node.fills && node.fills.length > 0) return null;
3426
+ if (match.hasStrokes === true && (!node.strokes || node.strokes.length === 0)) return null;
3427
+ if (match.hasStrokes === false && node.strokes && node.strokes.length > 0) return null;
3428
+ if (match.hasEffects === true && (!node.effects || node.effects.length === 0)) return null;
3429
+ if (match.hasEffects === false && node.effects && node.effects.length > 0) return null;
3430
+ if (match.minDepth !== void 0 && context.depth < match.minDepth) return null;
3431
+ if (match.maxDepth !== void 0 && context.depth > match.maxDepth) return null;
3432
+ const msg = (cr.message ?? `"${node.name}" matched custom rule "${cr.id}"`).replace(/\{name\}/g, node.name).replace(/\{type\}/g, node.type);
3433
+ return {
3434
+ ruleId: cr.id,
3435
+ nodeId: node.id,
3436
+ nodePath: context.path.join(" > "),
3437
+ message: msg
3438
+ };
3219
3439
  };
3440
+ }
3441
+
3442
+ // src/core/monitoring/events.ts
3443
+ var EVENT_PREFIX = "cic_";
3444
+ var EVENTS = {
3445
+ // Analysis
3446
+ ANALYSIS_STARTED: `${EVENT_PREFIX}analysis_started`,
3447
+ ANALYSIS_COMPLETED: `${EVENT_PREFIX}analysis_completed`,
3448
+ ANALYSIS_FAILED: `${EVENT_PREFIX}analysis_failed`,
3449
+ // Report
3450
+ REPORT_GENERATED: `${EVENT_PREFIX}report_generated`,
3451
+ COMMENT_POSTED: `${EVENT_PREFIX}comment_posted`,
3452
+ COMMENT_FAILED: `${EVENT_PREFIX}comment_failed`,
3453
+ // MCP
3454
+ MCP_TOOL_CALLED: `${EVENT_PREFIX}mcp_tool_called`,
3455
+ // CLI
3456
+ CLI_COMMAND: `${EVENT_PREFIX}cli_command`,
3457
+ CLI_INIT: `${EVENT_PREFIX}cli_init`
3220
3458
  };
3221
- defineRule({
3222
- definition: emptyFrameDef,
3223
- check: emptyFrameCheck
3224
- });
3225
3459
 
3226
- // src/core/rules/handoff-risk/index.ts
3227
- function hasAutoLayout3(node) {
3228
- return node.layoutMode !== void 0 && node.layoutMode !== "NONE";
3460
+ // src/core/monitoring/capture.ts
3461
+ var monitoringEnabled = false;
3462
+ var posthogApiKey;
3463
+ var sentryDsn;
3464
+ var distinctId = "anonymous";
3465
+ var environment = "unknown";
3466
+ var version2 = "unknown";
3467
+ var commonProps = {};
3468
+ function uuid4() {
3469
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
3470
+ const r = Math.random() * 16 | 0;
3471
+ const v = c === "x" ? r : r & 3 | 8;
3472
+ return v.toString(16);
3473
+ });
3229
3474
  }
3230
- function isContainerNode3(node) {
3231
- return node.type === "FRAME" || node.type === "GROUP" || node.type === "COMPONENT";
3475
+ function parseSentryDsn(dsn) {
3476
+ try {
3477
+ const url = new URL(dsn);
3478
+ const key = url.username;
3479
+ const projectId = url.pathname.slice(1);
3480
+ const host = url.protocol + "//" + url.host;
3481
+ if (!key || !projectId) return null;
3482
+ return { key, host, projectId };
3483
+ } catch {
3484
+ return null;
3485
+ }
3232
3486
  }
3233
- function isTextNode(node) {
3234
- return node.type === "TEXT";
3487
+ function initCapture(config2) {
3488
+ if (config2.enabled === false) return;
3489
+ if (!config2.posthogApiKey && !config2.sentryDsn) return;
3490
+ monitoringEnabled = true;
3491
+ posthogApiKey = config2.posthogApiKey;
3492
+ sentryDsn = config2.sentryDsn;
3493
+ distinctId = config2.distinctId ?? "anonymous";
3494
+ environment = config2.environment ?? "unknown";
3495
+ version2 = config2.version ?? "unknown";
3496
+ commonProps = {
3497
+ _sdk: "canicode",
3498
+ _sdk_version: version2,
3499
+ _env: environment
3500
+ };
3235
3501
  }
3236
- function isImageNode(node) {
3237
- if (node.type === "RECTANGLE" && node.fills) {
3238
- for (const fill of node.fills) {
3239
- const fillObj = fill;
3240
- if (fillObj["type"] === "IMAGE") return true;
3502
+ function captureEvent(event, properties) {
3503
+ if (!monitoringEnabled || !posthogApiKey) return;
3504
+ try {
3505
+ fetch("https://us.i.posthog.com/i/v0/e/", {
3506
+ method: "POST",
3507
+ headers: { "Content-Type": "application/json" },
3508
+ body: JSON.stringify({
3509
+ api_key: posthogApiKey,
3510
+ event,
3511
+ distinct_id: distinctId,
3512
+ properties: { ...commonProps, ...properties },
3513
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3514
+ })
3515
+ }).catch(() => {
3516
+ });
3517
+ } catch {
3518
+ }
3519
+ }
3520
+ function captureError(error, context) {
3521
+ if (!monitoringEnabled) return;
3522
+ if (sentryDsn) {
3523
+ const parsed = parseSentryDsn(sentryDsn);
3524
+ if (parsed) {
3525
+ try {
3526
+ const eventId = uuid4();
3527
+ const envelope = [
3528
+ JSON.stringify({ event_id: eventId, sent_at: (/* @__PURE__ */ new Date()).toISOString(), dsn: sentryDsn }),
3529
+ JSON.stringify({ type: "event", content_type: "application/json" }),
3530
+ JSON.stringify({
3531
+ event_id: eventId,
3532
+ exception: { values: [{ type: error.name, value: error.message }] },
3533
+ platform: "node",
3534
+ environment,
3535
+ release: `canicode@${version2}`,
3536
+ timestamp: Date.now() / 1e3,
3537
+ extra: context
3538
+ })
3539
+ ].join("\n");
3540
+ fetch(`${parsed.host}/api/${parsed.projectId}/envelope/`, {
3541
+ method: "POST",
3542
+ headers: {
3543
+ "Content-Type": "application/x-sentry-envelope",
3544
+ "X-Sentry-Auth": `Sentry sentry_version=7, sentry_key=${parsed.key}`
3545
+ },
3546
+ body: envelope
3547
+ }).catch(() => {
3548
+ });
3549
+ } catch {
3550
+ }
3241
3551
  }
3242
3552
  }
3243
- return false;
3553
+ captureEvent("cic_error", { error: error.message, ...context });
3244
3554
  }
3245
- var hardcodeRiskDef = {
3246
- id: "hardcode-risk",
3247
- name: "Hardcode Risk",
3248
- category: "handoff-risk",
3249
- why: "Absolute positioning with fixed values creates inflexible layouts",
3250
- impact: "Layout will break when content changes or on different screens",
3251
- fix: "Use Auto Layout with relative positioning"
3252
- };
3253
- var hardcodeRiskCheck = (node, context) => {
3254
- if (!isContainerNode3(node)) return null;
3255
- if (node.layoutPositioning !== "ABSOLUTE") return null;
3256
- if (context.parent && hasAutoLayout3(context.parent)) {
3257
- return {
3258
- ruleId: hardcodeRiskDef.id,
3259
- nodeId: node.id,
3260
- nodePath: context.path.join(" > "),
3261
- message: `"${node.name}" uses absolute positioning with fixed values`
3262
- };
3555
+ function shutdownCapture() {
3556
+ monitoringEnabled = false;
3557
+ posthogApiKey = void 0;
3558
+ sentryDsn = void 0;
3559
+ distinctId = "anonymous";
3560
+ environment = "unknown";
3561
+ version2 = "unknown";
3562
+ commonProps = {};
3563
+ }
3564
+
3565
+ // src/core/monitoring/index.ts
3566
+ function initMonitoring(config2) {
3567
+ initCapture(config2);
3568
+ }
3569
+ function trackEvent(event, properties) {
3570
+ try {
3571
+ captureEvent(event, properties);
3572
+ } catch {
3263
3573
  }
3264
- return null;
3265
- };
3266
- defineRule({
3267
- definition: hardcodeRiskDef,
3268
- check: hardcodeRiskCheck
3269
- });
3270
- var textTruncationUnhandledDef = {
3271
- id: "text-truncation-unhandled",
3272
- name: "Text Truncation Unhandled",
3273
- category: "handoff-risk",
3274
- why: "Text nodes without truncation handling may overflow",
3275
- impact: "Long text will break the layout",
3276
- fix: "Set text truncation (ellipsis) or ensure container can grow"
3277
- };
3278
- var textTruncationUnhandledCheck = (node, context) => {
3279
- if (!isTextNode(node)) return null;
3280
- if (!context.parent) return null;
3281
- if (!hasAutoLayout3(context.parent)) return null;
3282
- if (node.absoluteBoundingBox) {
3283
- const { width } = node.absoluteBoundingBox;
3284
- if (node.characters && node.characters.length > 50 && width < 300) {
3285
- return {
3286
- ruleId: textTruncationUnhandledDef.id,
3287
- nodeId: node.id,
3288
- nodePath: context.path.join(" > "),
3289
- message: `"${node.name}" may need text truncation handling`
3290
- };
3291
- }
3574
+ }
3575
+ function trackError(error, context) {
3576
+ try {
3577
+ captureError(error, context);
3578
+ } catch {
3292
3579
  }
3293
- return null;
3294
- };
3295
- defineRule({
3296
- definition: textTruncationUnhandledDef,
3297
- check: textTruncationUnhandledCheck
3298
- });
3299
- var imageNoPlaceholderDef = {
3300
- id: "image-no-placeholder",
3301
- name: "Image No Placeholder",
3302
- category: "handoff-risk",
3303
- why: "Images without placeholder state may cause layout shifts",
3304
- impact: "Poor user experience during image loading",
3305
- fix: "Define a placeholder state or background color"
3306
- };
3307
- var imageNoPlaceholderCheck = (node, context) => {
3308
- if (!isImageNode(node)) return null;
3309
- if (node.fills && Array.isArray(node.fills) && node.fills.length === 1) {
3310
- const fill = node.fills[0];
3311
- if (fill["type"] === "IMAGE") {
3312
- return {
3313
- ruleId: imageNoPlaceholderDef.id,
3314
- nodeId: node.id,
3315
- nodePath: context.path.join(" > "),
3316
- message: `"${node.name}" image has no placeholder fill`
3317
- };
3318
- }
3580
+ }
3581
+ function shutdownMonitoring() {
3582
+ try {
3583
+ shutdownCapture();
3584
+ } catch {
3319
3585
  }
3320
- return null;
3321
- };
3322
- defineRule({
3323
- definition: imageNoPlaceholderDef,
3324
- check: imageNoPlaceholderCheck
3325
- });
3326
- var prototypeLinkInDesignDef = {
3327
- id: "prototype-link-in-design",
3328
- name: "Prototype Link in Design",
3329
- category: "handoff-risk",
3330
- why: "Prototype connections may affect how the design is interpreted",
3331
- impact: "Developers may misunderstand which elements should be interactive",
3332
- fix: "Document interactions separately or use clear naming"
3333
- };
3334
- var prototypeLinkInDesignCheck = (_node, _context) => {
3335
- return null;
3336
- };
3337
- defineRule({
3338
- definition: prototypeLinkInDesignDef,
3339
- check: prototypeLinkInDesignCheck
3340
- });
3341
- var noDevStatusDef = {
3342
- id: "no-dev-status",
3343
- name: "No Dev Status",
3344
- category: "handoff-risk",
3345
- why: "Without dev status, developers cannot know if a design is ready",
3346
- impact: "May implement designs that are still in progress",
3347
- fix: "Mark frames as 'Ready for Dev' or 'Completed' when appropriate"
3348
- };
3349
- var noDevStatusCheck = (node, context) => {
3350
- if (node.type !== "FRAME") return null;
3351
- if (context.depth > 1) return null;
3352
- if (node.devStatus) return null;
3353
- return {
3354
- ruleId: noDevStatusDef.id,
3355
- nodeId: node.id,
3356
- nodePath: context.path.join(" > "),
3357
- message: `"${node.name}" has no dev status set`
3358
- };
3359
- };
3360
- defineRule({
3361
- definition: noDevStatusDef,
3362
- check: noDevStatusCheck
3363
- });
3586
+ }
3587
+
3588
+ // src/core/monitoring/keys.ts
3589
+ var POSTHOG_API_KEY = "phc_rBFeG140KqJLpUnlpYDEFgdMM6JozZeqQsf9twXf5Dq" ;
3590
+ var SENTRY_DSN = "https://80a836a8300b25f17ef5bbf23afb5b3a@o4511080656207872.ingest.us.sentry.io/4511080661319680" ;
3364
3591
 
3365
3592
  // src/mcp/server.ts
3366
3593
  config();
@@ -3452,10 +3679,10 @@ IMPORTANT \u2014 Before calling this tool, check which data source is available:
3452
3679
  const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}`;
3453
3680
  ensureReportsDir();
3454
3681
  const reportPath = `${getReportsDir()}/report-${ts}-${file.fileKey}.html`;
3455
- await new Promise((resolve4, reject) => {
3682
+ await new Promise((resolve6, reject) => {
3456
3683
  writeFile(reportPath, html, "utf-8", (err) => {
3457
3684
  if (err) reject(err);
3458
- else resolve4();
3685
+ else resolve6();
3459
3686
  });
3460
3687
  });
3461
3688
  const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
@@ -3478,6 +3705,7 @@ IMPORTANT \u2014 Before calling this tool, check which data source is available:
3478
3705
  type: "text",
3479
3706
  text: JSON.stringify(
3480
3707
  {
3708
+ version: pkg.version,
3481
3709
  fileName: file.name,
3482
3710
  nodeCount: result.nodeCount,
3483
3711
  maxDepth: result.maxDepth,
@@ -3570,16 +3798,16 @@ Use this when the user asks about customization, configuration, rule settings, o
3570
3798
  },
3571
3799
  async ({ topic }) => {
3572
3800
  const { readFile: readFile4 } = await import('fs/promises');
3573
- const { resolve: resolve4, dirname } = await import('path');
3801
+ const { resolve: resolve6, dirname: dirname2 } = await import('path');
3574
3802
  const { fileURLToPath } = await import('url');
3575
3803
  try {
3576
- const __dirname = dirname(fileURLToPath(import.meta.url));
3577
- const docPath = resolve4(__dirname, "../../docs/CUSTOMIZATION.md");
3804
+ const __dirname = dirname2(fileURLToPath(import.meta.url));
3805
+ const docPath = resolve6(__dirname, "../../docs/CUSTOMIZATION.md");
3578
3806
  let content;
3579
3807
  try {
3580
3808
  content = await readFile4(docPath, "utf-8");
3581
3809
  } catch {
3582
- const altPath = resolve4(__dirname, "../docs/CUSTOMIZATION.md");
3810
+ const altPath = resolve6(__dirname, "../docs/CUSTOMIZATION.md");
3583
3811
  content = await readFile4(altPath, "utf-8");
3584
3812
  }
3585
3813
  if (topic && topic !== "all") {
@@ -3598,7 +3826,9 @@ Use this when the user asks about customization, configuration, rule settings, o
3598
3826
  }
3599
3827
  }
3600
3828
  return {
3601
- content: [{ type: "text", text: content }]
3829
+ content: [{ type: "text", text: `canicode v${pkg.version}
3830
+
3831
+ ${content}` }]
3602
3832
  };
3603
3833
  } catch {
3604
3834
  return {