/**
* Badge Component Generator
*
* Generates a single Badge ComponentSet in Figma with variant property.
* Reads variant definitions from component-registry.json (the source of truth).
*/
import {
createTextNode,
bindStrokeToVariable,
getVariableByName,
createModeSection,
createRowLabel,
setWhiteTextColor,
bindTextColorToVariable,
bindFillToVariableWithOpacity,
SECTION_PADDING,
SECTION_GAP,
SECTION_TITLE,
GRID_LAYOUT,
SECTION_LAYOUT,
DASH_PATTERN,
} from "./shared";
import { parseTailwindClasses } from "../parsers/tailwind-to-figma";
import { logInfo, logWarn } from "../logger";
// Import variant data from the registry (generated by build:ai-metadata)
import registry from "@cloudflare/kumo/ai/component-registry.json";
const badgeComponent = registry.components.Badge;
const badgeProps = badgeComponent.props;
const variantProp = badgeProps.variant as {
values: string[];
classes: Record<string, string>;
descriptions: Record<string, string>;
default: string;
};
/**
* Badge base styles from KUMO_BADGE_BASE_STYLES in badge.tsx
* Read from component-registry.json (the source of truth)
*/
const BADGE_BASE_STYLES = badgeComponent.baseStyles as string;
/**
* TESTABLE EXPORTS - Pure functions that return intermediate data
* These functions compute data without calling Figma APIs, enabling snapshot tests.
*/
/**
* Get variant configuration from registry
*/
export function getBadgeVariantConfig() {
return {
values: variantProp.values,
classes: variantProp.classes,
descriptions: variantProp.descriptions,
default: variantProp.default,
};
}
/**
* Get parsed base styles
*/
export function getBadgeParsedBaseStyles() {
return parseTailwindClasses(BADGE_BASE_STYLES);
}
/**
* Get parsed styles for a specific variant
*/
export function getBadgeParsedVariantStyles(variant: string) {
const classes = variantProp.classes[variant] || "";
return {
variant,
classes,
description: variantProp.descriptions[variant] || "",
parsed: parseTailwindClasses(classes),
};
}
/**
* Get all variant data (for snapshot testing)
* Returns intermediate data before Figma API calls
*/
export function getAllBadgeVariantData() {
const baseStyles = getBadgeParsedBaseStyles();
const config = getBadgeVariantConfig();
return {
baseStyles: {
raw: BADGE_BASE_STYLES,
parsed: baseStyles,
},
variants: config.values.map((variant) => {
const variantData = getBadgeParsedVariantStyles(variant);
return {
...variantData,
// Layout calculations
layout: {
layoutMode: "HORIZONTAL",
primaryAxisAlignItems: "CENTER",
counterAxisAlignItems: "CENTER",
paddingLeft: baseStyles.paddingX ?? 8,
paddingRight: baseStyles.paddingX ?? 8,
paddingTop: baseStyles.paddingY ?? 2,
paddingBottom: baseStyles.paddingY ?? 2,
cornerRadius: baseStyles.borderRadius ?? 9999,
primaryAxisSizingMode: "AUTO",
counterAxisSizingMode: "AUTO",
},
// Text properties
text: {
fontSize: baseStyles.fontSize ?? 12,
fontWeight: baseStyles.fontWeight ?? 500,
},
};
}),
};
}
/**
* Create a single Badge component with the specified variant
*/
async function createBadgeComponent(variant: string): Promise<ComponentNode> {
const classes = variantProp.classes[variant] || "";
const description = variantProp.descriptions[variant] || "";
// Parse base styles and variant-specific styles
const baseStyles = parseTailwindClasses(BADGE_BASE_STYLES);
const variantStyles = parseTailwindClasses(classes);
// Create component
const component = figma.createComponent();
component.name = "variant=" + variant;
component.description = description;
// Set up auto-layout
component.layoutMode = "HORIZONTAL";
component.primaryAxisAlignItems = "CENTER";
component.counterAxisAlignItems = "CENTER";
// Apply paddingX with fallback
if (baseStyles.paddingX !== undefined) {
component.paddingLeft = baseStyles.paddingX;
component.paddingRight = baseStyles.paddingX;
} else {
logWarn("Badge: paddingX not found in baseStyles, using fallback 8");
component.paddingLeft = 8;
component.paddingRight = 8;
}
// Apply paddingY with fallback
if (baseStyles.paddingY !== undefined) {
component.paddingTop = baseStyles.paddingY;
component.paddingBottom = baseStyles.paddingY;
} else {
logWarn("Badge: paddingY not found in baseStyles, using fallback 2");
component.paddingTop = 2;
component.paddingBottom = 2;
}
component.primaryAxisSizingMode = "AUTO";
component.counterAxisSizingMode = "AUTO";
// Apply borderRadius with fallback
if (baseStyles.borderRadius !== undefined) {
component.cornerRadius = baseStyles.borderRadius;
} else {
logWarn("Badge: borderRadius not found in baseStyles, using fallback 9999");
component.cornerRadius = 9999;
}
// Apply fill from variant (handles opacity suffix like "color-kumo-info/20")
if (variantStyles.fillVariable) {
bindFillToVariableWithOpacity(component, variantStyles.fillVariable);
} else {
// Transparent background
component.fills = [];
}
// Apply border if variant has one
if (variantStyles.hasBorder && variantStyles.strokeVariable) {
const strokeVar = getVariableByName(variantStyles.strokeVariable);
if (strokeVar) {
// Apply strokeWeight with fallback
let strokeWeight: number;
if (variantStyles.strokeWeight !== undefined) {
strokeWeight = variantStyles.strokeWeight;
} else {
logWarn(
"Badge: strokeWeight not found in variantStyles, using fallback 1",
);
strokeWeight = 1;
}
bindStrokeToVariable(component, strokeVar.id, strokeWeight);
if (variantStyles.borderStyle === "dashed") {
// Apply dashPattern with fallback
if (variantStyles.dashPattern !== undefined) {
component.dashPattern = variantStyles.dashPattern;
} else {
logWarn(
"Badge: dashPattern not found in variantStyles, using centralized DASH_PATTERN.standard",
);
component.dashPattern = [...DASH_PATTERN.standard];
}
}
}
}
// Create text label with fallbacks
let fontSize: number;
if (baseStyles.fontSize !== undefined) {
fontSize = baseStyles.fontSize;
} else {
logWarn("Badge: fontSize not found in baseStyles, using fallback 12");
fontSize = 12;
}
let fontWeight: number;
if (baseStyles.fontWeight !== undefined) {
fontWeight = baseStyles.fontWeight;
} else {
logWarn("Badge: fontWeight not found in baseStyles, using fallback 500");
fontWeight = 500;
}
const textNode = await createTextNode("Badge", fontSize, fontWeight);
textNode.name = "Label";
// Apply text color from variant
if (variantStyles.isWhiteText) {
setWhiteTextColor(textNode);
} else if (variantStyles.textVariable) {
const textVar = getVariableByName(variantStyles.textVariable);
if (textVar) {
bindTextColorToVariable(textNode, textVar.id);
}
}
component.appendChild(textNode);
return component;
}
/**
* Generate Badge ComponentSet with variant property
*
* Creates a single "Badge" ComponentSet with variants derived from
* component-registry.json. Creates both light and dark mode sections.
*
* @param startY - Y position to start placing the section
* @returns The Y position after this section (for next section placement)
*/
export async function generateBadgeComponents(startY: number): Promise<number> {
if (startY === undefined) startY = 100;
// Get variant keys from the registry
const variants = variantProp.values;
const components: ComponentNode[] = [];
// Track row labels: { y, text }
const rowLabels: { y: number; text: string }[] = [];
// Layout spacing - vertical layout with labels
const rowGap = GRID_LAYOUT.rowGap.medium;
const labelColumnWidth = GRID_LAYOUT.labelColumnWidth.medium;
// Track position for laying out components vertically
let currentY = 0;
for (let i = 0; i < variants.length; i++) {
const variant = variants[i];
const component = await createBadgeComponent(variant);
// Record row label
rowLabels.push({ y: currentY, text: "variant=" + variant });
// Position each component vertically with label offset
component.x = labelColumnWidth;
component.y = currentY;
currentY += component.height + rowGap;
components.push(component);
}
// Combine all variants into a single ComponentSet
const componentSet = figma.combineAsVariants(components, figma.currentPage);
componentSet.name = "Badge";
componentSet.description = "Badge component with variant styles";
// Calculate content dimensions (add label column width)
const contentWidth = componentSet.width + labelColumnWidth;
const contentHeight = componentSet.height;
// Content Y offset to make room for title inside frame
const contentYOffset = SECTION_TITLE.height;
// Create light mode section
const lightSection = createModeSection(figma.currentPage, "Badge", "light");
lightSection.frame.resize(
contentWidth + SECTION_PADDING * 2,
contentHeight + SECTION_PADDING * 2 + contentYOffset,
);
// Create dark mode section
const darkSection = createModeSection(figma.currentPage, "Badge", "dark");
darkSection.frame.resize(
contentWidth + SECTION_PADDING * 2,
contentHeight + SECTION_PADDING * 2 + contentYOffset,
);
// Add title inside each frame
// Move ComponentSet into light section frame
lightSection.frame.appendChild(componentSet);
componentSet.x = SECTION_PADDING + labelColumnWidth;
componentSet.y = SECTION_PADDING + contentYOffset;
// Add row labels to light section
for (const label of rowLabels) {
const labelNode = await createRowLabel(
label.text,
SECTION_PADDING,
SECTION_PADDING +
contentYOffset +
label.y +
GRID_LAYOUT.labelVerticalOffset.sm, // Small offset to vertically center with badge
);
lightSection.frame.appendChild(labelNode);
}
// Create instances for dark section
// Note: component positions are relative to ComponentSet after combineAsVariants
// We need to add labelColumnWidth to match the light section layout
for (const component of components) {
const instance = component.createInstance();
instance.x = component.x + SECTION_PADDING + labelColumnWidth;
instance.y = component.y + SECTION_PADDING + contentYOffset;
darkSection.frame.appendChild(instance);
}
// Add row labels to dark section
for (const label of rowLabels) {
const labelNode = await createRowLabel(
label.text,
SECTION_PADDING,
SECTION_PADDING +
contentYOffset +
label.y +
GRID_LAYOUT.labelVerticalOffset.sm,
);
darkSection.frame.appendChild(labelNode);
}
// Resize sections to fit content with padding
const totalWidth = contentWidth + SECTION_PADDING * 2;
const totalHeight = contentHeight + SECTION_PADDING * 2 + contentYOffset;
lightSection.section.resizeWithoutConstraints(totalWidth, totalHeight);
darkSection.section.resizeWithoutConstraints(totalWidth, totalHeight);
// Position sections at startY (no title offset needed since title is inside)
lightSection.frame.x = SECTION_LAYOUT.startX;
lightSection.frame.y = startY;
darkSection.frame.x =
lightSection.frame.x + totalWidth + SECTION_LAYOUT.modeGap;
darkSection.frame.y = startY;
logInfo(
"✅ Generated Badge ComponentSet with " +
variants.length +
" variants (light + dark)",
);
return startY + totalHeight + SECTION_GAP;
}cloudflare/kumo
Publicmirrored fromhttps://github.com/cloudflare/kumoAvailable
packages/kumo-figma/src/generators/badge.ts
378lines · modepreview